=> {
+ try {
+ return await writeAuditLog(c, input);
+ } catch (error) {
+ if (error instanceof AuditLogError) throw error;
+ try {
+ await c
+ .get("log")
+ ?.error(
+ `[audit] Could not record "${input.action}" on ${input.target.type}: ${
+ error instanceof Error ? error.name : "unknown error"
+ }`,
+ );
+ } catch {
+ // The logger writes to the same database; nothing more to do.
+ }
+
+ return null;
+ }
+};
diff --git a/packages/vitnode/src/api/lib/plugin.ts b/packages/vitnode/src/api/lib/plugin.ts
index 91f677fc3a..cdf98637b2 100644
--- a/packages/vitnode/src/api/lib/plugin.ts
+++ b/packages/vitnode/src/api/lib/plugin.ts
@@ -7,6 +7,7 @@ import type { RegisteredContentType } from "@/content/registry";
import type { AnyContentModel } from "@/content/server/model";
import type { ContentNotificationsConfig } from "@/content/server/notifications/define";
import type { AnyContentTypeDefinition } from "@/content/types";
+import type { AuditActionDefinition } from "@/lib/audit/actions";
import type { ApiPluginContract } from "@/lib/fetcher/contract";
import type { LocaleMessagesMap } from "@/lib/i18n/types";
import type { PluginManifest } from "@/lib/plugin-manifest";
@@ -66,6 +67,8 @@ export interface BuildPluginApiReturn<
readonly BaseBuildModuleReturn[],
> {
aiActions?: AnyAiActionDefinition[];
+ /** The audit actions the plugin records, for the Activity Logs filters. */
+ auditActions?: readonly AuditActionDefinition[];
blocks?: BlockPluginSource;
contentContainers?: AnyContentContainerDefinition[];
contentModels?: AnyContentModel[];
@@ -147,6 +150,7 @@ export function buildApiPlugin<
const Modules extends readonly BuildModuleReturn
[] = readonly [],
>({
aiActions,
+ auditActions,
blocks,
contentNotifications,
editablePages,
@@ -163,6 +167,12 @@ export function buildApiPlugin<
* `runAsSystem()`; admins configure them and grant their permission per role.
*/
aiActions?: AnyAiActionDefinition[];
+ /**
+ * The audit actions the plugin records with `c.get("audit").record()`, from
+ * `defineAuditActions`. Optional: an undeclared action is still recorded and
+ * shown - declaring it only lists it in the Activity Logs action filter.
+ */
+ auditActions?: readonly AuditActionDefinition[];
/** The object the plugin's own widgets (`blocks`) module exports; its `pluginId` must match. */
blocks?: BlockPluginSource;
/**
@@ -311,6 +321,7 @@ export function buildApiPlugin<
return {
pluginId,
aiActions,
+ auditActions,
blocks,
editablePages: registeredPages.map(entry => entry.page),
manifest,
diff --git a/packages/vitnode/src/api/middlewares/global.middleware.ts b/packages/vitnode/src/api/middlewares/global.middleware.ts
index 58ec340dd4..4ccbfade88 100644
--- a/packages/vitnode/src/api/middlewares/global.middleware.ts
+++ b/packages/vitnode/src/api/middlewares/global.middleware.ts
@@ -20,6 +20,7 @@ import { DEFAULT_SESSION_COOKIE_NAME } from "@/api/lib/auth-cookie";
import { CacheModel } from "@/api/lib/cache";
import { MemoryCacheStore } from "@/api/lib/cache-memory";
import { AIModel } from "@/api/models/ai";
+import { AuditModel } from "@/api/models/audit";
import { EmailModel } from "@/api/models/email";
import { EventsModel } from "@/api/models/events";
import { I18nModel } from "@/api/models/i18n";
@@ -52,6 +53,7 @@ import { manifest as coreManifest } from "@/manifest";
import { realtime, runWithRealtimeKeepAlive } from "@/ws/registry";
import type { AiActionRegistry } from "../lib/ai/registry";
+import type { RegisteredAuditAction } from "../lib/audit/registry";
import type { BuildCronReturn } from "../lib/cron";
import type { RegisteredEditablePage } from "../lib/editable-pages";
import type { EventListenerConfig } from "../lib/events";
@@ -73,6 +75,7 @@ import type { SSOApiPlugin } from "../models/sso";
import { assertAiModelPricing } from "../lib/ai/pricing";
import { assertContentAiActions, collectAiActions } from "../lib/ai/registry";
+import { collectAuditActions } from "../lib/audit/registry";
import { resolveClientIp } from "../lib/client-ip";
import { collectCronJobs } from "../lib/cron";
import {
@@ -158,6 +161,11 @@ export interface EnvVariablesVitNode {
adminSessionExpiresAt: Date | null;
/** The AI model registry and action runner: see {@link AIModel}. */
ai: AIModel;
+ /**
+ * Records who did what to which resource in the central audit log (the
+ * AdminCP staff logs): see {@link AuditModel}.
+ */
+ audit: AuditModel;
/** Plugin-scoped JSON cache, Redis-backed when the API config has `redis`, a no-op otherwise. */
cache: CacheModel;
/**
@@ -168,6 +176,8 @@ export interface EnvVariablesVitNode {
core: {
ai?: VitNodeApiConfig["ai"];
aiActions: AiActionRegistry;
+ /** Every declared audit action - core's, the Content Engine's and each plugin's. */
+ auditActions: RegisteredAuditAction[];
authorization: {
adminCookieExpires: number;
adminCookieName: string;
@@ -394,6 +404,8 @@ export const globalMiddleware = ({
const aiActionsMetadata = collectAiActions(plugins);
+ const auditActionsMetadata = collectAuditActions(plugins);
+
const eventsMetadata: EventListenerConfig[] = plugins.flatMap(plugin =>
(plugin.events ?? []).map(listener => ({
...listener,
@@ -580,6 +592,7 @@ export const globalMiddleware = ({
{
ai,
aiActions: aiActionsMetadata,
+ auditActions: auditActionsMetadata,
i18n: i18nMetadata,
metadata,
email,
@@ -659,6 +672,7 @@ export const globalMiddleware = ({
}
c.set("db", dbProvider);
c.set("ai", new AIModel(c));
+ c.set("audit", new AuditModel(c));
c.set("cache", new CacheModel(cacheClient, c, memoryCache));
c.set("email", new EmailModel(c));
c.set("events", new EventsModel(c));
diff --git a/packages/vitnode/src/api/models/audit.ts b/packages/vitnode/src/api/models/audit.ts
new file mode 100644
index 0000000000..0cee5c93f5
--- /dev/null
+++ b/packages/vitnode/src/api/models/audit.ts
@@ -0,0 +1,130 @@
+import type { Context } from "hono";
+
+import type {
+ AuditArea,
+ AuditChanges,
+ AuditScope,
+ AuditTarget,
+} from "@/lib/audit/const";
+import type { AuditDiffArgs } from "@/lib/audit/diff";
+
+import { auditDiff } from "@/lib/audit/diff";
+
+import type { AuditDatabase } from "../lib/audit/write";
+
+import { writeAuditLog, writeAuditLogBestEffort } from "../lib/audit/write";
+
+/** One operation to record with `c.get("audit").record()`. */
+export interface AuditRecordInput {
+ /**
+ * A stable identifier, lowercase words joined by dots, unique within the
+ * plugin: `topic.moved`, `category.updated`. It is stored as-is and is what
+ * the UI translates, so never rename one that has shipped.
+ */
+ action: string;
+ /**
+ * Where the operation was performed. Defaults to `admin` on an AdminCP
+ * request, `public` for a signed-in user and `system` otherwise - pass
+ * `moderator` from moderation tools, which files the entry in the
+ * moderators' log instead of the admins'.
+ */
+ area?: AuditArea;
+ /** Before/after values, usually from `c.get("audit").diff()`. Sensitive fields are always redacted. */
+ changes?: AuditChanges | null;
+ /**
+ * Small, non-personal facts worth keeping (`{ fromForumId: 3 }`). Sanitized
+ * and size-capped; never pass request bodies, errors or secrets.
+ */
+ metadata?: Record;
+ /** Defaults to the plugin of the route handling the request. */
+ pluginId?: string;
+ /**
+ * The moderation scope the target lives in, as the moderation registry
+ * names it (`{ family: "forum", id: 4 }`).
+ */
+ scope?: AuditScope | null;
+ target: AuditTarget;
+}
+
+export interface AuditRecordOptions {
+ /**
+ * Log a failed write to the technical log instead of throwing. Only for
+ * operations that are not security-relevant; never with `tx`.
+ */
+ bestEffort?: boolean;
+ /**
+ * The transaction of the change being recorded. The entry then commits and
+ * rolls back with it, and a failed write fails the change - use it for
+ * anything security-relevant.
+ */
+ tx?: AuditDatabase;
+}
+
+/**
+ * `c.get("audit")`: records who did what, when and to which resource in the
+ * central audit log shown in AdminCP -> Staff -> Administrators / Moderators -> Logs.
+ * Server-only.
+ *
+ * The actor, IP address and correlation id always come from the request (or
+ * from a trusted `withAuditContext` frame for background work), never from the
+ * input. Content Engine writes are recorded automatically - call this for
+ * your plugin's own operations.
+ *
+ * @example
+ * ```ts
+ * await c.get("db").transaction(async tx => {
+ * const [before] = await tx.select().from(forum_topics).where(eq(forum_topics.id, id));
+ * const [after] = await tx
+ * .update(forum_topics)
+ * .set({ forumId })
+ * .where(eq(forum_topics.id, id))
+ * .returning();
+ *
+ * await c.get("audit").record(
+ * {
+ * action: "topic.moved",
+ * area: "moderator",
+ * target: { type: "topic", id, label: after.title },
+ * changes: c.get("audit").diff({ before, after, include: ["forumId"] }),
+ * scope: { family: "forum", id: forumId },
+ * },
+ * { tx },
+ * );
+ * });
+ * ```
+ */
+export class AuditModel {
+ constructor(c: Context) {
+ this.c = c;
+ }
+
+ protected readonly c: Context;
+
+ /**
+ * Structured before/after changes between two objects - see `auditDiff`
+ * in `@vitnode/core/lib/audit/diff`.
+ */
+ diff(
+ args: AuditDiffArgs,
+ ): AuditChanges | null {
+ return auditDiff(args);
+ }
+
+ /**
+ * Writes one entry.
+ *
+ * @returns The entry's id, or `null` when it was folded into an enclosing
+ * operation, or when a best-effort write failed.
+ * @throws `AuditLogError` when the input is malformed (an invalid action or
+ * target type) - a bug, so it is never swallowed. Database errors are thrown
+ * too unless `bestEffort` is set.
+ */
+ async record(
+ input: AuditRecordInput,
+ { bestEffort = false, tx }: AuditRecordOptions = {},
+ ): Promise {
+ if (!bestEffort || tx) return await writeAuditLog(this.c, input, tx);
+
+ return await writeAuditLogBestEffort(this.c, input);
+ }
+}
diff --git a/packages/vitnode/src/api/models/notifications/admin.ts b/packages/vitnode/src/api/models/notifications/admin.ts
index 1596ac3b60..22165cc74a 100644
--- a/packages/vitnode/src/api/models/notifications/admin.ts
+++ b/packages/vitnode/src/api/models/notifications/admin.ts
@@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
import type { NotificationTypePolicy } from "@/api/lib/notifications/preferences";
+import { writeAuditLogBestEffort } from "@/api/lib/audit/write";
import {
isNotificationEmailAvailable,
isNotificationInAppAvailable,
@@ -17,6 +18,7 @@ import {
core_notifications,
} from "@/database/notifications";
import { core_queue } from "@/database/queue";
+import { auditDiff } from "@/lib/audit/diff";
import type { NotificationsContext } from "./shared";
@@ -236,6 +238,11 @@ export const updateNotificationTypePolicy = async (
}
await upsertNotificationSetting(c, typeSettingsKey(typeId), { ...merged });
+ await writeAuditLogBestEffort(c, {
+ action: "notifications.type_updated",
+ changes: auditDiff({ after: merged, before: policies.get(typeId) ?? {} }),
+ target: { id: typeId, label: typeId, type: "notification_type" },
+ });
await c.get("events").emit("notifications.type.updated", { typeId });
return merged;
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts
new file mode 100644
index 0000000000..575b625ec6
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/activity-logs.admin.module.ts
@@ -0,0 +1,25 @@
+import { buildModule } from "@/api/builders";
+
+import { activityLogDetailAdminRoute } from "./routes/detail.route";
+import { activityLogEntityAdminRoute } from "./routes/entity.route";
+import { exportActivityLogsAdminRoute } from "./routes/export.route";
+import { activityLogFiltersAdminRoute } from "./routes/filters.route";
+import { listActivityLogsAdminRoute } from "./routes/list.route";
+import {
+ activityLogSettingsAdminRoute,
+ updateActivityLogSettingsAdminRoute,
+} from "./routes/settings.route";
+
+export const activityLogsAdminModule = buildModule({
+ name: "activity-logs",
+ // The fixed paths first, so `/{id}` never shadows them.
+ routes: [
+ listActivityLogsAdminRoute,
+ activityLogFiltersAdminRoute,
+ activityLogEntityAdminRoute,
+ exportActivityLogsAdminRoute,
+ activityLogSettingsAdminRoute,
+ updateActivityLogSettingsAdminRoute,
+ activityLogDetailAdminRoute,
+ ],
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts
new file mode 100644
index 0000000000..f083d378e9
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/activity-logs.routes.test.ts
@@ -0,0 +1,334 @@
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it } from "vitest";
+
+import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
+
+import { core_audit_logs, core_audit_settings } from "@/database/audit";
+import { core_roles } from "@/database/roles";
+import { core_users } from "@/database/users";
+import { createTestCache } from "@/tests/cache";
+import { testCategoryContentType } from "@/tests/content-fixtures";
+import { createMemoryDb } from "@/tests/memory-db";
+import { grantStaffPermissions } from "@/tests/staff-permissions";
+
+import { activityLogDetailAdminRoute } from "./detail.route";
+import { activityLogEntityAdminRoute } from "./entity.route";
+import { exportActivityLogsAdminRoute } from "./export.route";
+import { listActivityLogsAdminRoute } from "./list.route";
+import { updateActivityLogSettingsAdminRoute } from "./settings.route";
+
+const BLOG = "@vitnode/blog";
+const MEMBER_ROLE = 3;
+
+const log = (permission: string): PermissionsStaffArgs => ({
+ module: "activity_logs",
+ permission,
+ plugin: "@vitnode/core",
+});
+
+const ADMINS = {
+ editor: [
+ {
+ module: testCategoryContentType.permissionModule,
+ permission: "can_view",
+ plugin: BLOG,
+ },
+ ],
+ exporter: [log("can_export")],
+ nobody: [
+ { module: "users", permission: "can_view", plugin: "@vitnode/core" },
+ ],
+ retention: [log("can_manage_retention")],
+ sensitive: [log("can_view"), log("can_view_sensitive"), log("can_export")],
+ viewer: [log("can_view")],
+} satisfies Record;
+
+type Admin = keyof typeof ADMINS;
+
+const ADMIN_IDS = Object.fromEntries(
+ Object.keys(ADMINS).map((name, index) => [name, index + 1]),
+) as Record;
+
+const ENTRIES = [
+ {
+ action: "role.updated",
+ actorId: 1,
+ actorName: "Editor",
+ actorType: "staff",
+ area: "admin",
+ correlationId: "corr-1",
+ createdAt: new Date("2026-10-01T00:00:00.000Z"),
+ id: 1,
+ ipAddress: "203.0.113.7",
+ metadata: {},
+ pluginId: "@vitnode/core",
+ targetId: "5",
+ targetLabel: '=HYPERLINK("http://evil.test","Click")',
+ targetType: "role",
+ },
+ {
+ action: "content.updated",
+ actorId: 1,
+ actorName: "Editor",
+ actorType: "staff",
+ area: "admin",
+ changes: { title: { from: "Old", to: "New" } },
+ contentTypeId: testCategoryContentType.id,
+ createdAt: new Date("2026-10-02T00:00:00.000Z"),
+ id: 2,
+ ipAddress: "203.0.113.7",
+ metadata: {},
+ pluginId: BLOG,
+ targetId: "7",
+ targetLabel: "News",
+ targetType: testCategoryContentType.id,
+ },
+ {
+ action: "user.banned",
+ actorId: 1,
+ actorName: "Editor",
+ actorType: "staff",
+ area: "moderator",
+ createdAt: new Date("2026-10-03T00:00:00.000Z"),
+ id: 3,
+ metadata: {},
+ pluginId: "@vitnode/core",
+ targetId: "9",
+ targetLabel: "Spammer",
+ targetType: "user",
+ },
+ {
+ action: "audit_logs.pruned",
+ actorId: null,
+ actorName: null,
+ actorType: "system",
+ area: "system",
+ createdAt: new Date("2026-10-04T00:00:00.000Z"),
+ id: 4,
+ metadata: {},
+ pluginId: "@vitnode/core",
+ targetType: "audit_logs",
+ },
+];
+
+const harness = async () => {
+ const cache = createTestCache();
+ for (const [name, permissions] of Object.entries(ADMINS)) {
+ await grantStaffPermissions(cache, {
+ permissions,
+ userId: ADMIN_IDS[name as Admin],
+ });
+ }
+ const memory = createMemoryDb([
+ [
+ core_users,
+ Object.entries(ADMIN_IDS).map(([name, id]) => ({
+ id,
+ name,
+ nameCode: name,
+ roleId: MEMBER_ROLE,
+ })),
+ ],
+ [core_roles, [{ color: null, id: MEMBER_ROLE, prefix: null }]],
+ [core_audit_logs, ENTRIES],
+ [core_audit_settings, []],
+ ]);
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ const name = c.req.header("x-admin") as Admin;
+ c.set("admin", {
+ user: { id: ADMIN_IDS[name], name, roleId: MEMBER_ROLE },
+ } as unknown as Context["var"]["admin"]);
+ c.set("cache", cache);
+ c.set("db", memory.db as unknown as Context["var"]["db"]);
+ c.set("core", {
+ contentModels: [
+ { model: { definition: testCategoryContentType }, pluginId: BLOG },
+ ],
+ } as unknown as Context["var"]["core"]);
+ await next();
+ });
+ for (const built of [
+ exportActivityLogsAdminRoute,
+ activityLogEntityAdminRoute,
+ updateActivityLogSettingsAdminRoute,
+ activityLogDetailAdminRoute,
+ listActivityLogsAdminRoute,
+ ]) {
+ app.openapi(built.route, built.handler);
+ }
+
+ const request = async (admin: Admin, path: string, init: RequestInit = {}) =>
+ await app.request(path, {
+ ...init,
+ headers: {
+ "content-type": "application/json",
+ "x-admin": admin,
+ ...init.headers,
+ },
+ });
+
+ const audits = (action: string) =>
+ memory.rows(core_audit_logs).filter(row => row.action === action);
+
+ return { audits, memory, request };
+};
+
+const ENTITY = `/entity?pluginId=${encodeURIComponent(BLOG)}&targetType=${testCategoryContentType.id}&targetId=7`;
+
+describe("Activity Logs admin routes", () => {
+ it.each([
+ ["the list", "/", "viewer", 200],
+ ["the list", "/", "nobody", 403],
+ [
+ "an entity history with the content type's can_view only",
+ ENTITY,
+ "editor",
+ 200,
+ ],
+ ["an entity history", ENTITY, "nobody", 403],
+ ["the export", "/export", "exporter", 200],
+ ["the export", "/export", "viewer", 403],
+ ] as const)(
+ "answers %s (%s) for %s with %i",
+ async (_label, path, admin, status) => {
+ const h = await harness();
+
+ expect((await h.request(admin, path)).status).toBe(status);
+ },
+ );
+
+ it("lists entries newest first", async () => {
+ const h = await harness();
+
+ const res = await h.request("viewer", "/");
+ const body = (await res.json()) as {
+ edges: { id: number }[];
+ pageInfo: { hasNextPage: boolean };
+ };
+
+ expect(body.edges.map(edge => edge.id)).toEqual([4, 3, 2, 1]);
+ expect(body.pageInfo.hasNextPage).toBe(false);
+ });
+
+ it.each([
+ ["?staff=admin", [4, 2, 1]],
+ ["?staff=moderator", [3]],
+ ] as const)("filters the list by staff log (%s)", async (query, ids) => {
+ const h = await harness();
+
+ const res = await h.request("viewer", `/${query}`);
+ const body = (await res.json()) as { edges: { id: number }[] };
+
+ expect(body.edges.map(edge => edge.id)).toEqual(ids);
+ });
+
+ it("shows an entity's history with its recorded changes", async () => {
+ const h = await harness();
+
+ const res = await h.request("editor", ENTITY);
+
+ expect(await res.json()).toMatchObject({
+ edges: [{ changes: { title: { from: "Old", to: "New" } }, id: 2 }],
+ });
+ });
+
+ it.each([
+ ["hides", "viewer", { correlationId: null, ipAddress: null }],
+ [
+ "shows",
+ "sensitive",
+ { correlationId: "corr-1", ipAddress: "203.0.113.7" },
+ ],
+ ] as const)(
+ "%s the IP address and correlation id in the detail for %s",
+ async (_label, admin, expected) => {
+ const h = await harness();
+
+ const res = await h.request(admin, "/1");
+
+ expect(res.status).toBe(200);
+ expect(await res.json()).toMatchObject({ id: 1, ...expected });
+ },
+ );
+
+ describe("export", () => {
+ it("escapes formulas, omits IP addresses without can_view_sensitive and records itself", async () => {
+ const h = await harness();
+
+ const res = await h.request("exporter", "/export");
+ const csv = await res.text();
+ const [header] = csv.split("\r\n");
+
+ expect(res.headers.get("content-type")).toContain("text/csv");
+ expect(header).not.toContain("ipAddress");
+ expect(csv).not.toContain("203.0.113.7");
+ expect(csv).toContain(`"'=HYPERLINK(""http://evil.test"",""Click"")"`);
+ expect(h.audits("audit_logs.exported")).toEqual([
+ expect.objectContaining({
+ actorId: ADMIN_IDS.exporter,
+ metadata: expect.objectContaining({ rows: 4 }),
+ }),
+ ]);
+ });
+
+ it("includes IP addresses with can_view_sensitive", async () => {
+ const h = await harness();
+
+ const csv = await (await h.request("sensitive", "/export")).text();
+
+ expect(csv.split("\r\n")[0]).toContain('"ipAddress"');
+ expect(csv).toContain("203.0.113.7");
+ });
+ });
+
+ describe("PUT /settings", () => {
+ const put = async (
+ h: Awaited>,
+ admin: Admin,
+ retentionDays: number,
+ ) =>
+ await h.request(admin, "/settings", {
+ body: JSON.stringify({ retentionDays }),
+ method: "PUT",
+ });
+
+ it("saves an offered retention period and records the change", async () => {
+ const h = await harness();
+
+ const res = await put(h, "retention", 90);
+
+ expect(res.status).toBe(200);
+ expect(h.memory.rows(core_audit_settings)).toEqual([
+ expect.objectContaining({ id: 1, retentionDays: 90 }),
+ ]);
+ expect(h.audits("audit_settings.updated")).toEqual([
+ expect.objectContaining({
+ changes: { retentionDays: { from: 365, to: 90 } },
+ }),
+ ]);
+ });
+
+ it.each([
+ ["a period that is not offered", "retention", 45, 400],
+ ["an admin without can_manage_retention", "viewer", 90, 403],
+ ] as const)("refuses %s", async (_label, admin, days, status) => {
+ const h = await harness();
+
+ expect((await put(h, admin, days)).status).toBe(status);
+ expect(h.memory.rows(core_audit_settings)).toEqual([]);
+ expect(h.audits("audit_settings.updated")).toEqual([]);
+ });
+
+ it("records nothing when the period does not change", async () => {
+ const h = await harness();
+
+ await put(h, "retention", 365);
+
+ expect(h.audits("audit_settings.updated")).toEqual([]);
+ });
+ });
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts
new file mode 100644
index 0000000000..fa3f589751
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/detail.route.ts
@@ -0,0 +1,43 @@
+import { z } from "@hono/zod-openapi";
+
+import { buildRoute } from "@/api/builders";
+import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access";
+import { auditDetailOf } from "@/api/lib/audit/detail";
+import { findAuditLog, zodAuditDetail } from "@/api/lib/audit/query";
+
+export const activityLogDetailAdminRoute = buildRoute({
+ adminStaffPermission: { module: "activity_logs", permission: "can_view" },
+ route: {
+ method: "get",
+ path: "/{id}",
+ description:
+ "One Activity Logs entry with its changes, metadata and the content revision it points at",
+ request: {
+ params: z.object({
+ id: z.coerce.number().int().positive().openapi({ example: 1 }),
+ }),
+ },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodAuditDetail } },
+ description: "The entry",
+ },
+ 404: { description: "No such entry" },
+ },
+ },
+ handler: async c => {
+ const { id } = c.req.valid("param");
+ const entry = await findAuditLog(c, { access: undefined, id });
+ if (!entry) return c.json({ error: "Not found" }, 404);
+
+ return c.json(
+ await auditDetailOf(c, entry, {
+ canViewSensitive: await canAdminAudit(
+ c,
+ AUDIT_PERMISSIONS.viewSensitive,
+ ),
+ }),
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts
new file mode 100644
index 0000000000..8bec079767
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/entity.route.ts
@@ -0,0 +1,81 @@
+import { z } from "@hono/zod-openapi";
+import { HTTPException } from "hono/http-exception";
+
+import { buildRoute } from "@/api/builders";
+import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access";
+import {
+ listAuditLogsWithDetails,
+ zodAuditEntityPage,
+} from "@/api/lib/audit/query";
+import { checkStaffPermission } from "@/api/lib/check-staff-permission";
+import { contentDefinitionOf, findContentModel } from "@/content/server/model";
+import {
+ AUDIT_PLUGIN_ID_MAX_LENGTH,
+ AUDIT_TARGET_ID_MAX_LENGTH,
+ AUDIT_TARGET_TYPE_MAX_LENGTH,
+} from "@/lib/audit/const";
+
+/**
+ * Whether the admin may read one entity's history: with the Activity Logs
+ * permission always, and for a content record also with `can_view` on its own
+ * content type - an editor who can open a category can see who changed it,
+ * without being handed the whole site's log.
+ */
+const canReadEntityHistory = async (
+ c: Parameters[0],
+ { pluginId, targetType }: { pluginId: string; targetType: string },
+): Promise => {
+ if (await canAdminAudit(c, AUDIT_PERMISSIONS.view)) return true;
+
+ const registered = findContentModel(c.get("core").contentModels, targetType);
+ if (!registered || registered.pluginId !== pluginId) return false;
+
+ return await checkStaffPermission(c, {
+ module: contentDefinitionOf(registered.model).permissionModule,
+ permission: "can_view",
+ plugin: registered.pluginId,
+ type: "admin",
+ });
+};
+
+export const activityLogEntityAdminRoute = buildRoute({
+ route: {
+ method: "get",
+ path: "/entity",
+ description:
+ "The activity history of one resource - a content record or a plugin's own target. Needs Activity Logs `can_view`, or `can_view` on the content type",
+ request: {
+ query: z.object({
+ cursor: z.string().max(200).optional(),
+ first: z.coerce.number().int().min(1).max(100).optional(),
+ pluginId: z.string().min(1).max(AUDIT_PLUGIN_ID_MAX_LENGTH),
+ targetId: z.string().min(1).max(AUDIT_TARGET_ID_MAX_LENGTH),
+ targetType: z.string().min(1).max(AUDIT_TARGET_TYPE_MAX_LENGTH),
+ }),
+ },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodAuditEntityPage } },
+ description: "One page of the resource's history, newest first",
+ },
+ 403: { description: "May not read this resource's history" },
+ },
+ },
+ handler: async c => {
+ const { cursor, first, pluginId, targetId, targetType } =
+ c.req.valid("query");
+ if (!(await canReadEntityHistory(c, { pluginId, targetType }))) {
+ throw new HTTPException(403, { message: "Forbidden" });
+ }
+
+ return c.json(
+ await listAuditLogsWithDetails(c, {
+ access: undefined,
+ cursor,
+ filters: { pluginId, targetId, targetType },
+ first,
+ }),
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts
new file mode 100644
index 0000000000..b3016f83c4
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/export.route.ts
@@ -0,0 +1,83 @@
+import { z } from "@hono/zod-openapi";
+
+import { buildRoute } from "@/api/builders";
+import { AUDIT_PERMISSIONS, canAdminAudit } from "@/api/lib/audit/access";
+import { exportAuditLogs, zodAuditFilters } from "@/api/lib/audit/query";
+import { writeAuditLog } from "@/api/lib/audit/write";
+import { AUDIT_EXPORT_MAX_ROWS } from "@/lib/audit/const";
+import { toCsv } from "@/lib/audit/csv";
+
+export const exportActivityLogsAdminRoute = buildRoute({
+ adminStaffPermission: { module: "activity_logs", permission: "can_export" },
+ route: {
+ method: "get",
+ path: "/export",
+ description: `Download the filtered Activity Logs as CSV, newest first, at most ${AUDIT_EXPORT_MAX_ROWS} rows. IP addresses only with can_view_sensitive`,
+ request: { query: zodAuditFilters },
+ responses: {
+ 200: {
+ content: { "text/csv": { schema: z.string() } },
+ description: "CSV file",
+ },
+ },
+ },
+ handler: async c => {
+ const filters = c.req.valid("query");
+ const sensitive = await canAdminAudit(c, AUDIT_PERMISSIONS.viewSensitive);
+ const rows = await exportAuditLogs(c, {
+ access: undefined,
+ filters,
+ limit: AUDIT_EXPORT_MAX_ROWS,
+ });
+
+ // An export is a copy of the history leaving the system, so it is part of
+ // the history too.
+ await writeAuditLog(c, {
+ action: "audit_logs.exported",
+ metadata: { filters, rows: rows.length },
+ target: { id: "activity_logs", label: "Activity Logs", type: "settings" },
+ });
+
+ const csv = toCsv(
+ [
+ "id",
+ "createdAt",
+ "area",
+ "pluginId",
+ "action",
+ "actorType",
+ "actorId",
+ "actorName",
+ "targetType",
+ "targetId",
+ "targetLabel",
+ "scope",
+ "changes",
+ ...(sensitive ? ["ipAddress"] : []),
+ ],
+ rows.map(row => [
+ row.id,
+ row.createdAt,
+ row.area,
+ row.pluginId,
+ row.action,
+ row.actorType,
+ row.actorId,
+ row.actorName,
+ row.targetType,
+ row.targetId,
+ row.targetLabel,
+ row.scopeFamily ? `${row.scopeFamily}:${row.scopeId ?? ""}` : null,
+ row.changes,
+ ...(sensitive ? [row.ipAddress] : []),
+ ]),
+ );
+ const date = new Date().toISOString().slice(0, 10);
+
+ return c.body(csv, 200, {
+ "Cache-Control": "no-store",
+ "Content-Disposition": `attachment; filename="activity-logs-${date}.csv"`,
+ "Content-Type": "text/csv; charset=utf-8",
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts
new file mode 100644
index 0000000000..6197ca1780
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/filters.route.ts
@@ -0,0 +1,22 @@
+import { buildRoute } from "@/api/builders";
+import {
+ auditFilterOptions,
+ zodAuditFilterOptions,
+} from "@/api/lib/audit/registry";
+
+export const activityLogFiltersAdminRoute = buildRoute({
+ adminStaffPermission: { module: "activity_logs", permission: "can_view" },
+ route: {
+ method: "get",
+ path: "/filters",
+ description:
+ "The plugins and declared actions the Activity Logs filters offer",
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodAuditFilterOptions } },
+ description: "Filter options",
+ },
+ },
+ },
+ handler: c => c.json(auditFilterOptions(c.get("core")), 200),
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts
new file mode 100644
index 0000000000..9824025df6
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/list.route.ts
@@ -0,0 +1,32 @@
+import { buildRoute } from "@/api/builders";
+import {
+ listAuditLogs,
+ zodAuditPage,
+ zodAuditPageQuery,
+} from "@/api/lib/audit/query";
+
+export const listActivityLogsAdminRoute = buildRoute({
+ adminStaffPermission: { module: "activity_logs", permission: "can_view" },
+ route: {
+ method: "get",
+ path: "/",
+ description:
+ "Activity Logs: who did what to which resource, newest first, cursor-paginated and filtered",
+ request: { query: zodAuditPageQuery },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodAuditPage } },
+ description: "One page of audit entries",
+ },
+ 400: { description: "Invalid filter or cursor" },
+ },
+ },
+ handler: async c => {
+ const { cursor, first, ...filters } = c.req.valid("query");
+
+ return c.json(
+ await listAuditLogs(c, { access: undefined, cursor, filters, first }),
+ 200,
+ );
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts
new file mode 100644
index 0000000000..11b71ec105
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/activity-logs/routes/settings.route.ts
@@ -0,0 +1,85 @@
+import { z } from "@hono/zod-openapi";
+
+import { buildRoute } from "@/api/builders";
+import { loadAuditSettings, saveAuditSettings } from "@/api/lib/audit/settings";
+import { writeAuditLog } from "@/api/lib/audit/write";
+import { AUDIT_RETENTION_DAYS_OPTIONS } from "@/lib/audit/const";
+import { auditDiff } from "@/lib/audit/diff";
+
+const zodSettings = z.object({
+ retentionDays: z
+ .number()
+ .int()
+ .refine(
+ value =>
+ (AUDIT_RETENTION_DAYS_OPTIONS as readonly number[]).includes(value),
+ { message: "Choose one of the offered retention periods." },
+ )
+ .openapi({ example: 365 }),
+});
+
+export const activityLogSettingsAdminRoute = buildRoute({
+ adminStaffPermission: { module: "activity_logs", permission: "can_view" },
+ route: {
+ method: "get",
+ path: "/settings",
+ description: "How long Activity Logs entries are kept",
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodSettings } },
+ description: "The settings",
+ },
+ },
+ },
+ handler: async c => c.json(await loadAuditSettings(c.get("db")), 200),
+});
+
+export const updateActivityLogSettingsAdminRoute = buildRoute({
+ adminStaffPermission: {
+ module: "activity_logs",
+ permission: "can_manage_retention",
+ },
+ route: {
+ method: "put",
+ path: "/settings",
+ description:
+ "Change how long Activity Logs entries are kept. The daily retention job deletes what is older",
+ request: {
+ body: {
+ required: true,
+ content: { "application/json": { schema: zodSettings } },
+ },
+ },
+ responses: {
+ 200: {
+ content: { "application/json": { schema: zodSettings } },
+ description: "Saved",
+ },
+ },
+ },
+ handler: async c => {
+ const body = c.req.valid("json");
+ await c.get("db").transaction(async tx => {
+ const before = await loadAuditSettings(tx);
+ await saveAuditSettings(tx, body);
+ const changes = auditDiff({ after: body, before });
+ if (changes) {
+ await writeAuditLog(
+ c,
+ {
+ action: "audit_settings.updated",
+ changes,
+ target: {
+ id: "activity_logs",
+ label: "Activity Logs",
+ type: "settings",
+ },
+ },
+ tx,
+ );
+ }
+ });
+
+ return c.json(body, 200);
+ },
+});
diff --git a/packages/vitnode/src/api/modules/admin/admin.module.ts b/packages/vitnode/src/api/modules/admin/admin.module.ts
index 914e3579b3..0c25a69cdd 100644
--- a/packages/vitnode/src/api/modules/admin/admin.module.ts
+++ b/packages/vitnode/src/api/modules/admin/admin.module.ts
@@ -1,5 +1,6 @@
import { buildModule } from "@/api/builders";
+import { activityLogsAdminModule } from "./activity-logs/activity-logs.admin.module";
import { advancedAdminModule } from "./advanced/advanced.admin.module";
import { aiAdminModule } from "./ai/ai.admin.module";
import { dashboardAdminModule } from "./dashboard/dashboard.admin.module";
@@ -18,6 +19,7 @@ export const adminModule = buildModule({
name: "admin",
routes: [sessionAdminRoute, sendNotificationRoute],
modules: [
+ activityLogsAdminModule,
aiAdminModule,
usersAdminModule,
rolesAdminModule,
diff --git a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts
index aaf5f40f74..31575ce005 100644
--- a/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts
+++ b/packages/vitnode/src/api/modules/admin/advanced/cron/routes/run.route.ts
@@ -4,6 +4,8 @@ import { z } from "zod";
import type { CronJobConfig } from "@/api/lib/cron";
import { buildRoute } from "@/api/builders";
+import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { core_cron } from "@/database/cron";
import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date";
@@ -86,7 +88,31 @@ export const runCronRoute = buildRoute({
const now = new Date();
try {
- await job.handler(c);
+ // The job itself runs as the system - what it writes was not done by
+ // hand - while the admin who started it is named on its entries and
+ // recorded for starting it.
+ await withAuditContext(
+ c,
+ {
+ actor: AUDIT_SYSTEM_ACTOR,
+ area: "system",
+ metadata: {
+ cronJob: job.name,
+ trigger: "cron_manual",
+ triggeredByUserId: c.get("admin")?.user.id ?? null,
+ },
+ },
+ async () => {
+ await job.handler(c);
+ },
+ );
+
+ await writeAuditLog(c, {
+ action: "cron.run",
+ metadata: { module: job.module },
+ pluginId: job.pluginId,
+ target: { id: dbCron.id, label: job.name, type: "cron_job" },
+ });
await db
.update(core_cron)
diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts
index 53892cc97d..f2c2331865 100644
--- a/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts
+++ b/packages/vitnode/src/api/modules/admin/ai/routes/access.route.ts
@@ -3,6 +3,8 @@ import { z } from "zod";
import { buildRoute } from "@/api/builders";
import { formatDecimal, parseDecimal } from "@/api/lib/ai/decimal";
+import { roleAuditLabel, userAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import {
core_ai_role_permissions,
core_ai_role_policies,
@@ -278,6 +280,28 @@ export const updateAiRoleAccessAdminRoute = buildRoute({
],
});
}
+
+ await writeAuditLog(
+ c,
+ {
+ action: "ai_access.role_updated",
+ metadata: {
+ grants: grants.map(grant => ({
+ dailyLimit: grant.dailyLimit,
+ granted: grant.granted,
+ permission: grant.permission,
+ })),
+ monthlyPoints,
+ unlimited,
+ },
+ target: {
+ id: roleId,
+ label: await roleAuditLabel(c, roleId),
+ type: "role",
+ },
+ },
+ tx,
+ );
});
return c.json({ ok: true as const }, 200);
@@ -317,14 +341,25 @@ export const updateAiUserOverrideAdminRoute = buildRoute({
},
handler: async c => {
const { userId, ...values } = c.req.valid("json");
- await c
- .get("db")
- .insert(core_ai_user_overrides)
- .values({ userId, ...values })
- .onConflictDoUpdate({
- set: values,
- target: core_ai_user_overrides.userId,
- });
+ const label = await userAuditLabel(c, userId);
+ await c.get("db").transaction(async tx => {
+ await tx
+ .insert(core_ai_user_overrides)
+ .values({ userId, ...values })
+ .onConflictDoUpdate({
+ set: values,
+ target: core_ai_user_overrides.userId,
+ });
+ await writeAuditLog(
+ c,
+ {
+ action: "ai_access.user_override_saved",
+ metadata: values,
+ target: { id: userId, label, type: "user" },
+ },
+ tx,
+ );
+ });
return c.json({ ok: true as const }, 200);
},
@@ -348,10 +383,23 @@ export const deleteAiUserOverrideAdminRoute = buildRoute({
},
handler: async c => {
const { userId } = c.req.valid("query");
- await c
- .get("db")
- .delete(core_ai_user_overrides)
- .where(eq(core_ai_user_overrides.userId, userId));
+ const label = await userAuditLabel(c, userId);
+ await c.get("db").transaction(async tx => {
+ const removed = await tx
+ .delete(core_ai_user_overrides)
+ .where(eq(core_ai_user_overrides.userId, userId))
+ .returning({ userId: core_ai_user_overrides.userId });
+ if (removed.length === 0) return;
+
+ await writeAuditLog(
+ c,
+ {
+ action: "ai_access.user_override_removed",
+ target: { id: userId, label, type: "user" },
+ },
+ tx,
+ );
+ });
return c.json({ ok: true as const }, 200);
},
diff --git a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts
index d872929b96..0cb434ef0f 100644
--- a/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts
+++ b/packages/vitnode/src/api/modules/admin/ai/routes/update-settings.route.ts
@@ -1,8 +1,12 @@
+import { eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import { buildRoute } from "@/api/builders";
import { saveAiSettings } from "@/api/lib/ai/settings-store";
+import { writeAuditLog } from "@/api/lib/audit/write";
+import { core_ai_settings } from "@/database/ai";
+import { auditDiff } from "@/lib/audit/diff";
import { zodAiSettings } from "../schemas";
@@ -37,7 +41,31 @@ export const updateAiSettingsAdminRoute = buildRoute({
});
}
- await saveAiSettings(c.get("db"), body);
+ await c.get("db").transaction(async tx => {
+ const [before] = await tx
+ .select()
+ .from(core_ai_settings)
+ .where(eq(core_ai_settings.id, 1))
+ .limit(1);
+ await saveAiSettings(tx, body);
+
+ const changes = auditDiff({
+ after: body,
+ before: before ?? null,
+ include: Object.keys(body) as (keyof typeof body)[],
+ });
+ if (changes) {
+ await writeAuditLog(
+ c,
+ {
+ action: "settings.updated",
+ changes,
+ target: { id: "ai", label: "AI", type: "settings" },
+ },
+ tx,
+ );
+ }
+ });
return c.json({ ok: true as const }, 200);
},
diff --git a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts
index f0f1d3383f..213628f9c3 100644
--- a/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts
+++ b/packages/vitnode/src/api/modules/admin/notifications/routes/danger.route.ts
@@ -1,6 +1,9 @@
+import type { Context } from "hono";
+
import { z } from "zod";
import { buildRoute } from "@/api/builders";
+import { writeAuditLogBestEffort } from "@/api/lib/audit/write";
import {
cancelQueuedNotificationEmails,
deleteAllNotifications,
@@ -10,6 +13,14 @@ import {
resumeNotifications,
} from "@/api/models/notifications/danger";
+/** The danger-zone operations act on every member at once, so each is recorded. */
+const auditDanger = async (c: Context, operation: string): Promise => {
+ await writeAuditLogBestEffort(c, {
+ action: `notifications.${operation}`,
+ target: { label: "Notifications", type: "settings", id: "notifications" },
+ });
+};
+
const canManageNotifications = {
module: "notifications",
permission: "can_manage",
@@ -32,6 +43,7 @@ export const pauseNotificationsRoute = buildRoute({
},
handler: async c => {
await pauseNotifications(c);
+ await auditDanger(c, "paused");
return c.json({ success: true });
},
@@ -49,7 +61,12 @@ export const resumeNotificationsRoute = buildRoute({
"Resumed",
),
},
- handler: async c => c.json(await resumeNotifications(c)),
+ handler: async c => {
+ const result = await resumeNotifications(c);
+ await auditDanger(c, "resumed");
+
+ return c.json(result);
+ },
});
export const cancelQueuedNotificationEmailsRoute = buildRoute({
@@ -64,7 +81,12 @@ export const cancelQueuedNotificationEmailsRoute = buildRoute({
"Cancelled",
),
},
- handler: async c => c.json(await cancelQueuedNotificationEmails(c)),
+ handler: async c => {
+ const result = await cancelQueuedNotificationEmails(c);
+ await auditDanger(c, "emails_cancelled");
+
+ return c.json(result);
+ },
});
export const markEverythingReadRoute = buildRoute({
@@ -79,7 +101,12 @@ export const markEverythingReadRoute = buildRoute({
"Marked as read",
),
},
- handler: async c => c.json(await markEverythingRead(c)),
+ handler: async c => {
+ const result = await markEverythingRead(c);
+ await auditDanger(c, "all_marked_read");
+
+ return c.json(result);
+ },
});
export const deleteAllNotificationsRoute = buildRoute({
@@ -94,7 +121,12 @@ export const deleteAllNotificationsRoute = buildRoute({
"Deleted",
),
},
- handler: async c => c.json(await deleteAllNotifications(c)),
+ handler: async c => {
+ const result = await deleteAllNotifications(c);
+ await auditDanger(c, "all_deleted");
+
+ return c.json(result);
+ },
});
export const resetMemberNotificationPreferencesRoute = buildRoute({
@@ -106,5 +138,10 @@ export const resetMemberNotificationPreferencesRoute = buildRoute({
path: "/members/reset-preferences",
responses: dangerResponses(z.object({ members: z.number() }), "Reset"),
},
- handler: async c => c.json(await resetMemberNotificationPreferences(c)),
+ handler: async c => {
+ const result = await resetMemberNotificationPreferences(c);
+ await auditDanger(c, "member_preferences_reset");
+
+ return c.json(result);
+ },
});
diff --git a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts
index 96dc39595e..a0e88c3609 100644
--- a/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts
+++ b/packages/vitnode/src/api/modules/admin/report-reasons/routes/report-reasons.routes.ts
@@ -5,6 +5,8 @@ import { and, count, eq, inArray, max, sql } from "drizzle-orm";
import { randomBytes } from "node:crypto";
import { buildRoute } from "@/api/builders";
+import { multiLangAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import {
listReportReasons,
REPORT_REASON_WORDS,
@@ -14,6 +16,7 @@ import { saveLanguageWords } from "@/api/lib/save-language-words";
import { errorSchema, json } from "@/api/modules/moderation/lib/schema";
import { core_languages_words } from "@/database/languages";
import { core_moderation_report_reasons } from "@/database/moderation";
+import { auditDiff } from "@/lib/audit/diff";
import { MODERATION_LIMITS } from "@/lib/moderation/const";
const titleSchema = z
@@ -80,6 +83,7 @@ const findReasonRow = async (c: Context, id: number) => {
.select({
code: core_moderation_report_reasons.code,
id: core_moderation_report_reasons.id,
+ requiresDetails: core_moderation_report_reasons.requiresDetails,
})
.from(core_moderation_report_reasons)
.where(eq(core_moderation_report_reasons.id, id))
@@ -149,6 +153,26 @@ export const createReportReasonAdminRoute = buildRoute({
})
.returning({ id: core_moderation_report_reasons.id });
+ await writeAuditLog(
+ c,
+ {
+ action: "report_reason.created",
+ changes: auditDiff({
+ after: {
+ requiresDetails: body.requiresDetails,
+ title: multiLangAuditLabel(body.title),
+ },
+ before: null,
+ }),
+ target: {
+ id: row.id,
+ label: multiLangAuditLabel(body.title),
+ type: "report_reason",
+ },
+ },
+ tx,
+ );
+
return row;
});
if (!created) return c.json({ error: "limit_reached" }, 409);
@@ -188,13 +212,37 @@ export const updateReportReasonAdminRoute = buildRoute({
const row = await findReasonRow(c, id);
if (!row) return c.json({ error: "not_found" }, 404);
- if (body.requiresDetails !== undefined) {
- await c
- .get("db")
- .update(core_moderation_report_reasons)
- .set({ requiresDetails: body.requiresDetails })
- .where(eq(core_moderation_report_reasons.id, id));
- }
+ await c.get("db").transaction(async tx => {
+ if (body.requiresDetails !== undefined) {
+ await tx
+ .update(core_moderation_report_reasons)
+ .set({ requiresDetails: body.requiresDetails })
+ .where(eq(core_moderation_report_reasons.id, id));
+ }
+ await writeAuditLog(
+ c,
+ {
+ action: "report_reason.updated",
+ changes: auditDiff({
+ after: {
+ requiresDetails: body.requiresDetails ?? row.requiresDetails,
+ ...(body.title === undefined
+ ? {}
+ : { title: multiLangAuditLabel(body.title) }),
+ },
+ before: { requiresDetails: row.requiresDetails },
+ }),
+ metadata:
+ body.description === undefined ? {} : { descriptionChanged: true },
+ target: {
+ id,
+ label: multiLangAuditLabel(body.title) ?? row.code,
+ type: "report_reason",
+ },
+ },
+ tx,
+ );
+ });
await saveReasonWords(c, id, body);
await c.get("events").emit("moderation.report_reason.updated", {
code: row.code,
@@ -244,6 +292,14 @@ export const deleteReportReasonAdminRoute = buildRoute({
await tx
.delete(core_moderation_report_reasons)
.where(eq(core_moderation_report_reasons.id, id));
+ await writeAuditLog(
+ c,
+ {
+ action: "report_reason.deleted",
+ target: { id, label: row.code, type: "report_reason" },
+ },
+ tx,
+ );
return true;
});
@@ -303,10 +359,21 @@ export const reorderReportReasonsAdminRoute = buildRoute({
),
sql` `,
);
- await db
- .update(core_moderation_report_reasons)
- .set({ position: sql`case ${positionById} end` })
- .where(inArray(core_moderation_report_reasons.id, ids));
+ await db.transaction(async tx => {
+ await tx
+ .update(core_moderation_report_reasons)
+ .set({ position: sql`case ${positionById} end` })
+ .where(inArray(core_moderation_report_reasons.id, ids));
+ await writeAuditLog(
+ c,
+ {
+ action: "report_reason.reordered",
+ metadata: { order: ids },
+ target: { type: "report_reason" },
+ },
+ tx,
+ );
+ });
await c
.get("events")
.emit("moderation.report_reason.reordered", { reasonIds: ids });
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts
new file mode 100644
index 0000000000..b6cce4489b
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.test.ts
@@ -0,0 +1,114 @@
+import type { Table } from "drizzle-orm";
+import type { Context } from "hono";
+
+import { OpenAPIHono } from "@hono/zod-openapi";
+import { describe, expect, it, vi } from "vitest";
+
+import { core_audit_logs } from "@/database/audit";
+import { core_languages_words } from "@/database/languages";
+import { core_roles } from "@/database/roles";
+import { core_users } from "@/database/users";
+import { createTestCache } from "@/tests/cache";
+import { createMemoryDb } from "@/tests/memory-db";
+import {
+ grantStaffPermissions,
+ ROOT_STAFF_PERMISSIONS,
+} from "@/tests/staff-permissions";
+
+import { createRoleAdminRoute } from "./create.route";
+
+const CALLER = { id: 1, name: "Root", roleId: 3 };
+
+type MemoryDb = ReturnType["db"];
+
+/** The memory db, with every audit insert inside a transaction failing. */
+const failingAuditDb = (db: MemoryDb): MemoryDb => ({
+ ...db,
+ transaction: async run =>
+ await db.transaction(async tx => {
+ const inner = tx as MemoryDb;
+
+ return await run({
+ ...inner,
+ insert: (table: Table) => {
+ if (table === core_audit_logs) throw new Error("audit write failed");
+
+ return inner.insert(table);
+ },
+ });
+ }),
+});
+
+const harness = async ({ failAudit = false } = {}) => {
+ const cache = createTestCache();
+ await grantStaffPermissions(cache, {
+ permissions: ROOT_STAFF_PERMISSIONS,
+ userId: CALLER.id,
+ });
+ const memory = createMemoryDb([
+ [core_roles, []],
+ [core_users, [CALLER]],
+ [core_languages_words, []],
+ ]);
+ const db = failAudit ? failingAuditDb(memory.db) : memory.db;
+
+ const app = new OpenAPIHono();
+ app.use("*", async (c, next) => {
+ c.set("admin", { user: CALLER } as unknown as Context["var"]["admin"]);
+ c.set("cache", cache);
+ c.set("db", db as unknown as Context["var"]["db"]);
+ c.set("events", {
+ emit: vi.fn(async () => Promise.resolve()),
+ } as unknown as Context["var"]["events"]);
+ await next();
+ });
+ app.openapi(createRoleAdminRoute.route, createRoleAdminRoute.handler);
+
+ const create = async () =>
+ await app.request("/create", {
+ body: JSON.stringify({
+ color: "#0a0",
+ name: [{ languageCode: "en", value: "Editors" }],
+ }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ return { create, memory };
+};
+
+describe("POST /admin/roles/create - audit", () => {
+ it("records role.created with the new role's values", async () => {
+ const h = await harness();
+
+ const res = await h.create();
+ const { id } = (await res.json()) as { id: number };
+
+ expect(res.status).toBe(201);
+ expect(h.memory.rows(core_audit_logs)).toEqual([
+ expect.objectContaining({
+ action: "role.created",
+ actorId: CALLER.id,
+ actorType: "staff",
+ area: "admin",
+ changes: expect.objectContaining({
+ color: { from: null, to: "#0a0" },
+ name: { from: null, to: "Editors" },
+ }),
+ targetId: String(id),
+ targetLabel: "Editors",
+ targetType: "role",
+ }),
+ ]);
+ });
+
+ it("creates no role when its audit entry cannot be written", async () => {
+ const h = await harness({ failAudit: true });
+
+ const res = await h.create();
+
+ expect(res.status).toBe(500);
+ expect(h.memory.rows(core_roles)).toEqual([]);
+ expect(h.memory.rows(core_audit_logs)).toEqual([]);
+ });
+});
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts
index 780c46594a..e02ac62ded 100644
--- a/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/create.route.ts
@@ -1,8 +1,11 @@
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
+import { multiLangAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { saveLanguageWords } from "@/api/lib/save-language-words";
import { core_roles } from "@/database/roles";
+import { auditDiff } from "@/lib/audit/diff";
import {
EMOJI_ICON_MAX_LENGTH,
parseEmojiIcon,
@@ -107,29 +110,50 @@ export const createRoleAdminRoute = buildRoute({
maxCoverSize,
} = c.req.valid("json");
- const [role] = await c
- .get("db")
- .insert(core_roles)
- .values({
- color: color?.trim() ? color : null,
- prefix: serializeEmojiIcon(parseEmojiIcon(prefix)) || null,
- allowUploadFiles: allowUploadFiles ?? false,
- totalMaxStorage: totalMaxStorage ?? null,
- maxStorageForSubmit: maxStorageForSubmit ?? null,
- ...(allowUploadAvatar === undefined ? {} : { allowUploadAvatar }),
- ...(allowEditPersonalInfo === undefined
- ? {}
- : { allowEditPersonalInfo }),
- ...(allowEditOwnContent === undefined ? {} : { allowEditOwnContent }),
- editOwnContentMinutes: editOwnContentMinutes ?? null,
- allowDeleteOwnContent: allowDeleteOwnContent ?? false,
- allowHideOwnContent: allowHideOwnContent ?? false,
- ...(maxAvatarSize === undefined ? {} : { maxAvatarSize }),
- ...(allowUploadCover === undefined ? {} : { allowUploadCover }),
- ...(maxCoverSize === undefined ? {} : { maxCoverSize }),
- updatedAt: new Date(),
- })
- .returning({ id: core_roles.id });
+ const role = await c.get("db").transaction(async tx => {
+ const [created] = await tx
+ .insert(core_roles)
+ .values({
+ color: color?.trim() ? color : null,
+ prefix: serializeEmojiIcon(parseEmojiIcon(prefix)) || null,
+ allowUploadFiles: allowUploadFiles ?? false,
+ totalMaxStorage: totalMaxStorage ?? null,
+ maxStorageForSubmit: maxStorageForSubmit ?? null,
+ ...(allowUploadAvatar === undefined ? {} : { allowUploadAvatar }),
+ ...(allowEditPersonalInfo === undefined
+ ? {}
+ : { allowEditPersonalInfo }),
+ ...(allowEditOwnContent === undefined ? {} : { allowEditOwnContent }),
+ editOwnContentMinutes: editOwnContentMinutes ?? null,
+ allowDeleteOwnContent: allowDeleteOwnContent ?? false,
+ allowHideOwnContent: allowHideOwnContent ?? false,
+ ...(maxAvatarSize === undefined ? {} : { maxAvatarSize }),
+ ...(allowUploadCover === undefined ? {} : { allowUploadCover }),
+ ...(maxCoverSize === undefined ? {} : { maxCoverSize }),
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ await writeAuditLog(
+ c,
+ {
+ action: "role.created",
+ changes: auditDiff({
+ after: { ...created, name: multiLangAuditLabel(name) },
+ before: null,
+ exclude: ["createdAt", "id", "updatedAt"],
+ }),
+ target: {
+ id: created.id,
+ label: multiLangAuditLabel(name),
+ type: "role",
+ },
+ },
+ tx,
+ );
+
+ return created;
+ });
await saveLanguageWords(c, {
pluginCode: "core",
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts
index e88620942a..6a25702c9d 100644
--- a/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/delete.route.ts
@@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi";
import { and, count, eq, inArray } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { roleAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache";
import { invalidateSessionCacheForUser } from "@/api/models/session-revoke";
import { assertCanAssignPrimaryRole } from "@/api/modules/admin/users/lib/assert-edit-user-permission";
@@ -132,6 +134,8 @@ export const deleteRoleAdminRoute = buildRoute({
await assertCanAssignPrimaryRole(c, targetRoleId);
}
+ const label = await roleAuditLabel(c, roleId);
+
const affectedUserIds = await db.transaction(async tx => {
const members = await tx
.select({ id: core_users.id })
@@ -178,6 +182,19 @@ export const deleteRoleAdminRoute = buildRoute({
await tx.delete(core_roles).where(eq(core_roles.id, roleId));
+ await writeAuditLog(
+ c,
+ {
+ action: "role.deleted",
+ metadata: {
+ movedMembers: memberIds.length,
+ ...(targetRoleId === null ? {} : { movedToRoleId: targetRoleId }),
+ },
+ target: { id: roleId, label, type: "role" },
+ },
+ tx,
+ );
+
return [
...new Set([
...memberIds,
diff --git a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts
index af5660bf27..751a305b62 100644
--- a/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts
+++ b/packages/vitnode/src/api/modules/admin/roles/routes/update.route.ts
@@ -2,9 +2,12 @@ import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { multiLangAuditLabel, roleAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { saveLanguageWords } from "@/api/lib/save-language-words";
import { invalidateAllStaffPermissions } from "@/api/lib/staff-permission-cache";
import { core_roles } from "@/database/roles";
+import { auditDiff } from "@/lib/audit/diff";
import { parseEmojiIcon, serializeEmojiIcon } from "@/lib/emoji-icon";
import { assertCanManageAdminRole } from "../lib/assert-manage-admin-role";
@@ -91,7 +94,7 @@ export const updateRoleAdminRoute = buildRoute({
}
const [role] = await db
- .select({ id: core_roles.id })
+ .select()
.from(core_roles)
.where(eq(core_roles.id, roleId))
.limit(1);
@@ -151,7 +154,30 @@ export const updateRoleAdminRoute = buildRoute({
values.maxCoverSize = body.maxCoverSize;
}
- await db.update(core_roles).set(values).where(eq(core_roles.id, roleId));
+ const label = await roleAuditLabel(c, roleId);
+ const renamed =
+ body.name === undefined ? label : multiLangAuditLabel(body.name);
+
+ await db.transaction(async tx => {
+ await tx.update(core_roles).set(values).where(eq(core_roles.id, roleId));
+
+ const changes = auditDiff({
+ after: { ...role, ...values, name: renamed },
+ before: { ...role, name: label },
+ exclude: ["createdAt", "id", "updatedAt"],
+ });
+ if (changes) {
+ await writeAuditLog(
+ c,
+ {
+ action: "role.updated",
+ changes,
+ target: { id: roleId, label: renamed, type: "role" },
+ },
+ tx,
+ );
+ }
+ });
if (body.name !== undefined) {
await saveLanguageWords(c, {
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts
index 1bd421e10e..b31ec09e40 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/create.route.ts
@@ -2,6 +2,8 @@ import { z } from "@hono/zod-openapi";
import { eq, or } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { staffEntryAuditLabel } from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { assertStaffPermission } from "@/api/lib/check-staff-permission";
import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache";
import { core_admin_permissions } from "@/database/admins";
@@ -112,14 +114,28 @@ export const createStaffAdminRoute = buildRoute({
return c.json({ error: "Staff entry already exists" }, 409);
}
- const [created] = await c
- .get("db")
- .insert(table)
- .values({
- roleId: roleId ?? null,
- userId: userId ?? null,
- })
- .returning({ id: table.id });
+ const label = await staffEntryAuditLabel(c, { roleId, userId });
+ const created = await c.get("db").transaction(async tx => {
+ const [inserted] = await tx
+ .insert(table)
+ .values({
+ roleId: roleId ?? null,
+ userId: userId ?? null,
+ })
+ .returning({ id: table.id });
+
+ await writeAuditLog(
+ c,
+ {
+ action: `${type}_permissions.created`,
+ metadata: roleId ? { roleId } : { userId },
+ target: { id: inserted.id, label, type: "staff_entry" },
+ },
+ tx,
+ );
+
+ return inserted;
+ });
await invalidateStaffEntry(c, { roleId, userId });
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
index 68185a7aef..76efeb4aee 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/delete.route.ts
@@ -3,6 +3,11 @@ import { eq } from "drizzle-orm";
import { HTTPException } from "hono/http-exception";
import { buildRoute } from "@/api/builders";
+import {
+ staffEntryAuditLabel,
+ staffGrantsAuditDelta,
+} from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { assertStaffPermission } from "@/api/lib/check-staff-permission";
import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache";
import { core_admin_permissions } from "@/database/admins";
@@ -93,7 +98,26 @@ export const deleteStaffAdminRoute = buildRoute({
);
await assertCanManageStaffEntry(c, { type, entry });
- await c.get("db").delete(table).where(eq(table.id, entryId));
+ const label = await staffEntryAuditLabel(c, entry);
+ await c.get("db").transaction(async tx => {
+ await tx.delete(table).where(eq(table.id, entryId));
+ await writeAuditLog(
+ c,
+ {
+ action: `${type}_permissions.deleted`,
+ metadata: {
+ revoked: entry.unrestricted
+ ? ["*"]
+ : staffGrantsAuditDelta(entry.permissions, []).revoked,
+ ...(entry.roleId === null
+ ? { userId: entry.userId }
+ : { roleId: entry.roleId }),
+ },
+ target: { id: entryId, label, type: "staff_entry" },
+ },
+ tx,
+ );
+ });
await invalidateStaffEntry(c, entry);
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
index fb14187bac..6636563f73 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/staff-entry-routes.test.ts
@@ -12,6 +12,7 @@ import type { buildRoute } from "@/api/lib/route";
import { normalizePermissionStaffModules } from "@/api/lib/permission-staff";
import { core_admin_permissions } from "@/database/admins";
+import { core_audit_logs } from "@/database/audit";
import { core_moderators_permissions } from "@/database/moderators";
import { core_roles } from "@/database/roles";
import { core_users_secondary_roles } from "@/database/users";
@@ -160,7 +161,9 @@ const appFor = async (
const storedEntries = (type: PermissionStaffType = "admin") =>
memory.rows(tableByType[type]);
- return { app, storedEntries };
+ const auditEntries = () => memory.rows(core_audit_logs);
+
+ return { app, auditEntries, storedEntries };
};
const patch = async (
@@ -405,6 +408,57 @@ describe("updating a staff entry", () => {
const remove = async (app: OpenAPIHono, type: PermissionStaffType = "admin") =>
await app.request(`/entry/${type}/5`, { method: "DELETE" });
+describe("auditing a staff entry update", () => {
+ it("records the granted and revoked grants, never the whole list", async () => {
+ const { app, auditEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: entryFor({ permissions: [VIEW_ADMINS, DELETE_ADMINS] }),
+ grants: { admin: MANAGE_ADMINS },
+ },
+ );
+
+ const res = await patch(app, {
+ permissions: [VIEW_ADMINS, CREATE_ADMINS],
+ unrestricted: false,
+ });
+
+ expect(res.status).toBe(200);
+ expect(auditEntries()).toEqual([
+ expect.objectContaining({
+ action: "admin_permissions.updated",
+ actorId: CALLER.id,
+ changes: null,
+ metadata: {
+ granted: [`${CORE}:staff_admins:can_create`],
+ revoked: [`${CORE}:staff_admins:can_delete`],
+ roleId: TARGET_ROLE_ID,
+ },
+ targetId: String(entryFor().id),
+ targetType: "staff_entry",
+ }),
+ ]);
+ });
+
+ it("records nothing when the grants did not move", async () => {
+ const { app, auditEntries } = await appFor(
+ updatePermissionsStaffAdminRoute,
+ {
+ entry: entryFor({ permissions: [VIEW_ADMINS] }),
+ grants: { admin: MANAGE_ADMINS },
+ },
+ );
+
+ const res = await patch(app, {
+ permissions: [VIEW_ADMINS],
+ unrestricted: false,
+ });
+
+ expect(res.status).toBe(200);
+ expect(auditEntries()).toEqual([]);
+ });
+});
+
describe("removing a staff entry", () => {
it("removes an entry within the caller's own permissions", async () => {
const { app, storedEntries } = await appFor(deleteStaffAdminRoute, {
diff --git a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts
index d0c8b3fa4b..ebf9236d08 100644
--- a/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts
+++ b/packages/vitnode/src/api/modules/admin/staff/routes/update-permissions.route.ts
@@ -5,10 +5,16 @@ import { HTTPException } from "hono/http-exception";
import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
import { buildRoute } from "@/api/builders";
+import {
+ staffEntryAuditLabel,
+ staffGrantsAuditDelta,
+} from "@/api/lib/audit/labels";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { assertStaffPermission } from "@/api/lib/check-staff-permission";
import { invalidateStaffEntry } from "@/api/lib/staff-permission-cache";
import { core_admin_permissions } from "@/database/admins";
import { core_moderators_permissions } from "@/database/moderators";
+import { auditDiff } from "@/lib/audit/diff";
import { manifest } from "@/manifest";
import {
@@ -149,12 +155,42 @@ export const updatePermissionsStaffAdminRoute = buildRoute({
? []
: await sanitizeStaffGrants(c, { permissions, type });
- const [updated] = await c
- .get("db")
- .update(table)
- .set({ unrestricted, permissions: sanitized })
- .where(eq(table.id, entryId))
- .returning({ id: table.id });
+ const label = await staffEntryAuditLabel(c, entry);
+ const updated = await c.get("db").transaction(async tx => {
+ const [row] = await tx
+ .update(table)
+ .set({ unrestricted, permissions: sanitized })
+ .where(eq(table.id, entryId))
+ .returning({ id: table.id });
+ if (!row) return null;
+
+ // The grants that moved, never the whole list: what an auditor asks is
+ // "who was given what, and by whom".
+ const delta = staffGrantsAuditDelta(entry.permissions, sanitized);
+ const changes = auditDiff({
+ after: { unrestricted },
+ before: { unrestricted: entry.unrestricted },
+ });
+ if (changes || delta.granted.length > 0 || delta.revoked.length > 0) {
+ await writeAuditLog(
+ c,
+ {
+ action: `${type}_permissions.updated`,
+ changes,
+ metadata: {
+ ...delta,
+ ...(entry.roleId === null
+ ? { userId: entry.userId }
+ : { roleId: entry.roleId }),
+ },
+ target: { id: entryId, label, type: "staff_entry" },
+ },
+ tx,
+ );
+ }
+
+ return row;
+ });
if (!updated) {
return c.json({ error: "Staff entry not found" }, 404);
diff --git a/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts
new file mode 100644
index 0000000000..d1b4bc8d07
--- /dev/null
+++ b/packages/vitnode/src/api/modules/admin/users/lib/audit-changes.ts
@@ -0,0 +1,52 @@
+import type { AuditChanges } from "@/lib/audit/const";
+
+import { auditDiff } from "@/lib/audit/diff";
+
+/** Account fields an audit entry may show the values of: none of them is private. */
+const VISIBLE_FIELDS = [
+ "language",
+ "name",
+ "nameCode",
+ "newsletter",
+ "roleId",
+ "timeZone",
+] as const;
+
+/**
+ * The audit changes of an AdminCP edit of a user. Roles, names and preferences
+ * keep their values; personal data (email, birthday, real name, phone...) is
+ * recorded only as changed, never with its values - an audit trail is no
+ * reason to keep a second copy of somebody's address.
+ */
+export const adminUserAuditChanges = ({
+ after,
+ before,
+ secondaryRoleIds,
+}: {
+ after: Record;
+ before: Record;
+ secondaryRoleIds?: { after: number[]; before: number[] };
+}): AuditChanges | null => {
+ const visible = VISIBLE_FIELDS.filter(field => field in after);
+ const changes: AuditChanges = {
+ ...auditDiff({
+ after: Object.fromEntries(visible.map(field => [field, after[field]])),
+ before: Object.fromEntries(visible.map(field => [field, before[field]])),
+ }),
+ };
+
+ for (const field of Object.keys(after)) {
+ if ((VISIBLE_FIELDS as readonly string[]).includes(field)) continue;
+ changes[field] = { redacted: true };
+ }
+
+ if (secondaryRoleIds) {
+ const from = [...secondaryRoleIds.before].sort((a, b) => a - b);
+ const to = [...secondaryRoleIds.after].sort((a, b) => a - b);
+ if (from.join(",") !== to.join(",")) {
+ changes.secondaryRoleIds = { from, to };
+ }
+ }
+
+ return Object.keys(changes).length > 0 ? changes : null;
+};
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
index c7250761f3..65838d25e2 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/password.route.ts
@@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { assertPasswordSignInEnabled } from "@/api/lib/password-sign-in";
import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens";
import { PasswordModel } from "@/api/models/password";
@@ -76,7 +77,7 @@ export const setPasswordUserAdminRoute = buildRoute({
const db = c.get("db");
const [user] = await db
- .select({ id: core_users.id })
+ .select({ id: core_users.id, name: core_users.name })
.from(core_users)
.where(eq(core_users.id, userId))
.limit(1);
@@ -88,10 +89,23 @@ export const setPasswordUserAdminRoute = buildRoute({
await assertCanEditAdminTarget(c, user.id);
const hashedPassword = await new PasswordModel().encryptPassword(password);
- await db
- .update(core_users)
- .set({ password: hashedPassword })
- .where(eq(core_users.id, user.id));
+ await db.transaction(async tx => {
+ await tx
+ .update(core_users)
+ .set({ password: hashedPassword })
+ .where(eq(core_users.id, user.id));
+ // That it was reset, and by whom - the password itself is never an
+ // audit field, hashed or not.
+ await writeAuditLog(
+ c,
+ {
+ action: "user.password_reset",
+ changes: { password: { redacted: true } },
+ target: { id: user.id, label: user.name, type: "user" },
+ },
+ tx,
+ );
+ });
await c.get("events").emit("user.password.updated", { userId: user.id });
await revokeAccountRecoveryTokens(c, user.id);
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts
index 81c60eafef..7862502e8d 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-device.route.ts
@@ -1,6 +1,7 @@
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
+import { writeAuditLogBestEffort } from "@/api/lib/audit/write";
import { revokeSessions } from "@/api/models/session-revoke";
import { findUserDeviceId } from "@/api/models/user-devices";
@@ -62,6 +63,11 @@ export const revokeUserDeviceAdminRoute = buildRoute({
}
await revokeSessions(c, { deviceId, userId });
+ await writeAuditLogBestEffort(c, {
+ action: "user.device_revoked",
+ metadata: { deviceId },
+ target: { id: userId, type: "user" },
+ });
return c.json({ ok: true as const }, 200);
},
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts
index 304e851ea9..bfe1178b99 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/revoke-devices.route.ts
@@ -1,6 +1,7 @@
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
+import { writeAuditLogBestEffort } from "@/api/lib/audit/write";
import { revokeSessions } from "@/api/models/session-revoke";
import { assertCanEditAdminTarget } from "../lib/assert-edit-user-permission";
@@ -44,6 +45,10 @@ export const revokeUserDevicesAdminRoute = buildRoute({
await assertCanEditAdminTarget(c, userId);
await revokeSessions(c, { userId });
+ await writeAuditLogBestEffort(c, {
+ action: "user.devices_revoked",
+ target: { id: userId, type: "user" },
+ });
return c.json({ ok: true as const }, 200);
},
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
index a486e06e98..9319847c4e 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/update.route.ts
@@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi";
import { and, eq, inArray, ne } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { isValidTimeZone } from "@/api/lib/notifications/digest-period";
import { revokeAccountRecoveryTokens } from "@/api/lib/recovery-tokens";
import { invalidateStaffPermissionsForUser } from "@/api/lib/staff-permission-cache";
@@ -25,6 +26,7 @@ import {
assertCanAssignRoles,
assertCanEditAdminTarget,
} from "../lib/assert-edit-user-permission";
+import { adminUserAuditChanges } from "../lib/audit-changes";
const nameRegex = /^(?!.* {2})[\p{L}\p{N}._@ -]*$/u;
const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
@@ -173,7 +175,15 @@ export const updateUserAdminRoute = buildRoute({
}
const [user] = await db
- .select({ id: core_users.id, roleId: core_users.roleId })
+ .select({
+ id: core_users.id,
+ language: core_users.language,
+ name: core_users.name,
+ nameCode: core_users.nameCode,
+ newsletter: core_users.newsletter,
+ roleId: core_users.roleId,
+ timeZone: core_users.timeZone,
+ })
.from(core_users)
.where(eq(core_users.id, userId))
.limit(1);
@@ -309,20 +319,77 @@ export const updateUserAdminRoute = buildRoute({
values.roleId = body.roleId;
}
- if (secondaryRoleIds !== undefined) {
- await db
- .delete(core_users_secondary_roles)
- .where(eq(core_users_secondary_roles.userId, user.id));
-
- if (secondaryRoleIds.length > 0) {
- await db.insert(core_users_secondary_roles).values(
- secondaryRoleIds.map(roleId => ({
- userId: user.id,
- roleId,
- })),
+ const previousSecondaryRoleIds =
+ secondaryRoleIds === undefined
+ ? []
+ : (
+ await db
+ .select({ roleId: core_users_secondary_roles.roleId })
+ .from(core_users_secondary_roles)
+ .where(eq(core_users_secondary_roles.userId, user.id))
+ ).map(row => row.roleId);
+
+ // The role writes, the profile write and their audit entry commit together:
+ // a role change is exactly what an audit trail is for.
+ const updated = await db.transaction(async tx => {
+ if (secondaryRoleIds !== undefined) {
+ await tx
+ .delete(core_users_secondary_roles)
+ .where(eq(core_users_secondary_roles.userId, user.id));
+
+ if (secondaryRoleIds.length > 0) {
+ await tx.insert(core_users_secondary_roles).values(
+ secondaryRoleIds.map(roleId => ({
+ userId: user.id,
+ roleId,
+ })),
+ );
+ }
+ }
+
+ const [row] =
+ Object.keys(values).length > 0
+ ? await tx
+ .update(core_users)
+ .set(values)
+ .where(eq(core_users.id, user.id))
+ .returning({
+ id: core_users.id,
+ name: core_users.name,
+ email: core_users.email,
+ nameCode: core_users.nameCode,
+ })
+ : [];
+
+ const changes = adminUserAuditChanges({
+ after: values,
+ before: user,
+ secondaryRoleIds:
+ secondaryRoleIds === undefined
+ ? undefined
+ : { after: secondaryRoleIds, before: previousSecondaryRoleIds },
+ });
+ if (changes) {
+ await writeAuditLog(
+ c,
+ {
+ action:
+ changes.roleId || changes.secondaryRoleIds
+ ? "user.roles_changed"
+ : "user.updated",
+ changes,
+ target: {
+ id: user.id,
+ label: row?.name ?? user.name,
+ type: "user",
+ },
+ },
+ tx,
);
}
- }
+
+ return row ?? null;
+ });
// A role change moves which staff entries apply to this user, so their
// cached permission set has to go. Only this user's - the roles themselves
@@ -330,18 +397,7 @@ export const updateUserAdminRoute = buildRoute({
const rolesChanged =
body.roleId !== undefined || secondaryRoleIds !== undefined;
- if (Object.keys(values).length > 0) {
- const [updated] = await db
- .update(core_users)
- .set(values)
- .where(eq(core_users.id, user.id))
- .returning({
- id: core_users.id,
- name: core_users.name,
- email: core_users.email,
- nameCode: core_users.nameCode,
- });
-
+ if (updated) {
if (values.email !== undefined) {
await revokeAccountRecoveryTokens(c, user.id);
}
diff --git a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
index 569a022817..cfe8fed1b2 100644
--- a/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
+++ b/packages/vitnode/src/api/modules/admin/users/routes/verify-email.route.ts
@@ -2,6 +2,7 @@ import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
+import { writeAuditLog } from "@/api/lib/audit/write";
import { invalidateSessionCacheForUser } from "@/api/models/session-revoke";
import { core_users, core_users_confirm_emails } from "@/database/users";
@@ -66,19 +67,34 @@ export const verifyEmailUserAdminRoute = buildRoute({
await assertCanEditAdminTarget(c, userId);
- const [updated] = await db
- .update(core_users)
- .set({ emailVerified: true })
- .where(eq(core_users.id, user.id))
- .returning({
- email: core_users.email,
- name: core_users.name,
- emailVerified: core_users.emailVerified,
- });
+ const updated = await db.transaction(async tx => {
+ const [row] = await tx
+ .update(core_users)
+ .set({ emailVerified: true })
+ .where(eq(core_users.id, user.id))
+ .returning({
+ email: core_users.email,
+ name: core_users.name,
+ emailVerified: core_users.emailVerified,
+ });
- await db
- .delete(core_users_confirm_emails)
- .where(eq(core_users_confirm_emails.userId, user.id));
+ await tx
+ .delete(core_users_confirm_emails)
+ .where(eq(core_users_confirm_emails.userId, user.id));
+
+ if (!user.emailVerified) {
+ await writeAuditLog(
+ c,
+ {
+ action: "user.email_verified",
+ target: { id: user.id, label: row.name, type: "user" },
+ },
+ tx,
+ );
+ }
+
+ return row;
+ });
if (!user.emailVerified) {
await invalidateSessionCacheForUser(c, user.id);
await c
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
index 2ba4d23c59..9ef5fd502f 100644
--- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.test.ts
@@ -5,7 +5,12 @@ import { getTableName } from "drizzle-orm";
import { PgDialect } from "drizzle-orm/pg-core";
import { describe, expect, it, vi } from "vitest";
+import type { ContentActor } from "@/content/revisions";
+import type { ContentDatabase } from "@/content/server/service";
+
+import { recordContentAudit } from "@/content/server/audit";
import { core_content_schedules } from "@/database/content";
+import { createAuditRecorder } from "@/tests/audit-recorder";
import { testEditorialPostContentType } from "@/tests/content-fixtures";
import { executeContentSchedule } from "./execute-content-schedule";
@@ -106,7 +111,10 @@ const harness = ({
const settlements: Settlement[] = [];
let committed = false;
+ const audit = createAuditRecorder();
+
const handleFor = (handle: Handle) => ({
+ insert: (table: unknown) => audit.insert(table),
select: () => ({
from: (table: typeof core_content_schedules) => ({
where: (condition: SQL | undefined) => ({
@@ -171,6 +179,7 @@ const harness = ({
} as unknown as Context;
return {
+ audits: audit.entries,
c,
claims,
committed: () => committed,
@@ -289,6 +298,46 @@ describe("executeContentSchedule", () => {
});
});
+ it("audits the publish as the system, naming who booked it", async () => {
+ const { audits, c, publish } = harness();
+ // What the editorial capture does: record the transition in its own
+ // transaction, as the actor it was given.
+ publish.mockImplementation(
+ async (
+ itemId: number,
+ options: { actor: ContentActor; tx: ContentDatabase },
+ ) => {
+ await recordContentAudit(c, options.tx, {
+ action: "published",
+ actor: options.actor,
+ definition: testEditorialPostContentType,
+ itemId,
+ pluginId: PLUGIN_ID,
+ });
+
+ return outcome;
+ },
+ );
+
+ await executeContentSchedule(c, { generation: 1, scheduleId: 55 });
+
+ expect(audits).toEqual([
+ expect.objectContaining({
+ action: "content.published",
+ actorId: null,
+ actorType: "system",
+ area: "system",
+ correlationId: "schedule:55",
+ ipAddress: null,
+ metadata: {
+ scheduleId: 55,
+ scheduledByUserId: 3,
+ trigger: "scheduled_task",
+ },
+ }),
+ ]);
+ });
+
describe("the effects payload", () => {
it("names the person who booked it", async () => {
// The actor is genuinely the system, so "on whose instruction" has to
diff --git a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
index 517f094577..c151b92767 100644
--- a/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
+++ b/packages/vitnode/src/api/modules/content/helpers/execute-content-schedule.ts
@@ -5,6 +5,7 @@ import type { ContentEditorialOutcome } from "@/content/server/editorial-service
import type { ContentScheduleEffectsPayload } from "@/content/server/schedule-effects";
import type { AnyContentTypeDefinition } from "@/content/types";
+import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context";
import { CONTENT_QUEUE_TASK_SCHEDULE_EFFECTS } from "@/content/const";
import { contentIdFromKey } from "@/content/ids";
import { CONTENT_SYSTEM_ACTOR } from "@/content/server/actor";
@@ -176,14 +177,30 @@ export const executeContentSchedule = async (
return { kind: "skipped", reason: "not an identifier of this type" };
}
- const outcome = await editorialService(c, { pluginId })[claimed.action](
- itemId,
+ // Executed by the system, and audited as such: whoever booked the
+ // schedule did not press the button now. Their id, the schedule and the
+ // trigger ride along in the entry's metadata. The entry is written in this
+ // transaction, so a rolled-back or retried run leaves none, and the claim
+ // above means a settled schedule never runs - or is audited - twice.
+ const outcome = await withAuditContext(
+ c,
{
- // No fake user id anywhere. Who *asked* for this is on the schedule
- // row and travels in the event as `scheduledBy`.
- actor: CONTENT_SYSTEM_ACTOR,
- tx,
+ actor: AUDIT_SYSTEM_ACTOR,
+ area: "system",
+ correlationId: `schedule:${claimed.id}`,
+ metadata: {
+ scheduledByUserId: claimed.createdBy,
+ scheduleId: claimed.id,
+ trigger: "scheduled_task",
+ },
},
+ async () =>
+ await editorialService(c, { pluginId })[claimed.action](itemId, {
+ // No fake user id anywhere. Who *asked* for this is on the schedule
+ // row and travels in the event as `scheduledBy`.
+ actor: CONTENT_SYSTEM_ACTOR,
+ tx,
+ }),
);
// Settled whatever happened. A record that was deleted first, or is
diff --git a/packages/vitnode/src/api/modules/cron/cron.module.ts b/packages/vitnode/src/api/modules/cron/cron.module.ts
index cc3dda7b72..1e4d0dc384 100644
--- a/packages/vitnode/src/api/modules/cron/cron.module.ts
+++ b/packages/vitnode/src/api/modules/cron/cron.module.ts
@@ -1,5 +1,6 @@
import { buildModule } from "@/api/builders";
+import { auditRetentionCron } from "./cron/audit-retention.cron";
import { checkUpdatesCron } from "./cron/check-updates.cron";
import { cleanCron } from "./cron/clean.cron";
import { runCronRoute } from "./routes/cron.route";
@@ -7,5 +8,5 @@ import { runCronRoute } from "./routes/cron.route";
export const cronModule = buildModule({
name: "cron",
routes: [runCronRoute],
- cronJobs: [cleanCron, checkUpdatesCron],
+ cronJobs: [cleanCron, checkUpdatesCron, auditRetentionCron],
});
diff --git a/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts
new file mode 100644
index 0000000000..574c9e0b12
--- /dev/null
+++ b/packages/vitnode/src/api/modules/cron/cron/audit-retention.cron.ts
@@ -0,0 +1,23 @@
+import { pruneAuditLogs } from "@/api/lib/audit/retention";
+import { loadAuditSettings } from "@/api/lib/audit/settings";
+import { writeAuditLog } from "@/api/lib/audit/write";
+import { buildCron } from "@/api/lib/cron";
+
+export const auditRetentionCron = buildCron({
+ name: "audit-retention",
+ description:
+ "Delete Activity Logs entries older than the retention period set in the AdminCP",
+ schedule: "40 3 * * *",
+ handler: async c => {
+ const { retentionDays } = await loadAuditSettings(c.get("db"));
+ const deleted = await pruneAuditLogs(c.get("db"), { retentionDays });
+ if (deleted === 0) return;
+
+ // The one deletion the log ever sees, and it says so.
+ await writeAuditLog(c, {
+ action: "audit_logs.pruned",
+ metadata: { deleted, retentionDays },
+ target: { label: "Activity Logs", type: "settings", id: "activity_logs" },
+ });
+ },
+});
diff --git a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts
index 5e70674ffc..44f910be05 100644
--- a/packages/vitnode/src/api/modules/cron/routes/cron.route.ts
+++ b/packages/vitnode/src/api/modules/cron/routes/cron.route.ts
@@ -2,6 +2,7 @@ import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { buildRoute } from "@/api/builders";
+import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context";
import { cronAuthMiddleware } from "@/api/middlewares/cron-auth.middleware";
import { core_cron } from "@/database/cron";
import { getNextCronRunDate } from "@/lib/api/get-next-cron-run-date";
@@ -117,7 +118,17 @@ export const runCronRoute = buildRoute({
}
try {
- await job.handler(c);
+ await withAuditContext(
+ c,
+ {
+ actor: AUDIT_SYSTEM_ACTOR,
+ area: "system",
+ metadata: { cronJob: job.name, trigger: "cron" },
+ },
+ async () => {
+ await job.handler(c);
+ },
+ );
return { success: true, jobName: job.name };
} catch (error) {
diff --git a/packages/vitnode/src/api/modules/moderation/lib/audit.ts b/packages/vitnode/src/api/modules/moderation/lib/audit.ts
new file mode 100644
index 0000000000..837cefe397
--- /dev/null
+++ b/packages/vitnode/src/api/modules/moderation/lib/audit.ts
@@ -0,0 +1,91 @@
+import type { Context } from "hono";
+
+import type { ContentDatabase } from "@/content/server/service";
+import type { AuditChanges } from "@/lib/audit/const";
+
+import { withAuditContext } from "@/api/lib/audit/context";
+import { writeAuditLog } from "@/api/lib/audit/write";
+
+import type { ReportRow } from "./reports";
+
+type AuditedReport = Pick<
+ ReportRow,
+ | "id"
+ | "pluginId"
+ | "scopeFamily"
+ | "scopeId"
+ | "targetId"
+ | "targetTitle"
+ | "targetType"
+>;
+
+const scopeOf = (report: AuditedReport) =>
+ report.scopeFamily && report.scopeId
+ ? { family: report.scopeFamily, id: report.scopeId }
+ : null;
+
+/**
+ * Runs one moderator operation on a report: every audit entry written inside -
+ * the report's own and any its action causes, such as hiding the reported
+ * post - is attributed to the ModeratorCP and to the report's moderation
+ * scope, so a moderator of that scope (and only of that scope) sees it.
+ */
+export const withReportAudit = async (
+ c: Context,
+ report: AuditedReport,
+ body: () => Promise,
+): Promise =>
+ await withAuditContext(
+ c,
+ {
+ area: "moderator",
+ metadata: { reportId: report.id },
+ scope: scopeOf(report),
+ },
+ body,
+ );
+
+/**
+ * The global audit entry of a report workflow step, written in the same
+ * transaction as the report update and its own timeline event. The report's
+ * timeline (`core_moderation_report_events`) stays the detailed history; this
+ * entry is what a cross-plugin activity view lists.
+ */
+export const recordReportAudit = async (
+ c: Context,
+ tx: ContentDatabase,
+ report: AuditedReport,
+ {
+ action,
+ changes = null,
+ metadata,
+ }: {
+ action: string;
+ changes?: AuditChanges | null;
+ metadata?: Record;
+ },
+): Promise => {
+ await writeAuditLog(
+ c,
+ {
+ action: `report.${action}`,
+ area: "moderator",
+ changes,
+ metadata: {
+ reportedPluginId: report.pluginId,
+ reportedTargetId: report.targetId,
+ reportedTargetType: report.targetType,
+ ...metadata,
+ },
+ scope: scopeOf(report),
+ target: {
+ id: report.id,
+ label: report.targetTitle
+ ? `#${report.id} ${report.targetTitle}`
+ : `#${report.id}`,
+ type: "report",
+ },
+ },
+ tx,
+ );
+};
diff --git a/packages/vitnode/src/api/modules/moderation/reports.test.ts b/packages/vitnode/src/api/modules/moderation/reports.test.ts
index 044a8fbe1b..5ad6cf4d50 100644
--- a/packages/vitnode/src/api/modules/moderation/reports.test.ts
+++ b/packages/vitnode/src/api/modules/moderation/reports.test.ts
@@ -7,6 +7,7 @@ import type { PermissionsStaffArgs } from "@/api/lib/permission-staff";
import type { ModerationCaptureResult } from "@/api/moderation";
import { defineModerationScope, defineReportTarget } from "@/api/moderation";
+import { core_audit_logs } from "@/database/audit";
import { core_files } from "@/database/files";
import {
core_moderation_report_events,
@@ -553,6 +554,39 @@ describe("working a report", () => {
);
});
+ it("records a claim in the activity log next to the report's own timeline", async () => {
+ const h = await harness();
+ const id = await h.reportId(REPORTER, "1");
+
+ const res = await h.call(MOD_A, "POST", `/reports/${id}/claim`, {
+ expectedVersion: 1,
+ });
+
+ expect(res.status).toBe(200);
+ expect(
+ h.memory
+ .rows(core_audit_logs)
+ .filter(entry => entry.action === "report.claimed"),
+ ).toEqual([
+ expect.objectContaining({
+ actorId: MOD_A,
+ actorType: "staff",
+ area: "moderator",
+ changes: {
+ assigneeId: { from: null, to: MOD_A },
+ status: { from: "new", to: "in_progress" },
+ },
+ scopeFamily: FAMILY,
+ scopeId: SECTION_A,
+ targetId: String(id),
+ targetType: "report",
+ }),
+ ]);
+ expect(h.eventsOf(id).map(event => event.type)).toEqual(
+ expect.arrayContaining(["assigned", "status_changed"]),
+ );
+ });
+
it("resolves with an outcome and reopens with the history kept", async () => {
const h = await harness();
const id = await h.reportId(REPORTER, "1");
diff --git a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts
index f73a11e8c0..964b5a8dd9 100644
--- a/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts
+++ b/packages/vitnode/src/api/modules/moderation/routes/notes.routes.ts
@@ -5,6 +5,7 @@ import { buildRoute } from "@/api/builders";
import { requireModerator } from "@/api/lib/moderation/access";
import { core_moderation_report_events } from "@/database/moderation";
+import { recordReportAudit } from "../lib/audit";
import { canChangeNote, loadReportNote } from "../lib/notes";
import {
appendReportEvent,
@@ -80,6 +81,10 @@ export const editReportNoteRoute = buildRoute({
reportId: report.id,
type: "note_edited",
});
+ await recordReportAudit(c, tx, report, {
+ action: "note_edited",
+ metadata: { noteAuthorId: note.actorId, noteId: note.id },
+ });
});
await c.get("events").emit("moderation.report.note_updated", {
@@ -119,6 +124,10 @@ export const deleteReportNoteRoute = buildRoute({
reportId: report.id,
type: "note_deleted",
});
+ await recordReportAudit(c, tx, report, {
+ action: "note_deleted",
+ metadata: { noteAuthorId: note.actorId, noteId: note.id },
+ });
});
await c.get("events").emit("moderation.report.note_deleted", {
diff --git a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts
index 34f39426c8..a4fa8dcce5 100644
--- a/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts
+++ b/packages/vitnode/src/api/modules/moderation/routes/workflow.routes.ts
@@ -6,8 +6,10 @@ import { resolveStaffPermissions } from "@/api/lib/check-staff-permission";
import { canOnReport, requireModerator } from "@/api/lib/moderation/access";
import { core_moderation_reports } from "@/database/moderation";
import { core_users } from "@/database/users";
+import { auditDiff } from "@/lib/audit/diff";
import { MODERATION_PERMISSIONS } from "@/lib/moderation/const";
+import { recordReportAudit, withReportAudit } from "../lib/audit";
import { listModeratorCandidates } from "../lib/candidates";
import { loadReportPeople } from "../lib/people";
import {
@@ -88,6 +90,13 @@ export const claimReportRoute = buildRoute({
assigneeId: moderator.user.id,
status: "in_progress",
});
+ await recordReportAudit(c, tx, report, {
+ action: "claimed",
+ changes: auditDiff({
+ after: { assigneeId: moderator.user.id, status: "in_progress" },
+ before: { assigneeId: report.assigneeId, status: report.status },
+ }),
+ });
if (report.assigneeId !== moderator.user.id) {
await appendReportEvent(tx, {
actorId: moderator.user.id,
@@ -180,6 +189,13 @@ export const assignReportRoute = buildRoute({
const status = assigneeId === null ? "new" : "in_progress";
const updated = await c.get("db").transaction(async tx => {
const next = await updateReport(tx, report, { assigneeId, status });
+ await recordReportAudit(c, tx, report, {
+ action: assigneeId === null ? "unassigned" : "assigned",
+ changes: auditDiff({
+ after: { assigneeId, status },
+ before: { assigneeId: report.assigneeId, status: report.status },
+ }),
+ });
await appendReportEvent(tx, {
actorId: moderator.user.id,
data: { assigneeId, previousAssigneeId: report.assigneeId },
@@ -234,11 +250,16 @@ export const noteReportRoute = buildRoute({
manage: true,
});
- await appendReportEvent(c.get("db"), {
- actorId: moderator.user.id,
- data: { body: note },
- reportId: report.id,
- type: "note",
+ await c.get("db").transaction(async tx => {
+ await appendReportEvent(tx, {
+ actorId: moderator.user.id,
+ data: { body: note },
+ reportId: report.id,
+ type: "note",
+ });
+ // That a note was added - its text stays in the report's own timeline,
+ // behind the report's own permissions.
+ await recordReportAudit(c, tx, report, { action: "note_added" });
});
return c.json({ ok: true }, 201);
@@ -284,6 +305,14 @@ export const resolveReportRoute = buildRoute({
resolvedById: moderator.user.id,
status: "done",
});
+ await recordReportAudit(c, tx, report, {
+ action: "resolved",
+ changes: auditDiff({
+ after: { resolution, status: "done" },
+ before: { resolution: report.resolution, status: report.status },
+ }),
+ metadata: { withExplanation: Boolean(explanation) },
+ });
await appendReportEvent(tx, {
actorId: moderator.user.id,
data: explanation ? { explanation, resolution } : { resolution },
@@ -354,6 +383,13 @@ export const reopenReportRoute = buildRoute({
resolvedById: null,
status,
});
+ await recordReportAudit(c, tx, report, {
+ action: "reopened",
+ changes: auditDiff({
+ after: { resolution: null, status },
+ before: { resolution: report.resolution, status: report.status },
+ }),
+ });
await appendReportEvent(tx, {
actorId: moderator.user.id,
data: {
@@ -416,11 +452,19 @@ export const reportActionRoute = buildRoute({
}
const result = await c.get("db").transaction(async tx => {
- const outcome = await definition.run(c, {
- actorUserId: moderator.user.id,
- targetId: report.targetId,
- tx,
- });
+ // The content write the action performs is audited by its own owner (the
+ // Content Engine's `content.hidden`) - as a moderator's, in the report's
+ // scope.
+ const outcome = await withReportAudit(
+ c,
+ report,
+ async () =>
+ await definition.run(c, {
+ actorUserId: moderator.user.id,
+ targetId: report.targetId,
+ tx,
+ }),
+ );
if (!outcome) return null;
if (!outcome.changed) {
return { outcome, state: stateOf(report) };
@@ -433,6 +477,10 @@ export const reportActionRoute = buildRoute({
reportId: report.id,
type: "action",
});
+ await recordReportAudit(c, tx, report, {
+ action: "action_taken",
+ metadata: { action },
+ });
return { outcome, state: stateOf(next) };
});
diff --git a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts
index fa54471602..1869429ef5 100644
--- a/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts
+++ b/packages/vitnode/src/api/modules/queue/helpers/process-queue-tasks.ts
@@ -16,6 +16,7 @@ import {
import type { QueueTaskConfig } from "@/api/lib/queue";
import type { EnvVitNode } from "@/api/middlewares/global.middleware";
+import { AUDIT_SYSTEM_ACTOR, withAuditContext } from "@/api/lib/audit/context";
import { QueueDeferError } from "@/api/lib/queue";
import { core_queue } from "@/database/queue";
import { resolveQueueTaskOutcome } from "@/lib/api/resolve-queue-task-outcome";
@@ -165,7 +166,18 @@ const runQueueTask = async (
await c.get("log").warn(error);
} else {
try {
- await registered.handler(c, task.payload);
+ // A task runs as the system, whichever request happened to drain it.
+ await withAuditContext(
+ c,
+ {
+ actor: AUDIT_SYSTEM_ACTOR,
+ area: "system",
+ metadata: { queueTask: key, trigger: "queue_task" },
+ },
+ async () => {
+ await registered.handler(c, task.payload);
+ },
+ );
} catch (err) {
if (err instanceof QueueDeferError) {
return {
diff --git a/packages/vitnode/src/api/plugin.ts b/packages/vitnode/src/api/plugin.ts
index bf77ee8e3d..3519b028c3 100644
--- a/packages/vitnode/src/api/plugin.ts
+++ b/packages/vitnode/src/api/plugin.ts
@@ -102,6 +102,12 @@ export const newBuildPluginApiCore = buildApiPlugin({
],
ai: ["can_view", { permission: "can_manage", dependsOn: ["can_view"] }],
cron: ["can_view", { permission: "can_run", dependsOn: ["can_view"] }],
+ activity_logs: [
+ "can_view",
+ { permission: "can_view_sensitive", dependsOn: ["can_view"] },
+ { permission: "can_export", dependsOn: ["can_view"] },
+ { permission: "can_manage_retention", dependsOn: ["can_view"] },
+ ],
report_reasons: [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
diff --git a/packages/vitnode/src/content/approvals/server/decide.ts b/packages/vitnode/src/content/approvals/server/decide.ts
index 951ecb2a83..0834670631 100644
--- a/packages/vitnode/src/content/approvals/server/decide.ts
+++ b/packages/vitnode/src/content/approvals/server/decide.ts
@@ -12,6 +12,8 @@ import type { AnyContentTypeDefinition } from "../../types";
import type { ContentSubmissionDecisionCode } from "../const";
import type { ContentSubmissionRow } from "./submissions";
+import { withAuditContext } from "../../../api/lib/audit/context";
+import { writeAuditLog } from "../../../api/lib/audit/write";
import { core_content_submissions } from "../../../database/content-submissions";
import { CONTENT_PERMISSIONS } from "../../const";
import { contentContainerSourceOf } from "../../containers/association";
@@ -21,6 +23,7 @@ import {
} from "../../containers/server/policy";
import { ContentInputError, ContentVersionConflict } from "../../errors";
import { parseContentId } from "../../ids";
+import { contentAuditLabel } from "../../server/audit";
import { contentDefinitionOf } from "../../server/model";
import { recordContentModerationDecision } from "../../server/notifications/intents";
import { publicationMethods } from "../../server/publication";
@@ -260,17 +263,26 @@ export const decideContentSubmission = async (
});
if (!allowed) throw new ContentSubmissionDecisionError("forbidden");
- const applied: Applied =
- decision === "approve"
- ? await applyApproval(c, tx, {
- actor,
- id: itemId,
- model,
- pluginId: submission.pluginId,
- row,
- submission,
- })
- : { kind: "none" };
+ // A moderator's decision, in the container's moderation scope - for the
+ // decision's own entry and for the write that approving it performs.
+ const scope = container
+ ? { family: container.definition.id, id: String(container.container.id) }
+ : null;
+ const applied: Applied = await withAuditContext(
+ c,
+ { area: "moderator", scope },
+ async () =>
+ decision === "approve"
+ ? await applyApproval(c, tx, {
+ actor,
+ id: itemId,
+ model,
+ pluginId: submission.pluginId,
+ row,
+ submission,
+ })
+ : { kind: "none" },
+ );
const [decided] = await tx
.update(core_content_submissions)
@@ -293,6 +305,29 @@ export const decideContentSubmission = async (
// decision commits.
await recordContentModerationDecision(c, tx, decided);
+ await writeAuditLog(
+ c,
+ {
+ action: `submission.${decision === "approve" ? "approved" : "rejected"}`,
+ actor,
+ area: "moderator",
+ contentTypeId: definition.id,
+ metadata: {
+ contentId: submission.itemId,
+ kind: submission.kind,
+ ...(decision === "reject" ? { withReason: Boolean(reason) } : {}),
+ },
+ pluginId: submission.pluginId,
+ scope,
+ target: {
+ id: submission.id,
+ label: contentAuditLabel(definition, row),
+ type: "submission",
+ },
+ },
+ tx,
+ );
+
return { applied, decided, kind: "decided" as const, model };
});
diff --git a/packages/vitnode/src/content/define-audit.test.ts b/packages/vitnode/src/content/define-audit.test.ts
new file mode 100644
index 0000000000..d5436cee07
--- /dev/null
+++ b/packages/vitnode/src/content/define-audit.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+
+import type { ContentAuditInput } from "./types";
+
+import { defineContentType } from "./define";
+import { ContentEngineError } from "./errors";
+import { field } from "./fields";
+
+const fields = {
+ title: field.text({ required: true }),
+ views: field.number({ integer: true, defaultValue: 0 }),
+};
+
+const define = (audit?: ContentAuditInput | false) =>
+ defineContentType({
+ id: "test.audited",
+ tableName: "test_audited",
+ fields,
+ ...(audit === undefined ? {} : { audit }),
+ });
+
+describe("audit", () => {
+ it.each([
+ [
+ "is on and tracks changes by default",
+ undefined,
+ { enabled: true, excludeFields: [], trackChanges: true },
+ ],
+ [
+ "is off with false",
+ false,
+ { enabled: false, excludeFields: [], trackChanges: false },
+ ],
+ [
+ "keeps the excluded fields and the trackChanges choice",
+ { excludeFields: ["views"], trackChanges: false },
+ { enabled: true, excludeFields: ["views"], trackChanges: false },
+ ],
+ ] as const)("%s", (_label, audit, resolved) => {
+ expect(define(audit).audit).toEqual(resolved);
+ });
+
+ it("rejects an excluded field the content type does not have", () => {
+ expect(() =>
+ define({
+ excludeFields: ["missing" as keyof typeof fields],
+ }),
+ ).toThrow(ContentEngineError);
+ });
+});
diff --git a/packages/vitnode/src/content/define.ts b/packages/vitnode/src/content/define.ts
index 62fc25743c..3364b4fdb0 100644
--- a/packages/vitnode/src/content/define.ts
+++ b/packages/vitnode/src/content/define.ts
@@ -2,6 +2,7 @@ import type { ContentIdStrategy } from "./ids";
import type {
AnyContentTypeDefinition,
ContentAdminConfig,
+ ContentAuditInput,
ContentDeliveryConfig,
ContentDeliveryDescriptionField,
ContentDeliveryEnabled,
@@ -37,6 +38,7 @@ import type {
ContentSearchTitleField,
ContentThreadInput,
ContentTypeDefinition,
+ ResolvedContentAuditConfig,
ResolvedContentDeliveryConfig,
ResolvedContentDuplicationConfig,
ResolvedContentEditorialConfig,
@@ -159,6 +161,38 @@ const assertIndexable = (
}
};
+const resolveContentAudit = (
+ id: string,
+ audit: ContentAuditInput | false | undefined,
+ fieldNames: readonly string[],
+): ResolvedContentAuditConfig => {
+ if (audit === false) {
+ return { enabled: false, excludeFields: [], trackChanges: false };
+ }
+ if (audit !== undefined && (typeof audit !== "object" || audit === null)) {
+ throw new ContentEngineError(
+ "audit is `false` or an options object. Omit it to keep the default.",
+ { contentTypeId: id },
+ );
+ }
+
+ const excludeFields = [...(audit?.excludeFields ?? [])];
+ for (const field of excludeFields) {
+ if (!fieldNames.includes(field)) {
+ throw new ContentEngineError(
+ `audit.excludeFields references unknown field "${field}".`,
+ { contentTypeId: id },
+ );
+ }
+ }
+
+ return {
+ enabled: true,
+ excludeFields,
+ trackChanges: audit?.trackChanges ?? true,
+ };
+};
+
/**
* Declares a Content Engine content type: its fields, its storage and the
* capabilities (publication, localization, public API, editorial workflow,
@@ -268,6 +302,7 @@ export const defineContentType = <
TPublicListField extends TPublicField = never,
>({
admin = {},
+ audit,
delivery,
duplication,
editorial,
@@ -297,6 +332,14 @@ export const defineContentType = <
ContentEditorialEnabled
>;
+ /**
+ * Automatic audit logging: every create, edit, publication, visibility,
+ * schedule and translation write is recorded in the central audit log with
+ * its actor and changed fields. On by default; `false` turns it off, and
+ * `excludeFields` keeps noisy fields out of the recorded changes.
+ */
+ audit?: ContentAuditInput> | false;
+
/**
* Public delivery: canonical URLs (`/{publicApi.path}/:slug` by default), SEO
* metadata, localized alternates, sitemap entries and, with `redirects`, slug
@@ -721,6 +764,7 @@ export const defineContentType = <
> = {
admin: resolvedAdmin,
advanced: resolvedAdvanced,
+ audit: resolveContentAudit(id, audit, fieldNames),
delivery: resolvedDelivery as ResolvedContentDeliveryConfig<
ContentDeliveryEnabled
>,
diff --git a/packages/vitnode/src/content/index.ts b/packages/vitnode/src/content/index.ts
index 7e3e251646..e8f62ade8b 100644
--- a/packages/vitnode/src/content/index.ts
+++ b/packages/vitnode/src/content/index.ts
@@ -543,6 +543,7 @@ export type {
ContentAdminFormMode,
ContentAdminFormSection,
ContentAdminListConfig,
+ ContentAuditInput,
ContentBlocksField,
ContentBooleanField,
ContentCreateInput,
@@ -658,6 +659,7 @@ export type {
PublicFilterableContentFieldName,
PublicWritableContentTypeDefinition,
ResolvedContentAdminConfig,
+ ResolvedContentAuditConfig,
ResolvedContentDeliveryConfig,
ResolvedContentDeliverySeoConfig,
ResolvedContentDuplicationConfig,
diff --git a/packages/vitnode/src/content/server/audit.test.ts b/packages/vitnode/src/content/server/audit.test.ts
new file mode 100644
index 0000000000..3381ade5be
--- /dev/null
+++ b/packages/vitnode/src/content/server/audit.test.ts
@@ -0,0 +1,148 @@
+import type { Context } from "hono";
+
+import { describe, expect, it } from "vitest";
+
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentAuditWrite } from "./audit";
+import type { ContentDatabase } from "./service";
+
+import { writeAuditLog } from "../../api/lib/audit/write";
+import { core_audit_logs } from "../../database/audit";
+import { core_users } from "../../database/users";
+import { createMemoryDb } from "../../tests/memory-db";
+import { defineContentType } from "../define";
+import { field } from "../fields";
+import {
+ collectContentTranslationAudit,
+ recordContentAudit,
+ withContentDuplicateAudit,
+} from "./audit";
+
+const definition = defineContentType({
+ id: "test.note",
+ tableName: "test_notes",
+ fields: {
+ title: field.text({ required: true }),
+ views: field.number({ integer: true, defaultValue: 0 }),
+ },
+ audit: { excludeFields: ["views"] },
+}) as AnyContentTypeDefinition;
+
+const EDITOR = { id: 4, name: "Editor" };
+
+const harness = () => {
+ const memory = createMemoryDb([[core_users, [EDITOR]]]);
+ const c = {
+ get: (key: string) =>
+ key === "db" ? memory.db : key === "admin" ? { user: EDITOR } : undefined,
+ } as unknown as Context;
+ const tx = memory.db as unknown as ContentDatabase;
+
+ const record = async (overrides: Partial) =>
+ await recordContentAudit(c, tx, {
+ action: "created",
+ actor: { type: "staff", userId: EDITOR.id },
+ definition,
+ itemId: 9,
+ pluginId: "@vitnode/notes",
+ ...overrides,
+ });
+
+ return {
+ c,
+ entries: () => memory.rows(core_audit_logs),
+ record,
+ };
+};
+
+describe("recordContentAudit", () => {
+ it("names the changed fields without the excluded ones", async () => {
+ const h = harness();
+
+ await h.record({ action: "updated", changedFields: ["title", "views"] });
+
+ expect(h.entries()[0]).toMatchObject({
+ action: "content.updated",
+ contentTypeId: "test.note",
+ metadata: { changedFields: ["title"] },
+ pluginId: "@vitnode/notes",
+ targetType: "test.note",
+ });
+ });
+
+ it("drops values and field names when trackChanges is off", async () => {
+ const h = harness();
+
+ await h.record({
+ action: "updated",
+ changedFields: ["title"],
+ changes: { title: { from: "a", to: "b" } },
+ definition: {
+ ...definition,
+ audit: { ...definition.audit, trackChanges: false },
+ },
+ });
+
+ expect(h.entries()[0]).toMatchObject({ changes: null, metadata: {} });
+ });
+
+ it("writes a system actor's entry to the system area", async () => {
+ const h = harness();
+
+ await h.record({
+ action: "published",
+ actor: { type: "system", userId: null },
+ });
+
+ expect(h.entries()[0]).toMatchObject({
+ actorId: null,
+ actorType: "system",
+ area: "system",
+ ipAddress: null,
+ });
+ });
+});
+
+describe("withContentDuplicateAudit", () => {
+ it("leaves one content.duplicated entry naming the source, and no translation entries", async () => {
+ const h = harness();
+
+ await withContentDuplicateAudit(h.c, definition, 3, async () => {
+ await h.record({ action: "created" });
+ await h.record({ action: "translation_created", locale: "pl" });
+ await writeAuditLog(h.c, {
+ action: "role.updated",
+ target: { id: 1, type: "role" },
+ });
+ });
+
+ expect(h.entries()).toEqual([
+ expect.objectContaining({
+ action: "content.duplicated",
+ metadata: { sourceId: 3 },
+ targetId: "9",
+ }),
+ expect.objectContaining({ action: "role.updated" }),
+ ]);
+ });
+});
+
+describe("collectContentTranslationAudit", () => {
+ it("holds back the record's translation entries and writes the rest", async () => {
+ const h = harness();
+
+ const { entries } = await collectContentTranslationAudit(
+ h.c,
+ definition,
+ async () => {
+ await h.record({ action: "translation_published", locale: "pl" });
+ await h.record({ action: "published" });
+ },
+ );
+
+ expect(entries.map(entry => entry.metadata.locale)).toEqual(["pl"]);
+ expect(h.entries().map(entry => entry.action)).toEqual([
+ "content.published",
+ ]);
+ });
+});
diff --git a/packages/vitnode/src/content/server/audit.ts b/packages/vitnode/src/content/server/audit.ts
new file mode 100644
index 0000000000..68d5a18cb0
--- /dev/null
+++ b/packages/vitnode/src/content/server/audit.ts
@@ -0,0 +1,431 @@
+import type { Context } from "hono";
+
+import type { AuditPendingEntry } from "../../api/lib/audit/context";
+import type { AuditChanges } from "../../lib/audit/const";
+import type { ContentEventAction } from "../events";
+import type { ContentId } from "../ids";
+import type { ContentActor } from "../revisions";
+import type { AnyContentTypeDefinition } from "../types";
+import type { ContentDatabase } from "./service";
+
+import { withAuditContext } from "../../api/lib/audit/context";
+import { writeAuditLog } from "../../api/lib/audit/write";
+import { AUDIT_CORE_PLUGIN_ID } from "../../lib/audit/const";
+import { auditDiff } from "../../lib/audit/diff";
+import { partitionContentFields } from "../localization";
+
+/**
+ * The Content Engine operations that leave an audit entry, named after the
+ * content event they correspond to. Delivery events are not audited: a slug
+ * change is already part of the `updated` entry that caused it.
+ */
+export type ContentAuditAction = Exclude<
+ ContentEventAction,
+ "delivery_redirect_created" | "delivery_slug_changed"
+>;
+
+/** The audit action stored for a content operation: `content.updated`. */
+export const contentAuditAction = (action: ContentAuditAction): string =>
+ `content.${action}`;
+
+/** Revision operations of the shared history, mapped to their audit action. */
+export const CONTENT_REVISION_AUDIT_ACTIONS = {
+ create: "created",
+ delete: "deleted",
+ hide: "hidden",
+ publish: "published",
+ restore: "restored",
+ unhide: "unhidden",
+ unpublish: "unpublished",
+ update: "updated",
+} as const satisfies Record;
+
+/** Revision operations of one translation's history, mapped to their audit action. */
+export const CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS = {
+ create: "translation_created",
+ delete: "translation_deleted",
+ publish: "translation_published",
+ restore: "translation_restored",
+ unpublish: "translation_unpublished",
+ update: "translation_updated",
+} as const satisfies Record;
+
+/** One Content Engine operation to record, inside the write's transaction. */
+export interface ContentAuditWrite {
+ action: ContentAuditAction;
+ actor: ContentActor;
+ /** Canonical field paths the write moved (`seo.title`, `categories`). */
+ changedFields?: readonly string[];
+ /**
+ * Bounded before/after values, for a content type without revisions. An
+ * editorial content type leaves this out: its revision holds the values.
+ */
+ changes?: AuditChanges | null;
+ definition: AnyContentTypeDefinition;
+ itemId: ContentId;
+ /** The record's title at the time of the write, when the row carries one. */
+ label?: null | string;
+ /** Only for a translation operation. */
+ locale?: string;
+ metadata?: Record;
+ pluginId: string;
+ revisionId?: null | number;
+}
+
+/**
+ * The plugin a content type belongs to, from the content registry - never
+ * from the request. Falls back to the route's plugin for a content type bound
+ * outside the registry (tests, custom wiring).
+ */
+export const contentPluginIdOf = (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+): string =>
+ c
+ .get("core")
+ ?.contentModels?.find(entry => entry.model.definition.id === definition.id)
+ ?.pluginId ??
+ c.get("plugin")?.id ??
+ AUDIT_CORE_PLUGIN_ID;
+
+/** Whether `path` (or the group it belongs to) is one of `excluded`. */
+const isExcludedPath = (path: string, excluded: readonly string[]): boolean =>
+ excluded.includes(path) || excluded.includes(path.split(".")[0]);
+
+/**
+ * The title an entry is labelled with: `admin.titleField` read off the row,
+ * when it is a non-empty string on this row. A localized title lives on the
+ * translation row, so a base-row write of a localized content type has none.
+ */
+export const contentAuditLabel = (
+ definition: AnyContentTypeDefinition,
+ row: null | Record | undefined,
+): null | string => {
+ const field = definition.admin.titleField;
+ if (!row || field === null) return null;
+ const value =
+ row[field] ?? (row.values as Record | undefined)?.[field];
+
+ return typeof value === "string" && value.trim() !== "" ? value : null;
+};
+
+/**
+ * {@link contentAuditLabel}, falling back to the default translation's title
+ * for a localized content type - read with `readDefaultTranslation`, inside the
+ * write's transaction (and before a delete, while it still exists).
+ */
+export const resolveContentAuditLabel = async (
+ definition: AnyContentTypeDefinition,
+ row: null | Record | undefined,
+ readDefaultTranslation?: () => Promise,
+): Promise => {
+ const own = contentAuditLabel(definition, row);
+ if (
+ own !== null ||
+ !readDefaultTranslation ||
+ !definition.audit.enabled ||
+ !definition.localization.enabled
+ ) {
+ return own;
+ }
+
+ return contentAuditLabel(
+ definition,
+ (await readDefaultTranslation()) as null | Record,
+ );
+};
+
+const valueAt = (row: Record, path: string): unknown => {
+ let value: unknown = row;
+ for (const segment of path.split(".")) {
+ if (value === null || typeof value !== "object") return null;
+ value = (value as Record)[segment];
+ }
+
+ return value ?? null;
+};
+
+/**
+ * Bounded before/after values of the scalar paths a plain (revisionless)
+ * write moved. Collections are named in `changedFields` only: a list of ids
+ * says little that the record itself does not.
+ */
+export const contentAuditChanges = (
+ definition: AnyContentTypeDefinition,
+ {
+ after,
+ before,
+ changedFields,
+ scope = "shared",
+ }: {
+ after: Record;
+ before: Record;
+ changedFields: readonly string[];
+ /** Which half of the fields the rows hold: the base row's or a translation's. */
+ scope?: "localized" | "shared";
+ },
+): AuditChanges | null => {
+ const audit = definition.audit;
+ if (!audit.trackChanges) return null;
+
+ const { localizedFields, sharedFields } = partitionContentFields(
+ definition.fields,
+ );
+ const scalars = scope === "localized" ? localizedFields : sharedFields;
+ const paths = changedFields.filter(
+ path =>
+ !isExcludedPath(path, audit.excludeFields) &&
+ scalars[path.split(".")[0]] !== undefined,
+ );
+ if (paths.length === 0) return null;
+
+ return auditDiff({
+ after: Object.fromEntries(paths.map(path => [path, valueAt(after, path)])),
+ before: Object.fromEntries(
+ paths.map(path => [path, valueAt(before, path)]),
+ ),
+ });
+};
+
+/**
+ * Writes the audit entry of one Content Engine operation **inside** its
+ * transaction, so the entry exists exactly when the write committed. Called by
+ * the editorial and translation revision captures and by the plain services -
+ * never by a post-commit event listener. A no-op when the content type has
+ * `audit: false`.
+ */
+export const recordContentAudit = async (
+ c: Context,
+ tx: ContentDatabase,
+ {
+ action,
+ actor,
+ changedFields = [],
+ changes = null,
+ definition,
+ itemId,
+ label = null,
+ locale,
+ metadata,
+ pluginId,
+ revisionId = null,
+ }: ContentAuditWrite,
+): Promise => {
+ const audit = definition.audit;
+ if (!audit.enabled) return;
+
+ const fields = audit.trackChanges
+ ? changedFields.filter(path => !isExcludedPath(path, audit.excludeFields))
+ : [];
+ const itemKey = String(itemId);
+
+ await writeAuditLog(
+ c,
+ {
+ action: contentAuditAction(action),
+ actor,
+ ...(actor.type === "system" ? { area: "system" as const } : {}),
+ changes: audit.trackChanges ? changes : null,
+ contentTypeId: definition.id,
+ metadata: {
+ ...(fields.length > 0 ? { changedFields: fields } : {}),
+ ...(locale === undefined ? {} : { locale }),
+ ...metadata,
+ },
+ pluginId,
+ revisionId,
+ target: { id: itemKey, label, type: definition.id },
+ },
+ tx,
+ );
+};
+
+const isOwnEntry = (
+ entry: AuditPendingEntry,
+ definition: AnyContentTypeDefinition,
+): boolean => entry.contentTypeId === definition.id;
+
+/**
+ * Runs a duplicate so that it leaves one `content.duplicated` entry: the copy's
+ * own `created` entry is rewritten (it names the new record and the source),
+ * and the copied translations, created as part of the same operation, leave
+ * none of their own. Retried slug attempts roll their entry back with their
+ * savepoint, so a retry never doubles it.
+ */
+export const withContentDuplicateAudit = async (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ sourceId: ContentId,
+ body: () => Promise,
+): Promise =>
+ await withAuditContext(
+ c,
+ {
+ intercept: entry => {
+ if (!isOwnEntry(entry, definition)) return entry;
+ if (entry.action === contentAuditAction("created")) {
+ return {
+ ...entry,
+ action: contentAuditAction("duplicated"),
+ metadata: { ...entry.metadata, sourceId },
+ };
+ }
+ if (entry.action === contentAuditAction("translation_created")) {
+ return null;
+ }
+
+ return entry;
+ },
+ },
+ body,
+ );
+
+/**
+ * Runs the translation cascade of a record publish or unpublish and holds back
+ * the per-language entries it would write, so the caller can fold them into
+ * the record's own entry (`metadata.locales`) - or write them after all when
+ * the record itself did not move.
+ */
+export const collectContentTranslationAudit = async (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ body: () => Promise,
+): Promise<{ entries: AuditPendingEntry[]; result: TResult }> => {
+ const entries: AuditPendingEntry[] = [];
+ const result = await withAuditContext(
+ c,
+ {
+ intercept: entry => {
+ if (
+ !isOwnEntry(entry, definition) ||
+ !entry.action.startsWith("content.translation_")
+ ) {
+ return entry;
+ }
+ entries.push(entry);
+
+ return null;
+ },
+ },
+ body,
+ );
+
+ return { entries, result };
+};
+
+/** Writes entries {@link collectContentTranslationAudit} held back. */
+export const writeContentAuditEntries = async (
+ c: Context,
+ tx: ContentDatabase,
+ entries: readonly AuditPendingEntry[],
+): Promise => {
+ for (const entry of entries) await writeAuditLog(c, entry, tx);
+};
+
+/** The locales held-back translation entries name, for the folded entry. */
+export const contentAuditLocales = (
+ entries: readonly AuditPendingEntry[],
+): string[] =>
+ entries
+ .map(entry => entry.metadata.locale)
+ .filter((locale): locale is string => typeof locale === "string");
+
+/**
+ * Runs one localized AdminCP save - the base row and any number of its
+ * translations, in `tx` - and writes its audit entries at the end of it:
+ *
+ * - a **create** leaves one `content.created` entry, labelled with the default
+ * translation's title and listing the languages it was created in;
+ * - an **update** leaves an entry per part that moved (the shared fields, each
+ * language), each labelled with the record's title even when only the
+ * translation carries it.
+ */
+export const withContentSaveAudit = async (
+ c: Context,
+ definition: AnyContentTypeDefinition,
+ tx: ContentDatabase,
+ kind: "create" | "update",
+ body: () => Promise,
+): Promise => {
+ const held: AuditPendingEntry[] = [];
+ const result = await withAuditContext(
+ c,
+ {
+ intercept: entry => {
+ if (!isOwnEntry(entry, definition)) return entry;
+ held.push(entry);
+
+ return null;
+ },
+ },
+ body,
+ );
+
+ const defaultLocale = definition.localization.enabled
+ ? definition.localization.defaultLocale
+ : null;
+ const translations = held.filter(
+ entry => entry.metadata.locale !== undefined,
+ );
+ // The title as the save left it: the default translation's, when this save
+ // wrote one - a shared-field entry read the title before the translations
+ // moved.
+ const label =
+ translations.find(entry => entry.metadata.locale === defaultLocale)?.target
+ .label ??
+ held.find(entry => entry.target.label !== null)?.target.label ??
+ null;
+ const labelled = (entry: AuditPendingEntry): AuditPendingEntry => ({
+ ...entry,
+ target: {
+ ...entry.target,
+ label:
+ entry.metadata.locale === undefined
+ ? (label ?? entry.target.label)
+ : (entry.target.label ?? label),
+ },
+ });
+
+ const created = held.find(
+ entry => entry.action === contentAuditAction("created"),
+ );
+ if (kind === "create" && created) {
+ const folded = translations.filter(
+ entry => entry.action === contentAuditAction("translation_created"),
+ );
+ const changedFields = [
+ ...new Set(
+ [created, ...folded].flatMap(entry =>
+ Array.isArray(entry.metadata.changedFields)
+ ? (entry.metadata.changedFields as unknown[])
+ : [],
+ ),
+ ),
+ ];
+ await writeAuditLog(
+ c,
+ labelled({
+ ...created,
+ metadata: {
+ ...created.metadata,
+ ...(changedFields.length > 0 ? { changedFields } : {}),
+ locales: contentAuditLocales(folded),
+ },
+ }),
+ tx,
+ );
+ await writeContentAuditEntries(
+ c,
+ tx,
+ held
+ .filter(entry => entry !== created && !folded.includes(entry))
+ .map(labelled),
+ );
+
+ return result;
+ }
+
+ await writeContentAuditEntries(c, tx, held.map(labelled));
+
+ return result;
+};
diff --git a/packages/vitnode/src/content/server/editorial-service.test.ts b/packages/vitnode/src/content/server/editorial-service.test.ts
index 4e72792ed1..f80ca9ea44 100644
--- a/packages/vitnode/src/content/server/editorial-service.test.ts
+++ b/packages/vitnode/src/content/server/editorial-service.test.ts
@@ -2,6 +2,7 @@ import type { Context } from "hono";
import { describe, expect, it } from "vitest";
+import { createAuditRecorder } from "@/tests/audit-recorder";
import {
testCategoryContentType,
testEditorialNoteContentType,
@@ -75,9 +76,10 @@ const createDbMock = (
return chain(queue.shift() ?? []);
};
+ const audit = createAuditRecorder();
const db = {
delete: start("delete"),
- insert: start("insert"),
+ insert: (table: unknown) => audit.insert(table) ?? start("insert")(table),
select: start("select"),
transaction: async (
body: (tx: unknown) => Promise,
diff --git a/packages/vitnode/src/content/server/editorial-service.ts b/packages/vitnode/src/content/server/editorial-service.ts
index 8b9c044460..d6b5b5f0ef 100644
--- a/packages/vitnode/src/content/server/editorial-service.ts
+++ b/packages/vitnode/src/content/server/editorial-service.ts
@@ -65,6 +65,15 @@ import {
import { contentRelationStrategy, requireContentId } from "../ids";
import { partitionContentFields } from "../localization";
import { contentColumnsToValues, contentStorageColumns } from "../paths";
+import {
+ collectContentTranslationAudit,
+ CONTENT_REVISION_AUDIT_ACTIONS,
+ contentAuditLocales,
+ recordContentAudit,
+ resolveContentAuditLabel,
+ withContentDuplicateAudit,
+ writeContentAuditEntries,
+} from "./audit";
import {
buildContentRelationOperations,
buildContentRepeatableOperations,
@@ -524,10 +533,37 @@ export const createContentEditorialService = <
return await c.get("db").transaction(async tx => await body(tx));
};
+ /**
+ * The record's title for its audit entry. A localized title lives on the
+ * default translation, read in this transaction - before a delete, since the
+ * translations go with the row.
+ */
+ const auditLabel = async (
+ tx: ContentDatabase,
+ itemId: ContentId,
+ row: null | Record,
+ ): Promise =>
+ await resolveContentAuditLabel(
+ definition,
+ row,
+ translations && definition.localization.enabled
+ ? async () =>
+ await translations
+ .model()
+ .findByLocale(
+ itemId as ContentIdOf,
+ definition.localization.defaultLocale,
+ { tx },
+ )
+ : undefined,
+ );
+
const capture = async (
tx: ContentDatabase,
{
actor,
+ auditLabel: knownLabel,
+ auditMetadata,
changedFields,
operation,
restoredFromRevisionId,
@@ -535,6 +571,9 @@ export const createContentEditorialService = <
version,
}: {
actor: ContentActor;
+ /** Read before a delete, while the translations still exist. */
+ auditLabel?: null | string;
+ auditMetadata?: Record;
changedFields: readonly string[];
operation: ContentRevisionOperation;
restoredFromRevisionId?: number;
@@ -562,7 +601,7 @@ export const createContentEditorialService = <
version,
});
- return await revisions.capture(tx, {
+ const revisionId = await revisions.capture(tx, {
actor,
changedFields,
// Pinned in the same statement batch as the revision: a snapshot naming a
@@ -578,6 +617,29 @@ export const createContentEditorialService = <
snapshot,
version,
});
+
+ // One revision, one audit entry, one transaction: the entry exists exactly
+ // when the write does. It points at the revision rather than copying the
+ // values, which the revision already holds.
+ await recordContentAudit(c, tx, {
+ action: CONTENT_REVISION_AUDIT_ACTIONS[operation],
+ actor,
+ changedFields,
+ definition,
+ itemId,
+ label: knownLabel ?? (await auditLabel(tx, itemId, row)),
+ metadata: {
+ version,
+ ...(restoredFromRevisionId === undefined
+ ? {}
+ : { restoredFromRevisionId }),
+ ...auditMetadata,
+ },
+ pluginId,
+ revisionId,
+ });
+
+ return revisionId;
};
/**
@@ -710,6 +772,15 @@ export const createContentEditorialService = <
.where(and(...conditions))
.returning(ownSelection());
+ // Each language the record moves with it is part of this one operation,
+ // so its audit entry is held back and folded into the record's own.
+ const cascadeAudited = async () =>
+ await collectContentTranslationAudit(
+ c,
+ definition,
+ async () => await cascade(tx, id, operation, options.actor),
+ );
+
if (!row) {
const current = await readOne(id, tx);
if (!current) return null;
@@ -735,7 +806,10 @@ export const createContentEditorialService = <
// reconcile them: the transition is guarded on the base row, so it would
// short-circuit here for ever. Publishing means "make this published",
// and each language's own guard keeps it a no-op once it is.
- const moved = await cascade(tx, id, operation, options.actor);
+ const { entries, result: moved } = await cascadeAudited();
+ // The record did not move, so there is no entry to fold into: each
+ // language that did move is its own operation after all.
+ await writeContentAuditEntries(c, tx, entries);
return {
changed: false,
@@ -765,11 +839,13 @@ export const createContentEditorialService = <
// After the base row moves and before this record's own revision, so each
// language's delivery reservation is taken against a record that is already
// publicly visible: a localized address is only public when both halves are.
- const moved = await cascade(tx, id, operation, options.actor);
+ const { entries, result: moved } = await cascadeAudited();
+ const locales = contentAuditLocales(entries);
const version = versionOf(row);
const revisionId = await capture(tx, {
actor: options.actor,
+ ...(locales.length > 0 ? { auditMetadata: { locales } } : {}),
changedFields: [],
operation,
row,
@@ -1076,6 +1152,10 @@ export const createContentEditorialService = <
// child rows with the record, so this is the last moment the history can
// record what the record was related to.
const collections = (await store?.load(id, tx)) ?? {};
+ // Likewise the title: a localized one goes with the translation rows.
+ const label = definition.localization.enabled
+ ? await auditLabel(tx, id, null)
+ : null;
// Same guard as `guardedWrite`, in a `DELETE` - the version has to be
// part of the statement that removes the row, not checked before it.
@@ -1115,6 +1195,7 @@ export const createContentEditorialService = <
const version = versionOf(row) + 1;
const revisionId = await capture(tx, {
actor: options.actor,
+ auditLabel: label,
changedFields: [],
operation: "delete",
row: { ...row, __collections: collections },
@@ -1446,49 +1527,58 @@ export const createContentEditorialService = <
const duplication: ContentEditorialDuplicationMethods = {
duplicate: async (sourceId, options) => {
- const result = await runContentDuplicate(
- {
- advanced: store,
- c,
- columns,
- definition,
- pluginId,
- table,
- translation:
- translations as ContentDuplicateDependencies["translation"],
- },
+ const result = await withContentDuplicateAudit(
+ c,
+ definition,
sourceId,
- options,
- {
- base: async (values, tx) =>
- await editorial.create(values as ContentCreateInput, {
- actor: options.actor,
- tx,
- }),
- idOf: outcome =>
- requireContentId(
- definition.idStrategy,
- (outcome.row as { id: unknown }).id,
- contentTypeId,
- ),
- translation: async (itemId, locale, values, tx) => {
- if (!translations) {
- throw new ContentEngineError(
- "Duplicating a translation needs the translation editorial service.",
- { contentTypeId },
- );
- }
-
- return await translations
- .editorial()
- .create(
- itemId as ContentIdOf,
- locale,
- values as ContentLocalizedValues,
- { actor: options.actor, tx },
- );
- },
- },
+ async () =>
+ await runContentDuplicate(
+ {
+ advanced: store,
+ c,
+ columns,
+ definition,
+ pluginId,
+ table,
+ translation:
+ translations as ContentDuplicateDependencies["translation"],
+ },
+ sourceId,
+ options,
+ {
+ base: async (values, tx) =>
+ await editorial.create(
+ values as ContentCreateInput,
+ {
+ actor: options.actor,
+ tx,
+ },
+ ),
+ idOf: outcome =>
+ requireContentId(
+ definition.idStrategy,
+ (outcome.row as { id: unknown }).id,
+ contentTypeId,
+ ),
+ translation: async (itemId, locale, values, tx) => {
+ if (!translations) {
+ throw new ContentEngineError(
+ "Duplicating a translation needs the translation editorial service.",
+ { contentTypeId },
+ );
+ }
+
+ return await translations
+ .editorial()
+ .create(
+ itemId as ContentIdOf,
+ locale,
+ values as ContentLocalizedValues,
+ { actor: options.actor, tx },
+ );
+ },
+ },
+ ),
);
return result
diff --git a/packages/vitnode/src/content/server/localized-admin-routes.ts b/packages/vitnode/src/content/server/localized-admin-routes.ts
index 30944367b9..764817e6ee 100644
--- a/packages/vitnode/src/content/server/localized-admin-routes.ts
+++ b/packages/vitnode/src/content/server/localized-admin-routes.ts
@@ -28,6 +28,7 @@ import { CONTENT_LOCALE_MAX_LENGTH, CONTENT_PERMISSIONS } from "../const";
import { zodContentFileReferenceRejection } from "../files";
import { requireContentId } from "../ids";
import { resolveContentActor } from "./actor";
+import { withContentSaveAudit } from "./audit";
import { contentEditorialEffects } from "./editorial-effects";
import { emitContentEvent } from "./emit";
import { contentFileFields } from "./files";
@@ -328,35 +329,44 @@ export const buildContentLocalizedAdminRoutes = <
const result = await withHttpErrors(
"create",
async () =>
- await c.get("db").transaction(async tx => {
- const outcome = editorial
- ? await editorialService(c).create(body.values, {
- actor: resolveContentActor(c),
- tx,
- })
- : null;
- const row =
- outcome?.row ??
- (await model.service(c).create(body.values, { tx }));
-
- const written: ContentTranslationEditorialOutcome[] =
- [];
- for (const entry of ordered) {
- const translated = await writeTranslation(
+ await c.get("db").transaction(
+ async tx =>
+ await withContentSaveAudit(
c,
- requireContentId(
- definition.idStrategy,
- (row as { id: unknown }).id,
- definition.id,
- ) as ContentIdOf,
- entry,
+ definition,
tx,
- );
- if (translated) written.push(translated);
- }
-
- return { outcome, row, written };
- }),
+ "create",
+ async () => {
+ const outcome = editorial
+ ? await editorialService(c).create(body.values, {
+ actor: resolveContentActor(c),
+ tx,
+ })
+ : null;
+ const row =
+ outcome?.row ??
+ (await model.service(c).create(body.values, { tx }));
+
+ const written: ContentTranslationEditorialOutcome[] =
+ [];
+ for (const entry of ordered) {
+ const translated = await writeTranslation(
+ c,
+ requireContentId(
+ definition.idStrategy,
+ (row as { id: unknown }).id,
+ definition.id,
+ ) as ContentIdOf,
+ entry,
+ tx,
+ );
+ if (translated) written.push(translated);
+ }
+
+ return { outcome, row, written };
+ },
+ ),
+ ),
{ contentTypeId: definition.id, structured: editorial },
);
@@ -413,51 +423,63 @@ export const buildContentLocalizedAdminRoutes = <
const result = await withHttpErrors(
"update",
async () =>
- await c.get("db").transaction(async tx => {
- // Shared first, so a base version conflict costs nothing: the
- // translations are never written and the transaction ends here.
- let outcome: ContentEditorialOutcome | null = null;
- let changedFields: readonly string[] = [];
- let row: ContentSelect | null = null;
-
- if (shared !== undefined) {
- if (editorial) {
- outcome = await editorialService(c).update(id, shared, {
- actor: resolveContentActor(c),
- expectedVersion: body.expectedVersion ?? 0,
- tx,
- });
- if (!outcome) return null;
- changedFields = outcome.changedFields;
- row = outcome.row;
- } else {
- const plain = await model.service(c).update(id, shared, { tx });
- if (!plain) return null;
- changedFields = plain.changedFields;
- row = plain.row;
- }
- }
-
- const written: ContentTranslationEditorialOutcome[] =
- [];
- for (const entry of entries) {
- const translated = await writeTranslation(c, id, entry, tx);
- if (translated) written.push(translated);
- }
-
- const current =
- row ?? (await model.service(c).findById(id, { tx }));
-
- return current === null
- ? null
- : {
- changedFields,
- outcome,
- row: current,
- touched: row !== null,
- written,
- };
- }),
+ await c.get("db").transaction(
+ async tx =>
+ await withContentSaveAudit(
+ c,
+ definition,
+ tx,
+ "update",
+ async () => {
+ // Shared first, so a base version conflict costs nothing: the
+ // translations are never written and the transaction ends here.
+ let outcome: ContentEditorialOutcome | null =
+ null;
+ let changedFields: readonly string[] = [];
+ let row: ContentSelect | null = null;
+
+ if (shared !== undefined) {
+ if (editorial) {
+ outcome = await editorialService(c).update(id, shared, {
+ actor: resolveContentActor(c),
+ expectedVersion: body.expectedVersion ?? 0,
+ tx,
+ });
+ if (!outcome) return null;
+ changedFields = outcome.changedFields;
+ row = outcome.row;
+ } else {
+ const plain = await model
+ .service(c)
+ .update(id, shared, { tx });
+ if (!plain) return null;
+ changedFields = plain.changedFields;
+ row = plain.row;
+ }
+ }
+
+ const written: ContentTranslationEditorialOutcome[] =
+ [];
+ for (const entry of entries) {
+ const translated = await writeTranslation(c, id, entry, tx);
+ if (translated) written.push(translated);
+ }
+
+ const current =
+ row ?? (await model.service(c).findById(id, { tx }));
+
+ return current === null
+ ? null
+ : {
+ changedFields,
+ outcome,
+ row: current,
+ touched: row !== null,
+ written,
+ };
+ },
+ ),
+ ),
{ contentTypeId: definition.id, itemId: id, structured: editorial },
);
diff --git a/packages/vitnode/src/content/server/localized-service.test.ts b/packages/vitnode/src/content/server/localized-service.test.ts
index ac92cc09f8..17cb0327d6 100644
--- a/packages/vitnode/src/content/server/localized-service.test.ts
+++ b/packages/vitnode/src/content/server/localized-service.test.ts
@@ -2,6 +2,7 @@ import type { Context } from "hono";
import { describe, expect, it } from "vitest";
+import { createAuditRecorder } from "@/tests/audit-recorder";
import {
testLocalizedArticleContentType,
testPostContentType,
@@ -99,9 +100,10 @@ const createDbMock = (
);
};
+ const audit = createAuditRecorder();
const db = {
delete: start("delete"),
- insert: start("insert"),
+ insert: (table: unknown) => audit.insert(table) ?? start("insert")(table),
select: start("select"),
transaction: async (
body: (tx: unknown) => Promise,
diff --git a/packages/vitnode/src/content/server/schedules-model.ts b/packages/vitnode/src/content/server/schedules-model.ts
index 16350f6629..196a08c8f3 100644
--- a/packages/vitnode/src/content/server/schedules-model.ts
+++ b/packages/vitnode/src/content/server/schedules-model.ts
@@ -17,6 +17,8 @@ import { CONTENT_QUEUE_TASK_SCHEDULE, CONTENT_SCHEDULE_CODES } from "../const";
import { ContentScheduleError } from "../errors";
import { contentIdKey } from "../ids";
import { contentScheduleTimingError } from "../schedules";
+import { resolveContentActor } from "./actor";
+import { recordContentAudit } from "./audit";
/** A schedule row claimed for execution, with everything the handler needs. */
export interface ClaimedContentSchedule {
@@ -170,24 +172,41 @@ export const createContentSchedulesModel = ({
.where(and(scope(itemId), eq(core_content_schedules.status, "pending")));
return {
- cancel: async (itemId, scheduleId) => {
- const [row] = await c
- .get("db")
- .update(core_content_schedules)
- .set({ status: "cancelled" })
- .where(
- and(
- scope(itemId),
- eq(core_content_schedules.id, scheduleId),
- // Only a pending one can be cancelled. Re-cancelling a completed
- // schedule would rewrite history to say it never ran.
- eq(core_content_schedules.status, "pending"),
- ),
- )
- .returning({ action: core_content_schedules.action });
+ cancel: async (itemId, scheduleId) =>
+ await c.get("db").transaction(async tx => {
+ const [row] = await tx
+ .update(core_content_schedules)
+ .set({ status: "cancelled" })
+ .where(
+ and(
+ scope(itemId),
+ eq(core_content_schedules.id, scheduleId),
+ // Only a pending one can be cancelled. Re-cancelling a completed
+ // schedule would rewrite history to say it never ran.
+ eq(core_content_schedules.status, "pending"),
+ ),
+ )
+ .returning({
+ action: core_content_schedules.action,
+ scheduledFor: core_content_schedules.scheduledFor,
+ });
+ if (!row) return null;
+
+ await recordContentAudit(c, tx, {
+ action: "schedule_cancelled",
+ actor: resolveContentActor(c),
+ definition,
+ itemId,
+ metadata: {
+ scheduleAction: row.action,
+ scheduledFor: row.scheduledFor,
+ scheduleId,
+ },
+ pluginId,
+ });
- return row ?? null;
- },
+ return { action: row.action };
+ }),
listForItem: async itemId => {
const rows = await c
@@ -301,6 +320,20 @@ export const createContentSchedulesModel = ({
tx,
});
+ await recordContentAudit(c, tx, {
+ action: "scheduled",
+ actor: resolveContentActor(c),
+ definition,
+ itemId,
+ metadata: {
+ ...(previous ? { replacedPending: true } : {}),
+ scheduleAction: action,
+ scheduledFor,
+ scheduleId: row.id,
+ },
+ pluginId,
+ });
+
return { generation, id: row.id, scheduledFor };
});
},
diff --git a/packages/vitnode/src/content/server/service.test.ts b/packages/vitnode/src/content/server/service.test.ts
index 3f537ab901..e61a0d21b0 100644
--- a/packages/vitnode/src/content/server/service.test.ts
+++ b/packages/vitnode/src/content/server/service.test.ts
@@ -6,6 +6,7 @@ import { PgDialect } from "drizzle-orm/pg-core";
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
+import { createAuditRecorder } from "@/tests/audit-recorder";
import {
testArticleContentType,
testCategoryContentType,
@@ -86,10 +87,17 @@ const createDbMock = (
return chain(queue.shift() ?? []);
};
+ // Audit entries are kept apart from the scripted statements: they are
+ // written inside every audited write, and asserted on their own.
+ const audit = createAuditRecorder();
+
const db = {
delete: start("delete"),
- insert: start("insert"),
+ insert: (table: unknown) => audit.insert(table) ?? start("insert")(table),
select: start("select"),
+ transaction: async (
+ body: (tx: unknown) => Promise,
+ ): Promise => await body(db),
update: start("update"),
};
@@ -97,7 +105,7 @@ const createDbMock = (
get: (key: string) => (key === "db" ? db : variables[key]),
} as Context;
- return { c, calls };
+ return { audits: audit.entries, c, calls };
};
const opsOf = (calls: RecordedCall[], op: string) =>
@@ -252,6 +260,83 @@ describe("content service", () => {
});
});
+ describe("audit", () => {
+ const auditedNotes = createContentModel(
+ defineContentType({
+ id: "test.audited-note",
+ tableName: "test_audited_notes",
+ fields: {
+ title: field.text({ required: true }),
+ body: field.textarea({ nullable: true }),
+ views: field.number({ integer: true, defaultValue: 0 }),
+ },
+ audit: { excludeFields: ["views"] },
+ admin: { titleField: "title" },
+ }),
+ );
+
+ it("records content.updated with bounded before/after values, without excluded fields", async () => {
+ const long = "x".repeat(600);
+ const { audits, c } = createDbMock(
+ [
+ [{ body: null, id: 7, title: "Old", views: 1 }],
+ [{ body: long, id: 7, title: "New", views: 2 }],
+ ],
+ { admin: { user: { id: 4, name: "Editor" } } },
+ );
+
+ await auditedNotes
+ .service(c)
+ .update(7, { body: long, title: "New", views: 2 });
+
+ expect(audits).toEqual([
+ expect.objectContaining({
+ action: "content.updated",
+ actorId: 4,
+ actorType: "staff",
+ changes: {
+ body: { from: null, to: `${"x".repeat(500)}…` },
+ title: { from: "Old", to: "New" },
+ },
+ contentTypeId: "test.audited-note",
+ metadata: { changedFields: ["title", "body"] },
+ targetId: "7",
+ targetLabel: "New",
+ targetType: "test.audited-note",
+ }),
+ ]);
+ });
+
+ it("records nothing when nothing moved", async () => {
+ const { audits, c } = createDbMock([
+ [{ body: null, id: 7, title: "Same", views: 1 }],
+ ]);
+
+ await auditedNotes.service(c).update(7, { title: "Same" });
+
+ expect(audits).toEqual([]);
+ });
+
+ it("records nothing for a content type with audit: false", async () => {
+ const silent = createContentModel(
+ defineContentType({
+ id: "test.silent",
+ tableName: "test_silent",
+ fields: { title: field.text({ required: true }) },
+ audit: false,
+ }),
+ );
+ const { audits, c } = createDbMock([
+ [{ id: 7, title: "Old" }],
+ [{ id: 7, title: "New" }],
+ ]);
+
+ await silent.service(c).update(7, { title: "New" });
+
+ expect(audits).toEqual([]);
+ });
+ });
+
describe("delete", () => {
it("returns the deleted row", async () => {
const { c } = createDbMock([[{ id: 7, title: "Hello" }]]);
diff --git a/packages/vitnode/src/content/server/service.ts b/packages/vitnode/src/content/server/service.ts
index d47fe615f5..9616b62bd4 100644
--- a/packages/vitnode/src/content/server/service.ts
+++ b/packages/vitnode/src/content/server/service.ts
@@ -10,6 +10,7 @@ import type { Context } from "hono";
import { and, eq, inArray, ne, or, sql } from "drizzle-orm";
import type { PaginationCursorColumn } from "../../api/lib/with-pagination";
+import type { AuditChanges } from "../../lib/audit/const";
import type { ContentId, ContentIdOf } from "../ids";
import type { ContentSchemas } from "../schemas";
import type {
@@ -36,6 +37,7 @@ import type {
} from "../types";
import type { ContentVisibilityAction } from "../visibility";
import type { ContentAdvancedStore } from "./advanced-store";
+import type { ContentAuditAction } from "./audit";
import type {
ContentDuplicateTranslationSource,
ContentDuplicationMethods,
@@ -65,6 +67,15 @@ import { contentRelationStrategy, requireContentId } from "../ids";
import { partitionContentFields } from "../localization";
import { contentColumnsToValues, contentStorageColumns } from "../paths";
import { orderableColumns } from "../registry";
+import { resolveContentActor } from "./actor";
+import {
+ contentAuditChanges,
+ contentAuditLabel,
+ contentPluginIdOf,
+ recordContentAudit,
+ resolveContentAuditLabel,
+ withContentDuplicateAudit,
+} from "./audit";
import {
buildContentRelationOperations,
buildContentRepeatableOperations,
@@ -562,6 +573,72 @@ export const createContentService = <
const db = (options?: ContentServiceOptions): ContentDatabase =>
options?.tx ?? c.get("db");
+ // An editorial content type is audited by its revision captures, so only a
+ // content type without one is audited here - never both.
+ const auditing = definition.audit.enabled && !definition.editorial.enabled;
+
+ const auditLabel = async (
+ tx: ContentDatabase,
+ itemId: ContentId,
+ row: null | Record,
+ ): Promise =>
+ await resolveContentAuditLabel(
+ definition,
+ row,
+ translation?.model
+ ? async () =>
+ await translation
+ .model?.()
+ .findByLocale(
+ itemId as ContentIdOf,
+ definition.localization.defaultLocale,
+ { tx },
+ )
+ : undefined,
+ );
+
+ /** Records one plain write, in its transaction. */
+ const audit = async (
+ tx: ContentDatabase,
+ {
+ action,
+ changedFields,
+ changes,
+ itemId,
+ label,
+ row,
+ }: {
+ action: ContentAuditAction;
+ changedFields?: readonly string[];
+ changes?: AuditChanges | null;
+ itemId: ContentId;
+ label?: null | string;
+ row: null | Record;
+ },
+ ): Promise => {
+ if (!auditing) return;
+
+ await recordContentAudit(c, tx, {
+ action,
+ actor: resolveContentActor(c),
+ changedFields,
+ changes,
+ definition,
+ itemId,
+ label: label === undefined ? await auditLabel(tx, itemId, row) : label,
+ pluginId: contentPluginIdOf(c, definition),
+ });
+ };
+
+ /** A transaction whenever the write is audited, so the entry is atomic with it. */
+ const auditedTransaction = async (
+ options: ContentServiceOptions | undefined,
+ body: (tx: ContentDatabase) => Promise,
+ ): Promise =>
+ options?.tx || !auditing
+ ? await body(db(options))
+ : await c.get("db").transaction(async tx => await body(tx));
+
const ownSelection = (): Record =>
Object.fromEntries(ownColumnNames.map(name => [name, columns[name]]));
@@ -738,32 +815,38 @@ export const createContentService = <
options: ContentServiceOptions | undefined,
values: Record,
guard: SQL,
- ): Promise | null> => {
- const database = db(options);
+ ): Promise | null> =>
+ await auditedTransaction(options, async database => {
+ const [row] = await database
+ .update(table)
+ .set(values)
+ .where(and(eq(primaryCursor, id), guard))
+ .returning(ownSelection());
- const [row] = await database
- .update(table)
- .set(values)
- .where(and(eq(primaryCursor, id), guard))
- .returning(ownSelection());
+ if (row) {
+ await audit(database, {
+ action: values.status === "published" ? "published" : "unpublished",
+ itemId: id,
+ row,
+ });
- if (row)
- return {
- changed: true,
- publishedAt: publishedAtOf(row),
- row: toRow(row),
- };
+ return {
+ changed: true,
+ publishedAt: publishedAtOf(row),
+ row: toRow(row),
+ };
+ }
- const current = await readOne(id, database);
+ const current = await readOne(id, database);
- return current
- ? {
- changed: false,
- publishedAt: publishedAtOf(current),
- row: toRow(current),
- }
- : null;
- };
+ return current
+ ? {
+ changed: false,
+ publishedAt: publishedAtOf(current),
+ row: toRow(current),
+ }
+ : null;
+ });
const publish = async (
id: ContentId,
@@ -857,6 +940,12 @@ export const createContentService = <
.returning(ownSelection());
if (!row) return null;
+ await audit(tx, {
+ action: action === "hide" ? "hidden" : "unhidden",
+ itemId: id,
+ row,
+ });
+
return {
changed: true,
...visibilityStateOf(row),
@@ -889,7 +978,8 @@ export const createContentService = <
body: (tx: ContentDatabase) => Promise,
): Promise => {
if (options?.tx) return await body(options.tx);
- if (!store?.enabled) return await body(c.get("db"));
+ // An audited write opens one too: its entry has to commit with it.
+ if (!store?.enabled && !auditing) return await body(c.get("db"));
return await c.get("db").transaction(async tx => await body(tx));
};
@@ -977,39 +1067,58 @@ export const createContentService = <
// In the same transaction as the row it belongs to: a create that
// committed its categories and rolled back its article would leave
// junction rows pointing at nothing.
- if (store?.enabled) {
- await store.write(
- tx,
- requireContentId(definition.idStrategy, row.id, contentTypeId),
- parsed,
- );
- }
+ const itemId = requireContentId(
+ definition.idStrategy,
+ row.id,
+ contentTypeId,
+ );
+ if (store?.enabled) await store.write(tx, itemId, parsed);
+
+ await audit(tx, {
+ action: "created",
+ changedFields: Object.keys(parsed),
+ itemId,
+ // Never the translation's: a record's translations are written after it.
+ label: contentAuditLabel(definition, row),
+ row,
+ });
return toRow(row);
}),
delete: async (id, options) => {
const remove = async (database: ContentDatabase) => {
+ // Read first: a localized title goes with the translation rows.
+ const translated =
+ auditing && definition.localization.enabled
+ ? await auditLabel(database, id, null)
+ : null;
const [row] = await database
.delete(table)
.where(eq(primaryCursor, id))
.returning(ownSelection());
- if (row) {
- await cancelContentSubmissions(database, definition, id);
- await recordContentNotificationCleanup(c, database, {
- definition,
- itemId: id,
- });
- }
+ if (!row) return null;
+
+ await cancelContentSubmissions(database, definition, id);
+ await recordContentNotificationCleanup(c, database, {
+ definition,
+ itemId: id,
+ });
+ await audit(database, {
+ action: "deleted",
+ itemId: id,
+ label: translated ?? contentAuditLabel(definition, row),
+ row,
+ });
- return row ? toRow(row) : null;
+ return toRow(row);
};
// The cleanup intent must commit with the delete, so a type with
// content notifications always deletes in a transaction.
return hasContentNotificationDependents(c, definition)
? await inTransaction(options, remove)
- : await remove(db(options));
+ : await auditedTransaction(options, remove);
},
findById: async (id, options) => {
@@ -1330,6 +1439,20 @@ export const createContentService = <
.where(eq(primaryCursor, id))
.returning(ownSelection());
+ await audit(tx, {
+ action: "updated",
+ changedFields,
+ changes: auditing
+ ? contentAuditChanges(definition, {
+ after: projectRow(row),
+ before: projectRow(current),
+ changedFields: changedPaths,
+ })
+ : null,
+ itemId: id,
+ row,
+ });
+
return {
changedFields: changedFields as ContentChangedPath[],
row: toRow(row),
@@ -1427,52 +1550,61 @@ export const createContentService = <
const duplicationMethods: ContentDuplicationMethods = {
duplicate: async (sourceId, options = {}) => {
const translationModel = translation?.model;
- const result = await runContentDuplicate(
- {
- advanced: store,
- c,
- columns,
- definition,
- table,
- translation:
- translation && translationModel
- ? {
- columns: translation.columns,
- model:
- translationModel as unknown as () => ContentDuplicateTranslationSource,
- table: translation.table,
- }
- : undefined,
- },
+ const result = await withContentDuplicateAudit(
+ c,
+ definition,
sourceId,
- options,
- {
- base: async (values, tx) =>
- await service.create(values as ContentCreateInput, {
- tx,
- }),
- idOf: row =>
- requireContentId(
- definition.idStrategy,
- (row as { id: unknown }).id,
- contentTypeId,
- ),
- translation: async (itemId, locale, values, tx) => {
- if (!translationModel) {
- throw new ContentEngineError(
- "Duplicating a translation needs the translation model.",
- { contentTypeId },
- );
- }
+ async () =>
+ await runContentDuplicate(
+ {
+ advanced: store,
+ c,
+ columns,
+ definition,
+ table,
+ translation:
+ translation && translationModel
+ ? {
+ columns: translation.columns,
+ model:
+ translationModel as unknown as () => ContentDuplicateTranslationSource,
+ table: translation.table,
+ }
+ : undefined,
+ },
+ sourceId,
+ options,
+ {
+ base: async (values, tx) =>
+ await service.create(
+ values as ContentCreateInput,
+ {
+ tx,
+ },
+ ),
+ idOf: row =>
+ requireContentId(
+ definition.idStrategy,
+ (row as { id: unknown }).id,
+ contentTypeId,
+ ),
+ translation: async (itemId, locale, values, tx) => {
+ if (!translationModel) {
+ throw new ContentEngineError(
+ "Duplicating a translation needs the translation model.",
+ { contentTypeId },
+ );
+ }
- return await translationModel().create(
- itemId as ContentIdOf,
- locale,
- values as ContentLocalizedValues,
- { tx },
- );
- },
- },
+ return await translationModel().create(
+ itemId as ContentIdOf,
+ locale,
+ values as ContentLocalizedValues,
+ { tx },
+ );
+ },
+ },
+ ),
);
return result
diff --git a/packages/vitnode/src/content/server/translation-editorial-service.ts b/packages/vitnode/src/content/server/translation-editorial-service.ts
index 1a820774be..e1ce4b8988 100644
--- a/packages/vitnode/src/content/server/translation-editorial-service.ts
+++ b/packages/vitnode/src/content/server/translation-editorial-service.ts
@@ -36,6 +36,11 @@ import {
contentInnerFields,
splitContentFieldPath,
} from "../paths";
+import {
+ CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS,
+ contentAuditLabel,
+ recordContentAudit,
+} from "./audit";
import {
applyContentDeliveryWrite,
contentSlugHistoryFor,
@@ -342,8 +347,8 @@ export const createContentTranslationEditorialService = <
row: ContentTranslationRow;
version: number;
},
- ): Promise =>
- await revisionsFor(languageId).capture(tx, {
+ ): Promise => {
+ const revisionId = await revisionsFor(languageId).capture(tx, {
actor,
changedFields,
itemId: row.itemId,
@@ -357,6 +362,29 @@ export const createContentTranslationEditorialService = <
version,
});
+ // In the transaction, beside the revision it points at - see the shared
+ // service's `capture`.
+ await recordContentAudit(c, tx, {
+ action: CONTENT_TRANSLATION_REVISION_AUDIT_ACTIONS[operation],
+ actor,
+ changedFields,
+ definition,
+ itemId: row.itemId,
+ label: contentAuditLabel(definition, row),
+ locale,
+ metadata: {
+ version,
+ ...(restoredFromRevisionId === undefined
+ ? {}
+ : { restoredFromRevisionId }),
+ },
+ pluginId,
+ revisionId,
+ });
+
+ return revisionId;
+ };
+
const unchanged = (
operation: ContentTranslationRevisionOperation,
row: ContentTranslationRow,
diff --git a/packages/vitnode/src/content/server/translation-model.ts b/packages/vitnode/src/content/server/translation-model.ts
index 96b860a98e..0fa0336226 100644
--- a/packages/vitnode/src/content/server/translation-model.ts
+++ b/packages/vitnode/src/content/server/translation-model.ts
@@ -4,6 +4,7 @@ import type { Context } from "hono";
import { and, asc, eq, inArray, ne, sql } from "drizzle-orm";
+import type { AuditChanges } from "../../lib/audit/const";
import type { ContentId, ContentIdOf } from "../ids";
import type { ContentTranslationSchemas } from "../schemas";
import type {
@@ -14,6 +15,7 @@ import type {
ContentTranslationMeta,
ContentTranslationRow,
} from "../types";
+import type { ContentAuditAction } from "./audit";
import type { ContentLanguage } from "./language-resolver";
import type { ContentDatabase } from "./service";
@@ -34,6 +36,13 @@ import {
contentStorageColumns,
contentValuesToColumns,
} from "../paths";
+import { resolveContentActor } from "./actor";
+import {
+ contentAuditChanges,
+ contentAuditLabel,
+ contentPluginIdOf,
+ recordContentAudit,
+} from "./audit";
import {
contentDatabase,
findContentLanguage,
@@ -430,7 +439,44 @@ export const createContentTranslationModel = <
}
};
- return {
+ // A translation of an editorial content type is audited by its revision
+ // capture; only a revisionless one is audited here.
+ const auditing = definition.audit.enabled && !definition.editorial.enabled;
+
+ const audited = async (
+ options: ContentTranslationOptions | undefined,
+ body: (tx: ContentDatabase) => Promise,
+ ): Promise =>
+ options?.tx
+ ? await body(options.tx)
+ : await c.get("db").transaction(async tx => await body(tx));
+
+ const audit = async (
+ tx: ContentDatabase,
+ action: ContentAuditAction,
+ row: ContentTranslationRow,
+ {
+ changedFields,
+ changes,
+ }: {
+ changedFields?: readonly string[];
+ changes?: AuditChanges | null;
+ } = {},
+ ): Promise => {
+ await recordContentAudit(c, tx, {
+ action,
+ actor: resolveContentActor(c),
+ changedFields,
+ changes,
+ definition,
+ itemId: row.itemId,
+ label: contentAuditLabel(definition, row),
+ locale: row.locale,
+ pluginId: contentPluginIdOf(c, definition),
+ });
+ };
+
+ const methods: ContentTranslationModel = {
create: async (itemId, locale, values, options) => {
const target = await language(locale, {
requireEnabled: true,
@@ -786,14 +832,83 @@ export const createContentTranslationModel = <
await touchItem(itemId, database);
+ const updated = toRow(row, target.locale);
+ if (auditing) {
+ await audit(database, "translation_updated", updated, {
+ changedFields,
+ changes: contentAuditChanges(definition, {
+ after: updated.values,
+ before: toRow(current, target.locale).values,
+ changedFields,
+ scope: "localized",
+ }),
+ });
+ }
+
return {
changed: true,
changedFields,
- row: toRow(row, target.locale),
+ row: updated,
version: versionOf(row),
};
},
};
+ if (!auditing) return methods;
+
+ return {
+ ...methods,
+ create: async (itemId, locale, values, options) =>
+ await audited(options, async tx => {
+ const row = await methods.create(itemId, locale, values, {
+ ...options,
+ tx,
+ });
+ await audit(tx, "translation_created", row, {
+ changedFields: Object.keys(row.values),
+ });
+
+ return row;
+ }),
+ delete: async (itemId, locale, options) =>
+ await audited(options, async tx => {
+ const row = await methods.delete(itemId, locale, { ...options, tx });
+ if (row) await audit(tx, "translation_deleted", row);
+
+ return row;
+ }),
+ publish: async (itemId, locale, options) =>
+ await audited(options, async tx => {
+ const result = await methods.publish(itemId, locale, {
+ ...options,
+ tx,
+ });
+ if (result?.changed) {
+ await audit(tx, "translation_published", result.row);
+ }
+
+ return result;
+ }),
+ unpublish: async (itemId, locale, options) =>
+ await audited(options, async tx => {
+ const result = await methods.unpublish(itemId, locale, {
+ ...options,
+ tx,
+ });
+ if (result?.changed) {
+ await audit(tx, "translation_unpublished", result.row);
+ }
+
+ return result;
+ }),
+ // Audited where the row is read and written, so the entry costs no extra
+ // query; this only supplies the transaction.
+ update: async (itemId, locale, values, options) =>
+ await audited(
+ options,
+ async tx =>
+ await methods.update(itemId, locale, values, { ...options, tx }),
+ ),
+ };
};
/** The language registry keyed by id, for turning a stored FK back into a locale. */
diff --git a/packages/vitnode/src/content/types.ts b/packages/vitnode/src/content/types.ts
index 42bdd3c9f6..3b66f5dc7b 100644
--- a/packages/vitnode/src/content/types.ts
+++ b/packages/vitnode/src/content/types.ts
@@ -2035,6 +2035,33 @@ export interface ContentLeafColumn {
path: string;
}
+/**
+ * How the Content Engine records a content type's writes in the central audit
+ * log. Present and on by default; `audit: false` turns it off.
+ */
+export interface ContentAuditInput {
+ /**
+ * Fields whose changes are never named or diffed in an audit entry -
+ * counters, cached totals, anything that changes on every write. The write
+ * itself is still recorded. Sensitive fields need no entry: they are always
+ * redacted.
+ */
+ excludeFields?: readonly TFieldName[];
+ /**
+ * Record which fields changed (and, without `editorial`, their bounded
+ * before/after values). Defaults to `true`; `false` records the operation
+ * only.
+ */
+ trackChanges?: boolean;
+}
+
+/** {@link ContentAuditInput} with every default applied. */
+export interface ResolvedContentAuditConfig {
+ enabled: boolean;
+ excludeFields: string[];
+ trackChanges: boolean;
+}
+
/**
* A **resolved** content type, as returned by `defineContentType()`.
*
@@ -2067,6 +2094,8 @@ export interface ContentTypeDefinition<
admin: ResolvedContentAdminConfig;
/** Generated junction tables, child tables and the leaf-path mapping. */
advanced: ResolvedContentAdvancedConfig;
+ /** Automatic audit logging of the content type's writes. */
+ audit: ResolvedContentAuditConfig;
delivery: ResolvedContentDeliveryConfig;
duplication: ResolvedContentDuplicationConfig;
diff --git a/packages/vitnode/src/database/audit.ts b/packages/vitnode/src/database/audit.ts
new file mode 100644
index 0000000000..ebd1217b6d
--- /dev/null
+++ b/packages/vitnode/src/database/audit.ts
@@ -0,0 +1,99 @@
+import { camelCase, index } from "drizzle-orm/pg-core";
+
+import type { AuditChanges } from "@/lib/audit/const";
+
+import {
+ AUDIT_ACTION_MAX_LENGTH,
+ AUDIT_ACTOR_TYPES,
+ AUDIT_AREAS,
+ AUDIT_DEFAULT_RETENTION_DAYS,
+ AUDIT_PLUGIN_ID_MAX_LENGTH,
+ AUDIT_SCOPE_FAMILY_MAX_LENGTH,
+ AUDIT_SCOPE_ID_MAX_LENGTH,
+ AUDIT_TARGET_ID_MAX_LENGTH,
+ AUDIT_TARGET_LABEL_MAX_LENGTH,
+ AUDIT_TARGET_TYPE_MAX_LENGTH,
+} from "@/lib/audit/const";
+
+import { core_users } from "./users";
+
+/**
+ * The append-only history of administrative, moderation, plugin and Content
+ * Engine operations - who did what, when, to which resource.
+ *
+ * Deliberately separate from `core_logs` (technical warnings and errors) and
+ * from `core_content_revisions` (what a record's content used to be). Nothing
+ * here cascades from the resources it describes: `pluginId`, `targetType`,
+ * `targetId` and `targetLabel` are plain text, so an entry outlives the record,
+ * the content type and the plugin it names. Only the actor is a foreign key,
+ * set to `null` when the account is deleted; `actorName` keeps the name the
+ * entry was written under.
+ */
+export const core_audit_logs = camelCase.table.withRLS(
+ "core_audit_logs",
+ t => ({
+ id: t.bigserial({ mode: "number" }).primaryKey(),
+ pluginId: t.varchar({ length: AUDIT_PLUGIN_ID_MAX_LENGTH }).notNull(),
+ area: t.varchar({ enum: AUDIT_AREAS, length: 16 }).notNull(),
+ action: t.varchar({ length: AUDIT_ACTION_MAX_LENGTH }).notNull(),
+ actorType: t.varchar({ enum: AUDIT_ACTOR_TYPES, length: 16 }).notNull(),
+ actorId: t.integer().references(() => core_users.id, {
+ onDelete: "set null",
+ onUpdate: "cascade",
+ }),
+ actorName: t.varchar({ length: 255 }),
+ targetType: t.varchar({ length: AUDIT_TARGET_TYPE_MAX_LENGTH }).notNull(),
+ targetId: t.varchar({ length: AUDIT_TARGET_ID_MAX_LENGTH }),
+ targetLabel: t.varchar({ length: AUDIT_TARGET_LABEL_MAX_LENGTH }),
+ changes: t.jsonb().$type(),
+ metadata: t.jsonb().$type>().notNull().default({}),
+ contentTypeId: t.varchar({ length: 100 }),
+ /** Not a foreign key: revisions are pruned by their own retention. */
+ revisionId: t.integer(),
+ scopeFamily: t.varchar({ length: AUDIT_SCOPE_FAMILY_MAX_LENGTH }),
+ scopeId: t.varchar({ length: AUDIT_SCOPE_ID_MAX_LENGTH }),
+ ipAddress: t.varchar({ length: 45 }),
+ /** One id per request or background run, shared by every entry it wrote. */
+ correlationId: t.varchar({ length: 64 }),
+ createdAt: t.timestamp().notNull().defaultNow(),
+ }),
+ t => [
+ // Newest first, date ranges and the retention sweep.
+ index("core_audit_logs_created_at_id_idx").on(t.createdAt, t.id),
+ index("core_audit_logs_actor_idx").on(t.actorId, t.createdAt),
+ index("core_audit_logs_plugin_idx").on(t.pluginId, t.createdAt),
+ index("core_audit_logs_action_idx").on(t.action, t.createdAt),
+ index("core_audit_logs_area_idx").on(t.area, t.createdAt),
+ // One entity's history.
+ index("core_audit_logs_target_idx").on(
+ t.pluginId,
+ t.targetType,
+ t.targetId,
+ t.createdAt,
+ ),
+ // A scoped moderator's view.
+ index("core_audit_logs_scope_idx").on(
+ t.scopeFamily,
+ t.scopeId,
+ t.createdAt,
+ ),
+ ],
+);
+
+/**
+ * The Activity Logs settings, one row (`id = 1`). Absent until an admin first
+ * saves them, so every reader falls back to the defaults.
+ */
+export const core_audit_settings = camelCase.table.withRLS(
+ "core_audit_settings",
+ t => ({
+ id: t.integer().primaryKey().default(1),
+ /** Days an entry is kept. `0` keeps everything. */
+ retentionDays: t.integer().notNull().default(AUDIT_DEFAULT_RETENTION_DAYS),
+ updatedAt: t
+ .timestamp()
+ .notNull()
+ .defaultNow()
+ .$onUpdate(() => new Date()),
+ }),
+);
diff --git a/packages/vitnode/src/database/relations.ts b/packages/vitnode/src/database/relations.ts
index 3325dfe1e5..4b60cbb474 100644
--- a/packages/vitnode/src/database/relations.ts
+++ b/packages/vitnode/src/database/relations.ts
@@ -2,6 +2,7 @@ import { defineRelations } from "drizzle-orm";
import * as admins from "./admins";
import * as ai from "./ai";
+import * as audit from "./audit";
import * as content from "./content";
import * as contentContainers from "./content-containers";
import * as contentSubmissions from "./content-submissions";
@@ -24,6 +25,7 @@ import * as users from "./users";
export const coreSchema = {
...admins,
...ai,
+ ...audit,
...content,
...contentContainers,
...contentSubmissions,
diff --git a/packages/vitnode/src/lib/audit/actions.test.ts b/packages/vitnode/src/lib/audit/actions.test.ts
new file mode 100644
index 0000000000..44db39d838
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/actions.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from "vitest";
+
+import { defineAuditActions } from "./actions";
+import { AUDIT_ACTION_MAX_LENGTH } from "./const";
+
+describe("defineAuditActions", () => {
+ it("returns valid declarations unchanged", () => {
+ const actions = [
+ { action: "topic.moved", targetType: "topic" },
+ { action: "post.status_changed", targetType: "forum.post" },
+ ] as const;
+
+ expect(defineAuditActions(actions)).toBe(actions);
+ });
+
+ it.each([
+ ["an uppercase action", [{ action: "Topic.Moved", targetType: "topic" }]],
+ ["a trailing dot", [{ action: "topic.", targetType: "topic" }]],
+ [
+ "an action over the length limit",
+ [{ action: "a".repeat(AUDIT_ACTION_MAX_LENGTH + 1), targetType: "t" }],
+ ],
+ [
+ "a target type with spaces",
+ [{ action: "topic.moved", targetType: "a b" }],
+ ],
+ [
+ "a duplicate action",
+ [
+ { action: "topic.moved", targetType: "topic" },
+ { action: "topic.moved", targetType: "post" },
+ ],
+ ],
+ ])("throws for %s", (_label, actions) => {
+ expect(() => defineAuditActions(actions)).toThrow();
+ });
+});
diff --git a/packages/vitnode/src/lib/audit/actions.ts b/packages/vitnode/src/lib/audit/actions.ts
new file mode 100644
index 0000000000..4d3353b96f
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/actions.ts
@@ -0,0 +1,139 @@
+import {
+ AUDIT_ACTION_MAX_LENGTH,
+ AUDIT_ACTION_PATTERN,
+ AUDIT_TARGET_TYPE_PATTERN,
+} from "./const";
+
+/**
+ * One audit action a plugin records, declared so the Activity Logs filters can
+ * offer it before it has ever happened.
+ *
+ * Its label is not part of the declaration: the AdminCP translates
+ * `{pluginId}.audit.actions.{action}` from the plugin's own locale file (dots
+ * in the action nest, so `topic.moved` is `audit.actions.topic.moved`), and
+ * falls back to the action id itself. Nothing a plugin declares runs when an
+ * entry is rendered, which is what keeps entries readable after the plugin is
+ * uninstalled.
+ */
+export interface AuditActionDefinition {
+ /** `topic.moved` - lowercase words joined by dots, unique within the plugin. */
+ action: string;
+ /** What it acts on: `topic`, `order`, `settings`. */
+ targetType: string;
+}
+
+/**
+ * Declares the audit actions a plugin records with `c.get("audit").record()`.
+ * Pass the result to `buildApiPlugin({ auditActions })`. Universal.
+ *
+ * @throws When an action or target type is not a valid identifier, or an
+ * action is declared twice.
+ *
+ * @example
+ * ```ts
+ * import { defineAuditActions } from "@vitnode/core/lib/audit/actions";
+ *
+ * export const forumAuditActions = defineAuditActions([
+ * { action: "topic.moved", targetType: "topic" },
+ * { action: "topic.locked", targetType: "topic" },
+ * ]);
+ * ```
+ */
+export const defineAuditActions = <
+ const TActions extends readonly AuditActionDefinition[],
+>(
+ actions: TActions,
+): TActions => {
+ const seen = new Set();
+ for (const { action, targetType } of actions) {
+ if (
+ action.length > AUDIT_ACTION_MAX_LENGTH ||
+ !AUDIT_ACTION_PATTERN.test(action)
+ ) {
+ throw new Error(
+ `Audit action "${action}" must be lowercase words joined by dots, like "topic.moved".`,
+ );
+ }
+ if (!AUDIT_TARGET_TYPE_PATTERN.test(targetType)) {
+ throw new Error(
+ `Audit target type "${targetType}" of "${action}" must be a lowercase identifier.`,
+ );
+ }
+ if (seen.has(action)) {
+ throw new Error(`Audit action "${action}" is declared twice.`);
+ }
+ seen.add(action);
+ }
+
+ return actions;
+};
+
+/** The Content Engine's actions, recorded under the content type's plugin. */
+export const CONTENT_AUDIT_ACTIONS = [
+ "content.created",
+ "content.updated",
+ "content.deleted",
+ "content.duplicated",
+ "content.published",
+ "content.unpublished",
+ "content.hidden",
+ "content.unhidden",
+ "content.restored",
+ "content.scheduled",
+ "content.schedule_cancelled",
+ "content.translation_created",
+ "content.translation_updated",
+ "content.translation_deleted",
+ "content.translation_published",
+ "content.translation_unpublished",
+ "content.translation_restored",
+] as const;
+
+/** Everything core records under `@vitnode/core`. */
+export const CORE_AUDIT_ACTIONS = defineAuditActions([
+ { action: "role.created", targetType: "role" },
+ { action: "role.updated", targetType: "role" },
+ { action: "role.deleted", targetType: "role" },
+ { action: "admin_permissions.created", targetType: "staff_entry" },
+ { action: "admin_permissions.updated", targetType: "staff_entry" },
+ { action: "admin_permissions.deleted", targetType: "staff_entry" },
+ { action: "moderator_permissions.created", targetType: "staff_entry" },
+ { action: "moderator_permissions.updated", targetType: "staff_entry" },
+ { action: "moderator_permissions.deleted", targetType: "staff_entry" },
+ { action: "user.updated", targetType: "user" },
+ { action: "user.roles_changed", targetType: "user" },
+ { action: "user.password_reset", targetType: "user" },
+ { action: "user.email_verified", targetType: "user" },
+ { action: "user.device_revoked", targetType: "user" },
+ { action: "user.devices_revoked", targetType: "user" },
+ { action: "settings.updated", targetType: "settings" },
+ { action: "audit_settings.updated", targetType: "settings" },
+ { action: "ai_access.role_updated", targetType: "role" },
+ { action: "ai_access.user_override_saved", targetType: "user" },
+ { action: "ai_access.user_override_removed", targetType: "user" },
+ { action: "notifications.type_updated", targetType: "notification_type" },
+ { action: "notifications.paused", targetType: "settings" },
+ { action: "notifications.resumed", targetType: "settings" },
+ { action: "notifications.emails_cancelled", targetType: "settings" },
+ { action: "notifications.all_marked_read", targetType: "settings" },
+ { action: "notifications.all_deleted", targetType: "settings" },
+ { action: "notifications.member_preferences_reset", targetType: "settings" },
+ { action: "report_reason.created", targetType: "report_reason" },
+ { action: "report_reason.updated", targetType: "report_reason" },
+ { action: "report_reason.deleted", targetType: "report_reason" },
+ { action: "report_reason.reordered", targetType: "report_reason" },
+ { action: "report.claimed", targetType: "report" },
+ { action: "report.assigned", targetType: "report" },
+ { action: "report.unassigned", targetType: "report" },
+ { action: "report.note_added", targetType: "report" },
+ { action: "report.note_edited", targetType: "report" },
+ { action: "report.note_deleted", targetType: "report" },
+ { action: "report.resolved", targetType: "report" },
+ { action: "report.reopened", targetType: "report" },
+ { action: "report.action_taken", targetType: "report" },
+ { action: "submission.approved", targetType: "submission" },
+ { action: "submission.rejected", targetType: "submission" },
+ { action: "cron.run", targetType: "cron_job" },
+ { action: "audit_logs.pruned", targetType: "settings" },
+ { action: "audit_logs.exported", targetType: "settings" },
+] as const);
diff --git a/packages/vitnode/src/lib/audit/const.ts b/packages/vitnode/src/lib/audit/const.ts
new file mode 100644
index 0000000000..a811381de7
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/const.ts
@@ -0,0 +1,109 @@
+/**
+ * Where an audited operation was performed: the AdminCP, the ModeratorCP (or a
+ * moderator tool on the public site), a member's own write on the public site,
+ * or a background task with nobody behind it.
+ */
+export const AUDIT_AREAS = ["admin", "moderator", "public", "system"] as const;
+
+export type AuditArea = (typeof AUDIT_AREAS)[number];
+
+/**
+ * The two staff logs of the AdminCP: moderators' (the `moderator` area) and
+ * admins' (every other area, system and member activity included).
+ */
+export const AUDIT_STAFF_LOGS = ["admin", "moderator"] as const;
+
+export type AuditStaffLog = (typeof AUDIT_STAFF_LOGS)[number];
+
+/**
+ * Who performed an audited operation: a staff member (admin or moderator), a
+ * signed-in member writing through the public API, or the system itself. The
+ * same vocabulary the Content Engine uses for its revisions.
+ */
+export const AUDIT_ACTOR_TYPES = ["staff", "api", "system"] as const;
+
+export type AuditActorType = (typeof AUDIT_ACTOR_TYPES)[number];
+
+/** The plugin id core records its own operations under. */
+export const AUDIT_CORE_PLUGIN_ID = "@vitnode/core";
+
+export const AUDIT_PLUGIN_ID_MAX_LENGTH = 255;
+export const AUDIT_ACTION_MAX_LENGTH = 150;
+export const AUDIT_TARGET_TYPE_MAX_LENGTH = 100;
+export const AUDIT_TARGET_ID_MAX_LENGTH = 64;
+export const AUDIT_TARGET_LABEL_MAX_LENGTH = 255;
+export const AUDIT_SCOPE_FAMILY_MAX_LENGTH = 100;
+export const AUDIT_SCOPE_ID_MAX_LENGTH = 64;
+
+/**
+ * Stable, lowercase, dot-separated identifiers: `category.updated`,
+ * `report.status_changed`, `content.translation_published`.
+ */
+export const AUDIT_ACTION_PATTERN = /^[a-z0-9_]+(?:\.[a-z0-9_]+)*$/;
+
+/**
+ * A target type uses the same alphabet: `category`, `report`, or a content
+ * type id such as `blog.category`.
+ */
+export const AUDIT_TARGET_TYPE_PATTERN = /^[a-z0-9_-]+(?:\.[a-z0-9_-]+)*$/;
+
+/** Longest string a change or metadata value keeps before it is cut. */
+export const AUDIT_STRING_MAX_LENGTH = 500;
+/** Most fields one `changes` object keeps. */
+export const AUDIT_CHANGES_MAX_FIELDS = 50;
+/** Most keys one metadata object (at any depth) keeps. */
+export const AUDIT_OBJECT_MAX_KEYS = 50;
+/** Most items an array keeps. */
+export const AUDIT_ARRAY_MAX_ITEMS = 50;
+/** How deep a nested value is followed before it is summarised. */
+export const AUDIT_MAX_DEPTH = 4;
+/** Serialized budget of `changes`, and separately of `metadata`, in bytes. */
+export const AUDIT_JSON_MAX_BYTES = 16_384;
+
+/** What a value is replaced with when it must never be stored. */
+export const AUDIT_REDACTED = "[redacted]";
+/** What a value is replaced with when it is too large to be worth storing. */
+export const AUDIT_TRUNCATED = "[truncated]";
+/** What a rich-text document is stored as: the revision has the document. */
+export const AUDIT_RICH_TEXT = "[rich text]";
+
+/** Retention choices offered in the AdminCP, in days. `0` keeps everything. */
+export const AUDIT_RETENTION_DAYS_OPTIONS = [0, 30, 90, 180, 365, 730] as const;
+export const AUDIT_DEFAULT_RETENTION_DAYS = 365;
+/** Rows the retention job deletes per statement, so a backlog never locks the table. */
+export const AUDIT_RETENTION_BATCH_SIZE = 5_000;
+/** Most rows one CSV export contains. */
+export const AUDIT_EXPORT_MAX_ROWS = 10_000;
+
+/** A single before/after transition of one field. */
+export interface AuditChange {
+ from?: unknown;
+ /**
+ * `true` when the field is sensitive: the change is recorded, its values are
+ * not.
+ */
+ redacted?: true;
+ to?: unknown;
+}
+
+/** Structured before/after changes, keyed by field name. */
+export type AuditChanges = Record;
+
+/** The scope a moderation operation belongs to, as the moderation registry names it. */
+export interface AuditScope {
+ family: string;
+ id: number | string;
+}
+
+/** The resource an audited operation acted on. */
+export interface AuditTarget {
+ /** Numeric and string ids are both stored as text. `null` for a singleton (settings). */
+ id?: null | number | string;
+ /**
+ * A short human-readable name, kept so the entry still reads well after the
+ * resource is deleted. Not personal data beyond what the resource shows.
+ */
+ label?: null | string;
+ /** `category`, `role`, `report`, `settings`... */
+ type: string;
+}
diff --git a/packages/vitnode/src/lib/audit/csv.test.ts b/packages/vitnode/src/lib/audit/csv.test.ts
new file mode 100644
index 0000000000..15d3424d07
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/csv.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from "vitest";
+
+import { csvCell, toCsv } from "./csv";
+
+describe("csvCell", () => {
+ it.each([
+ ['=HYPERLINK("http://x")', `"'=HYPERLINK(""http://x"")"`],
+ ["+1", `"'+1"`],
+ ["-2", `"'-2"`],
+ ["@SUM(A1)", `"'@SUM(A1)"`],
+ ["\tcmd", `"'\tcmd"`],
+ ["\rcmd", `"'\rcmd"`],
+ ['say "hi"', `"say ""hi"""`],
+ ["plain", `"plain"`],
+ [null, `""`],
+ [undefined, `""`],
+ [42, `"42"`],
+ [false, `"false"`],
+ [new Date("2026-01-02T00:00:00.000Z"), `"2026-01-02T00:00:00.000Z"`],
+ [{ a: "b" }, `"{""a"":""b""}"`],
+ ])("%j -> %s", (value, cell) => {
+ expect(csvCell(value)).toBe(cell);
+ });
+});
+
+describe("toCsv", () => {
+ it("writes the header first and joins rows with CRLF", () => {
+ expect(
+ toCsv(
+ ["id", "label"],
+ [
+ [1, "A"],
+ [2, "=B"],
+ ],
+ ),
+ ).toBe(`"id","label"\r\n"1","A"\r\n"2","'=B"`);
+ });
+});
diff --git a/packages/vitnode/src/lib/audit/csv.ts b/packages/vitnode/src/lib/audit/csv.ts
new file mode 100644
index 0000000000..06d0360cfa
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/csv.ts
@@ -0,0 +1,33 @@
+/** Characters a spreadsheet reads as the start of a formula. */
+const FORMULA_START = /^[=+\-@\t\r]/;
+
+/**
+ * One CSV cell: `null` and `undefined` are empty, objects are JSON, and a
+ * value a spreadsheet would run as a formula (`=HYPERLINK(...)`, `+1`, `@SUM`)
+ * is prefixed with `'` so it is shown, not evaluated. Always quoted.
+ */
+export const csvCell = (value: unknown): string => {
+ if (value === null || value === undefined) return '""';
+
+ let text: string;
+ if (value instanceof Date) text = value.toISOString();
+ else if (typeof value === "string") text = value;
+ else if (
+ typeof value === "number" ||
+ typeof value === "boolean" ||
+ typeof value === "bigint"
+ ) {
+ text = value.toString();
+ } else text = JSON.stringify(value) ?? "";
+
+ if (FORMULA_START.test(text)) text = `'${text}`;
+
+ return `"${text.replaceAll('"', '""')}"`;
+};
+
+/** A CSV document - header row first, CRLF line endings (RFC 4180). */
+export const toCsv = (
+ header: readonly string[],
+ rows: readonly (readonly unknown[])[],
+): string =>
+ [header, ...rows].map(row => row.map(csvCell).join(",")).join("\r\n");
diff --git a/packages/vitnode/src/lib/audit/diff.test.ts b/packages/vitnode/src/lib/audit/diff.test.ts
new file mode 100644
index 0000000000..f9665210bc
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/diff.test.ts
@@ -0,0 +1,80 @@
+import { describe, expect, it } from "vitest";
+
+import { AUDIT_RICH_TEXT } from "./const";
+import { auditDiff } from "./diff";
+
+const doc = (text: string) => ({
+ content: [{ content: [{ text, type: "text" }], type: "paragraph" }],
+ type: "doc",
+});
+
+describe("auditDiff", () => {
+ it("leaves unchanged fields out and keeps transitions to and from null", () => {
+ expect(
+ auditDiff({
+ after: { color: "#fff", name: "News", parentId: null },
+ before: { color: null, name: "News", parentId: 3 },
+ }),
+ ).toEqual({
+ color: { from: null, to: "#fff" },
+ parentId: { from: 3, to: null },
+ });
+ });
+
+ it("counts a field missing on one side as null", () => {
+ expect(auditDiff({ after: { name: "News" }, before: null })).toEqual({
+ name: { from: null, to: "News" },
+ });
+ });
+
+ it("is null when nothing moved", () => {
+ expect(
+ auditDiff({
+ after: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") },
+ before: { tags: [1, 2], updated: new Date("2026-01-01T00:00:00Z") },
+ }),
+ ).toBeNull();
+ });
+
+ it("compares only the included fields and skips the excluded ones", () => {
+ const before = { name: "A", slug: "a", updatedAt: 1 };
+ const after = { name: "B", slug: "b", updatedAt: 2 };
+
+ expect(auditDiff({ after, before, include: ["name"] })).toEqual({
+ name: { from: "A", to: "B" },
+ });
+ expect(auditDiff({ after, before, exclude: ["updatedAt"] })).toEqual({
+ name: { from: "A", to: "B" },
+ slug: { from: "a", to: "b" },
+ });
+ });
+
+ it("records a sensitive field without its values", () => {
+ expect(
+ auditDiff({
+ after: { passwordHash: "new" },
+ before: { passwordHash: "old" },
+ }),
+ ).toEqual({ passwordHash: { redacted: true } });
+ });
+
+ it("stores a changed rich-text document as a marker, and an unchanged one not at all", () => {
+ expect(
+ auditDiff({
+ after: { body: doc("new"), intro: doc("same") },
+ before: { body: doc("old"), intro: doc("same") },
+ }),
+ ).toEqual({ body: { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT } });
+ });
+
+ it("serializes dates in a recorded change", () => {
+ expect(
+ auditDiff({
+ after: { publishedAt: new Date("2026-02-01T00:00:00.000Z") },
+ before: { publishedAt: null },
+ }),
+ ).toEqual({
+ publishedAt: { from: null, to: "2026-02-01T00:00:00.000Z" },
+ });
+ });
+});
diff --git a/packages/vitnode/src/lib/audit/diff.ts b/packages/vitnode/src/lib/audit/diff.ts
new file mode 100644
index 0000000000..ee101743b0
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/diff.ts
@@ -0,0 +1,106 @@
+import type { AuditChanges } from "./const";
+
+import { AUDIT_RICH_TEXT } from "./const";
+import { isSensitiveAuditKey, sanitizeAuditChanges } from "./sanitize";
+
+/** Arguments of {@link auditDiff}. */
+export interface AuditDiffArgs {
+ /** The values after the operation. `null` for a deletion. */
+ after: null | Partial>;
+ /** The values before the operation. `null` for a creation. */
+ before: null | Partial>;
+ /**
+ * Fields never compared - high-frequency counters, timestamps, derived
+ * columns. Sensitive fields need no entry here: they are always redacted.
+ */
+ exclude?: readonly NoInfer[];
+ /**
+ * Compare only these fields. Prefer it for security-relevant writes: an
+ * allowlist cannot leak a column added later.
+ */
+ include?: readonly NoInfer[];
+}
+
+const isRichText = (value: unknown): boolean =>
+ typeof value === "object" &&
+ value !== null &&
+ (value as { type?: unknown }).type === "doc" &&
+ Array.isArray((value as { content?: unknown }).content);
+
+const comparable = (value: unknown): unknown => {
+ if (value instanceof Date) return value.toISOString();
+
+ return value;
+};
+
+const sameValue = (before: unknown, after: unknown): boolean => {
+ const left = comparable(before);
+ const right = comparable(after);
+ if (left === right) return true;
+ if (left === null || right === null) return false;
+ if (typeof left !== "object" || typeof right !== "object") return false;
+
+ return JSON.stringify(left) === JSON.stringify(right);
+};
+
+/**
+ * Structured before/after changes between two plain objects, ready for
+ * `c.get("audit").record({ changes })`. Universal and pure.
+ *
+ * - Unchanged fields are left out; a transition to or from `null` is kept.
+ * - A field missing on one side counts as `null` on that side.
+ * - Sensitive fields (passwords, tokens, secrets, keys...) are recorded as
+ * `{ redacted: true }` - that they changed, never their values.
+ * - Rich-text documents are compared by their serialized form, never
+ * diffed node by node, and stored as a marker: their history belongs to
+ * Content Revisions, not to audit logs.
+ * - The result is bounded by the same limits as every audit payload.
+ *
+ * @returns `null` when nothing changed.
+ *
+ * @example
+ * ```ts
+ * import { auditDiff } from "@vitnode/core/lib/audit/diff";
+ *
+ * auditDiff({
+ * before: { name: "News", slug: "news" },
+ * after: { name: "Announcements", slug: "announcements" },
+ * });
+ * // { name: { from: "News", to: "Announcements" },
+ * // slug: { from: "news", to: "announcements" } }
+ * ```
+ */
+export const auditDiff = ({
+ after,
+ before,
+ exclude = [],
+ include,
+}: AuditDiffArgs): AuditChanges | null => {
+ const left = (before ?? {}) as Record;
+ const right = (after ?? {}) as Record;
+ const excluded = new Set(exclude);
+ const fields =
+ include ??
+ ([...new Set([...Object.keys(left), ...Object.keys(right)])] as string[]);
+
+ const changes: AuditChanges = {};
+ for (const field of fields) {
+ if (excluded.has(field)) continue;
+ const from = left[field] ?? null;
+ const to = right[field] ?? null;
+
+ if (isRichText(from) || isRichText(to)) {
+ if (JSON.stringify(from) !== JSON.stringify(to)) {
+ changes[field] = { from: AUDIT_RICH_TEXT, to: AUDIT_RICH_TEXT };
+ }
+ continue;
+ }
+ if (sameValue(from, to)) continue;
+
+ changes[field] = isSensitiveAuditKey(field)
+ ? { redacted: true }
+ : { from, to };
+ }
+
+ return sanitizeAuditChanges(changes);
+};
diff --git a/packages/vitnode/src/lib/audit/sanitize.test.ts b/packages/vitnode/src/lib/audit/sanitize.test.ts
new file mode 100644
index 0000000000..59239bb9c1
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/sanitize.test.ts
@@ -0,0 +1,197 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ AUDIT_ARRAY_MAX_ITEMS,
+ AUDIT_CHANGES_MAX_FIELDS,
+ AUDIT_MAX_DEPTH,
+ AUDIT_OBJECT_MAX_KEYS,
+ AUDIT_REDACTED,
+ AUDIT_RICH_TEXT,
+ AUDIT_STRING_MAX_LENGTH,
+ AUDIT_TRUNCATED,
+} from "./const";
+import {
+ isSensitiveAuditKey,
+ sanitizeAuditChanges,
+ sanitizeAuditMetadata,
+ sanitizeAuditValue,
+} from "./sanitize";
+
+const nested = (depth: number): unknown =>
+ depth === 0 ? "leaf" : { child: nested(depth - 1) };
+
+describe("isSensitiveAuditKey", () => {
+ it.each([
+ ["accessToken", true],
+ ["client_secret", true],
+ ["passwordHash", true],
+ ["apiKey", true],
+ ["recoveryCodes", true],
+ ["REFRESH-TOKEN", true],
+ ["sessionId", true],
+ ["Authorization", true],
+ ["maxTokens", false],
+ ["keyboard", false],
+ ["name", false],
+ ["sessionCount", false],
+ ["apiVersion", false],
+ ])("%s -> %s", (key, sensitive) => {
+ expect(isSensitiveAuditKey(key)).toBe(sensitive);
+ });
+});
+
+describe("sanitizeAuditValue", () => {
+ it("cuts a long string", () => {
+ const value = sanitizeAuditValue("x".repeat(AUDIT_STRING_MAX_LENGTH + 10));
+
+ expect(value).toBe(`${"x".repeat(AUDIT_STRING_MAX_LENGTH)}…`);
+ });
+
+ it("summarises a value nested past the depth limit", () => {
+ let value = sanitizeAuditValue(nested(AUDIT_MAX_DEPTH + 2));
+ for (let depth = 0; depth < AUDIT_MAX_DEPTH; depth++) {
+ value = (value as { child: unknown }).child;
+ }
+
+ expect(value).toBe(AUDIT_TRUNCATED);
+ });
+
+ it("keeps only an error's name", () => {
+ const error = new TypeError("postgres://user:hunter2@db/prod");
+
+ expect(sanitizeAuditValue(error)).toEqual({ error: "TypeError" });
+ });
+
+ it("stores a rich-text document as a marker", () => {
+ expect(
+ sanitizeAuditValue({ content: [{ type: "paragraph" }], type: "doc" }),
+ ).toBe(AUDIT_RICH_TEXT);
+ });
+
+ it("drops class instances, functions and symbols", () => {
+ class Connection {
+ password = "hunter2";
+ }
+
+ expect(
+ sanitizeAuditValue({
+ connection: new Connection(),
+ fn: () => 1,
+ kept: 1,
+ symbol: Symbol("x"),
+ }),
+ ).toEqual({ kept: 1 });
+ });
+
+ it("redacts sensitive keys at any depth", () => {
+ expect(
+ sanitizeAuditValue({
+ name: "Ann",
+ provider: { clientSecret: "abc", id: "github" },
+ }),
+ ).toEqual({
+ name: "Ann",
+ provider: { clientSecret: AUDIT_REDACTED, id: "github" },
+ });
+ });
+
+ it.each([
+ [Number.NaN, null],
+ [Number.POSITIVE_INFINITY, null],
+ [10n, "10"],
+ [new Date("2026-01-02T03:04:05.000Z"), "2026-01-02T03:04:05.000Z"],
+ [new Date("not a date"), null],
+ ])("turns %s into %s", (input, output) => {
+ expect(sanitizeAuditValue(input)).toEqual(output);
+ });
+
+ it("cuts long arrays and wide objects with a marker", () => {
+ const array = sanitizeAuditValue(
+ Array.from({ length: AUDIT_ARRAY_MAX_ITEMS + 5 }, (_, index) => index),
+ ) as unknown[];
+ const object = sanitizeAuditValue(
+ Object.fromEntries(
+ Array.from({ length: AUDIT_OBJECT_MAX_KEYS + 5 }, (_, index) => [
+ `k${index}`,
+ index,
+ ]),
+ ),
+ ) as Record;
+
+ expect(array).toHaveLength(AUDIT_ARRAY_MAX_ITEMS + 1);
+ expect(array.at(-1)).toBe(AUDIT_TRUNCATED);
+ expect(Object.keys(object)).toHaveLength(AUDIT_OBJECT_MAX_KEYS + 1);
+ expect(object[AUDIT_TRUNCATED]).toBe(true);
+ });
+});
+
+describe("sanitizeAuditMetadata", () => {
+ it("replaces metadata over the size budget with a marker", () => {
+ const metadata = Object.fromEntries(
+ Array.from({ length: 40 }, (_, index) => [
+ `field${index}`,
+ "x".repeat(AUDIT_STRING_MAX_LENGTH),
+ ]),
+ );
+
+ expect(sanitizeAuditMetadata(metadata)).toEqual({
+ [AUDIT_TRUNCATED]: true,
+ });
+ });
+
+ it.each([null, undefined])("is empty for %s", metadata => {
+ expect(sanitizeAuditMetadata(metadata)).toEqual({});
+ });
+});
+
+describe("sanitizeAuditChanges", () => {
+ it("keeps only the fact that a sensitive or flagged field changed", () => {
+ expect(
+ sanitizeAuditChanges({
+ name: { from: "a", to: "b" },
+ password: { from: "old", to: "new" },
+ webhook: { from: "x", redacted: true, to: "y" },
+ }),
+ ).toEqual({
+ name: { from: "a", to: "b" },
+ password: { redacted: true },
+ webhook: { redacted: true },
+ });
+ });
+
+ it("keeps at most the field limit", () => {
+ const changes = Object.fromEntries(
+ Array.from({ length: AUDIT_CHANGES_MAX_FIELDS + 5 }, (_, index) => [
+ `f${index}`,
+ { from: index, to: index + 1 },
+ ]),
+ );
+
+ expect(Object.keys(sanitizeAuditChanges(changes) ?? {})).toHaveLength(
+ AUDIT_CHANGES_MAX_FIELDS,
+ );
+ });
+
+ it("drops the values but keeps the field names over the size budget", () => {
+ const long = "x".repeat(AUDIT_STRING_MAX_LENGTH);
+ const changes = Object.fromEntries(
+ Array.from({ length: 20 }, (_, index) => [
+ `f${index}`,
+ { from: long, to: long },
+ ]),
+ );
+
+ const result = sanitizeAuditChanges({
+ ...changes,
+ token: { from: "a", to: "b" },
+ });
+
+ expect(result?.f0).toEqual({ from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED });
+ expect(result?.token).toEqual({ redacted: true });
+ expect(Object.keys(result ?? {})).toHaveLength(21);
+ });
+
+ it.each([null, undefined, {}])("is null for %s", changes => {
+ expect(sanitizeAuditChanges(changes)).toBeNull();
+ });
+});
diff --git a/packages/vitnode/src/lib/audit/sanitize.ts b/packages/vitnode/src/lib/audit/sanitize.ts
new file mode 100644
index 0000000000..b0aadb153b
--- /dev/null
+++ b/packages/vitnode/src/lib/audit/sanitize.ts
@@ -0,0 +1,209 @@
+import type { AuditChange, AuditChanges } from "./const";
+
+import {
+ AUDIT_ARRAY_MAX_ITEMS,
+ AUDIT_CHANGES_MAX_FIELDS,
+ AUDIT_JSON_MAX_BYTES,
+ AUDIT_MAX_DEPTH,
+ AUDIT_OBJECT_MAX_KEYS,
+ AUDIT_REDACTED,
+ AUDIT_RICH_TEXT,
+ AUDIT_STRING_MAX_LENGTH,
+ AUDIT_TRUNCATED,
+} from "./const";
+
+/** Words that make a key sensitive wherever they appear in it. */
+const SENSITIVE_WORDS = new Set([
+ "authorization",
+ "cookie",
+ "cookies",
+ "credential",
+ "credentials",
+ "hash",
+ "otp",
+ "passphrase",
+ "passwd",
+ "password",
+ "salt",
+ "secret",
+ "secrets",
+ "totp",
+]);
+
+/** Word pairs that are sensitive together but harmless apart (`api` + `key`). */
+const SENSITIVE_PAIRS: readonly (readonly [string, string])[] = [
+ ["access", "key"],
+ ["api", "key"],
+ ["private", "key"],
+ ["recovery", "code"],
+ ["recovery", "codes"],
+ ["session", "id"],
+ ["signing", "key"],
+];
+
+/** Splits `refreshToken`, `refresh_token` and `REFRESH-TOKEN` into lowercase words. */
+const keyWords = (key: string): string[] =>
+ key
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter(word => word !== "");
+
+/**
+ * Whether a field or metadata key names something that must never be stored
+ * in an audit entry: passwords and their hashes, tokens, secrets, API and
+ * private keys, cookies, recovery codes, credentials. Matched on whole words,
+ * so `accessToken` and `client_secret` are sensitive while `maxTokens` and
+ * `keyboard` are not.
+ */
+export const isSensitiveAuditKey = (key: string): boolean => {
+ const words = keyWords(key);
+ if (words.some(word => SENSITIVE_WORDS.has(word))) return true;
+ // `token` names a credential; `tokens` is a count (`maxTokens`).
+ if (words.at(-1) === "token") return true;
+
+ return SENSITIVE_PAIRS.some(([first, second]) => {
+ const index = words.indexOf(first);
+
+ return index !== -1 && words[index + 1] === second;
+ });
+};
+
+const isRichTextDocument = (value: object): boolean =>
+ (value as { type?: unknown }).type === "doc" &&
+ Array.isArray((value as { content?: unknown }).content);
+
+const truncateString = (value: string): string =>
+ value.length > AUDIT_STRING_MAX_LENGTH
+ ? `${value.slice(0, AUDIT_STRING_MAX_LENGTH)}…`
+ : value;
+
+const isPlainObject = (value: object): value is Record => {
+ const prototype = Object.getPrototypeOf(value) as unknown;
+
+ return prototype === Object.prototype || prototype === null;
+};
+
+/**
+ * Turns any value into a bounded, JSON-safe one that is safe to store:
+ * sensitive keys are replaced with `[redacted]`, long strings and collections
+ * are cut, rich-text documents become a marker (the revision keeps the
+ * document), errors keep their name only, and anything that is not data
+ * (functions, symbols, class instances) is dropped. Universal.
+ */
+export const sanitizeAuditValue = (value: unknown, depth = 0): unknown => {
+ if (value === null) return null;
+
+ switch (typeof value) {
+ case "bigint":
+ return value.toString();
+ case "boolean":
+ return value;
+ case "number":
+ return Number.isFinite(value) ? value : null;
+ case "object":
+ break;
+ case "string":
+ return truncateString(value);
+ default:
+ return undefined;
+ }
+
+ if (value instanceof Date) {
+ return Number.isNaN(value.getTime()) ? null : value.toISOString();
+ }
+ // An error's message is where connection strings and tokens end up, so only
+ // its class survives.
+ if (value instanceof Error) return { error: value.name };
+ if (depth >= AUDIT_MAX_DEPTH) return AUDIT_TRUNCATED;
+
+ if (Array.isArray(value)) {
+ const items = value
+ .slice(0, AUDIT_ARRAY_MAX_ITEMS)
+ .map(item => sanitizeAuditValue(item, depth + 1) ?? null);
+
+ return value.length > AUDIT_ARRAY_MAX_ITEMS
+ ? [...items, AUDIT_TRUNCATED]
+ : items;
+ }
+
+ if (isRichTextDocument(value)) return AUDIT_RICH_TEXT;
+ if (!isPlainObject(value)) return undefined;
+
+ const result: Record = {};
+ const entries = Object.entries(value);
+ for (const [key, entry] of entries.slice(0, AUDIT_OBJECT_MAX_KEYS)) {
+ if (isSensitiveAuditKey(key)) {
+ result[key] = AUDIT_REDACTED;
+ continue;
+ }
+ const sanitized = sanitizeAuditValue(entry, depth + 1);
+ if (sanitized !== undefined) result[key] = sanitized;
+ }
+ if (entries.length > AUDIT_OBJECT_MAX_KEYS) result[AUDIT_TRUNCATED] = true;
+
+ return result;
+};
+
+const byteLength = (value: unknown): number =>
+ new TextEncoder().encode(JSON.stringify(value)).byteLength;
+
+/**
+ * Sanitizes free-form metadata with {@link sanitizeAuditValue} and enforces
+ * the serialized size budget: metadata over it is replaced with
+ * `{ "[truncated]": true }` rather than half-stored.
+ */
+export const sanitizeAuditMetadata = (
+ metadata: null | Record | undefined,
+): Record => {
+ if (!metadata) return {};
+ const sanitized = sanitizeAuditValue(metadata);
+ if (!sanitized || typeof sanitized !== "object" || Array.isArray(sanitized)) {
+ return {};
+ }
+ if (byteLength(sanitized) > AUDIT_JSON_MAX_BYTES) {
+ return { [AUDIT_TRUNCATED]: true };
+ }
+
+ return sanitized as Record;
+};
+
+/**
+ * Sanitizes structured changes: a sensitive field keeps only the fact that it
+ * changed (`{ redacted: true }`), every value goes through
+ * {@link sanitizeAuditValue}, at most {@link AUDIT_CHANGES_MAX_FIELDS} fields
+ * are kept, and when the result is still over the size budget the values are
+ * dropped while the field names stay. `null` when nothing is left.
+ */
+export const sanitizeAuditChanges = (
+ changes: AuditChanges | null | undefined,
+): AuditChanges | null => {
+ if (!changes) return null;
+
+ const result: AuditChanges = {};
+ for (const [field, change] of Object.entries(changes).slice(
+ 0,
+ AUDIT_CHANGES_MAX_FIELDS,
+ )) {
+ if (isSensitiveAuditKey(field) || change.redacted) {
+ result[field] = { redacted: true };
+ continue;
+ }
+
+ const entry: AuditChange = {};
+ if ("from" in change) entry.from = sanitizeAuditValue(change.from) ?? null;
+ if ("to" in change) entry.to = sanitizeAuditValue(change.to) ?? null;
+ result[field] = entry;
+ }
+
+ if (Object.keys(result).length === 0) return null;
+ if (byteLength(result) <= AUDIT_JSON_MAX_BYTES) return result;
+
+ return Object.fromEntries(
+ Object.entries(result).map(([field, change]) => [
+ field,
+ change.redacted ? change : { from: AUDIT_TRUNCATED, to: AUDIT_TRUNCATED },
+ ]),
+ );
+};
diff --git a/packages/vitnode/src/locales/en.json b/packages/vitnode/src/locales/en.json
index 0faad31d27..c0849b5ab2 100644
--- a/packages/vitnode/src/locales/en.json
+++ b/packages/vitnode/src/locales/en.json
@@ -46,6 +46,11 @@
"@vitnode/core:cron": "Cron Jobs",
"@vitnode/core:cron:can_view": "View cron jobs",
"@vitnode/core:cron:can_run": "Run cron jobs",
+ "@vitnode/core:activity_logs": "Activity Logs",
+ "@vitnode/core:activity_logs:can_view": "View activity logs",
+ "@vitnode/core:activity_logs:can_view_sensitive": "View IP addresses and request ids",
+ "@vitnode/core:activity_logs:can_export": "Export activity logs",
+ "@vitnode/core:activity_logs:can_manage_retention": "Change how long activity logs are kept",
"@vitnode/core:report_reasons": "Report reasons",
"@vitnode/core:report_reasons:can_view": "View report reasons",
"@vitnode/core:report_reasons:can_create": "Create report reasons",
@@ -1695,7 +1700,8 @@
"delete": "Delete",
"duplicate": "Duplicate",
"hide": "Hide",
- "unhide": "Unhide"
+ "unhide": "Unhide",
+ "activity": "Activity"
},
"create": {
"title": "Create {name}",
@@ -2785,6 +2791,194 @@
}
}
}
+ },
+ "activity_logs": {
+ "columns": {
+ "date": "Date",
+ "actor": "Actor",
+ "area": "Area",
+ "plugin": "Plugin",
+ "action": "Action",
+ "target": "Target",
+ "details": "Details"
+ },
+ "areas": {
+ "admin": "AdminCP",
+ "moderator": "ModeratorCP",
+ "public": "Site",
+ "system": "System"
+ },
+ "actor_types": {
+ "staff": "Staff",
+ "api": "Member",
+ "system": "System"
+ },
+ "system_actor": "System",
+ "content_noun": "content",
+ "deleted_actor": "{name} (deleted account)",
+ "removed_plugin": "{plugin} (removed)",
+ "open_details": "Details of: {action}",
+ "filters": {
+ "label": "Filter activity",
+ "search": "Search",
+ "search_placeholder": "Search targets, actions and people",
+ "plugin": "Plugin",
+ "area": "Area",
+ "action": "Action",
+ "from": "From",
+ "to": "To",
+ "all": "All",
+ "actor": "Actor: {name}",
+ "clear_actor": "Show everyone",
+ "reset": "Clear filters"
+ },
+ "load_more": "Load older activity",
+ "loading": "Loading activity",
+ "empty_title": "No activity yet",
+ "empty_desc": "Nothing matches these filters. Try a wider date range, or clear the filters.",
+ "error_title": "Activity could not be loaded",
+ "error_desc": "Something went wrong reading the log. Refresh the page to try again.",
+ "detail": {
+ "title": "Activity details",
+ "loading": "Loading details",
+ "actor": "Actor",
+ "date": "Date",
+ "area": "Area",
+ "plugin": "Plugin",
+ "action": "Action",
+ "target": "Target",
+ "scope": "Scope",
+ "changes": "Changes",
+ "no_changes": "No field values are recorded for this operation.",
+ "before": "Before",
+ "after": "After",
+ "empty_value": "Empty",
+ "redacted": "Changed - the value is never recorded",
+ "revision": "Revision",
+ "revision_version": "Version {version}",
+ "revision_unavailable": "The revision this entry points at is no longer available - it was pruned, or you cannot view this content type.",
+ "metadata": "Details",
+ "technical": "Technical",
+ "ip_address": "IP address",
+ "correlation_id": "Request id",
+ "open_target": "Open",
+ "filter_actor": "Show this person's activity",
+ "not_found": "This entry does not exist, or you cannot see it."
+ },
+ "history": {
+ "title": "Activity",
+ "desc": "Everything recorded about this item, newest first.",
+ "empty": "Nothing has been recorded for this item yet.",
+ "load_more": "Load older activity"
+ },
+ "actions": {
+ "content": {
+ "created": "Created {type}",
+ "updated": "Updated {type}",
+ "deleted": "Deleted {type}",
+ "duplicated": "Duplicated {type}",
+ "published": "Published {type}",
+ "unpublished": "Unpublished {type}",
+ "hidden": "Hid {type}",
+ "unhidden": "Unhid {type}",
+ "restored": "Restored a revision of {type}",
+ "scheduled": "Scheduled {type}",
+ "schedule_cancelled": "Cancelled a schedule of {type}",
+ "translation_created": "Added a translation of {type}",
+ "translation_updated": "Updated a translation of {type}",
+ "translation_deleted": "Deleted a translation of {type}",
+ "translation_published": "Published a translation of {type}",
+ "translation_unpublished": "Unpublished a translation of {type}",
+ "translation_restored": "Restored a translation of {type}"
+ },
+ "role": {
+ "created": "Created role",
+ "updated": "Updated role",
+ "deleted": "Deleted role"
+ },
+ "admin_permissions": {
+ "created": "Added administrator permissions",
+ "updated": "Changed administrator permissions",
+ "deleted": "Removed administrator permissions"
+ },
+ "moderator_permissions": {
+ "created": "Added moderator permissions",
+ "updated": "Changed moderator permissions",
+ "deleted": "Removed moderator permissions"
+ },
+ "user": {
+ "updated": "Updated user",
+ "roles_changed": "Changed user roles",
+ "password_reset": "Reset password",
+ "email_verified": "Verified email address",
+ "device_revoked": "Signed out a device",
+ "devices_revoked": "Signed out all devices"
+ },
+ "settings": {
+ "updated": "Updated settings"
+ },
+ "audit_settings": {
+ "updated": "Changed log retention"
+ },
+ "audit_logs": {
+ "pruned": "Deleted expired log entries",
+ "exported": "Exported the activity log"
+ },
+ "ai_access": {
+ "role_updated": "Changed AI access of a role",
+ "user_override_saved": "Set an AI exception",
+ "user_override_removed": "Removed an AI exception"
+ },
+ "notifications": {
+ "type_updated": "Changed a notification type",
+ "paused": "Paused notifications",
+ "resumed": "Resumed notifications",
+ "emails_cancelled": "Cancelled queued notification emails",
+ "all_marked_read": "Marked every notification read",
+ "all_deleted": "Deleted every notification",
+ "member_preferences_reset": "Reset members' notification preferences"
+ },
+ "report_reason": {
+ "created": "Created report reason",
+ "updated": "Updated report reason",
+ "deleted": "Deleted report reason",
+ "reordered": "Reordered report reasons"
+ },
+ "report": {
+ "claimed": "Took report",
+ "assigned": "Assigned report",
+ "unassigned": "Unassigned report",
+ "note_added": "Added a note to a report",
+ "note_edited": "Edited a report note",
+ "note_deleted": "Deleted a report note",
+ "resolved": "Resolved report",
+ "reopened": "Reopened report",
+ "action_taken": "Acted on reported content"
+ },
+ "submission": {
+ "approved": "Approved a submission",
+ "rejected": "Rejected a submission"
+ },
+ "cron": {
+ "run": "Ran a cron job"
+ }
+ },
+ "fields": {
+ "status": "Status",
+ "assigneeId": "Assignee",
+ "resolution": "Resolution",
+ "name": "Name",
+ "color": "Color",
+ "prefix": "Prefix",
+ "roleId": "Primary role",
+ "secondaryRoleIds": "Secondary roles",
+ "unrestricted": "Unrestricted",
+ "retentionDays": "Retention (days)",
+ "email": "Email",
+ "password": "Password",
+ "requiresDetails": "Requires details",
+ "title": "Title"
+ }
}
},
"admin": {
@@ -3611,6 +3805,31 @@
"title": "No administrators found",
"description": "Assign a role or user to grant administrator permissions."
}
+ },
+ "logs": {
+ "open": "Logs",
+ "title": {
+ "admin": "Admin logs",
+ "moderator": "Moderator logs"
+ },
+ "desc": {
+ "admin": "Who changed what in the AdminCP, plus what plugins, members and scheduled jobs did - newest first.",
+ "moderator": "What moderators did: reports, approvals and content they hid or restored."
+ },
+ "export": "Export CSV",
+ "export_error": "The export could not be downloaded. Try again in a moment.",
+ "settings": {
+ "title": "Retention",
+ "open": "Retention settings",
+ "desc": "One period for both staff logs. Entries older than this are deleted by a daily job. Shorter keeps less personal data around; longer keeps a longer trail.",
+ "retention": "Keep entries for",
+ "forever": "Forever",
+ "days": "{count, plural, one {# day} other {# days}}",
+ "saved": "Retention saved",
+ "saved_desc": "Entries older than {period} will be deleted by the next daily run.",
+ "saved_forever": "Entries will be kept until you change this.",
+ "error": "The retention period could not be saved."
+ }
}
},
"moderation": {
diff --git a/packages/vitnode/src/pages/admin/staff/admins/logs.tsx b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx
new file mode 100644
index 0000000000..4d173e40da
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/staff/admins/logs.tsx
@@ -0,0 +1,39 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route";
+import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search";
+
+import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs";
+import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route";
+import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen";
+import {
+ defineAdminRoute,
+ routeBreadcrumbGroup,
+} from "@/tanstack/plugin-routes";
+
+const AdminsLogsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps) => (
+
+);
+
+export const route = defineAdminRoute<
+ AdminStaffLogsRouteData,
+ ActivityRouteSearch
+>({
+ // `head` after `load`, always.
+ load: async ({ context, search, t }) =>
+ await loadAdminStaffLogsRoute({ ...context, search, t, type: "admin" }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: routeBreadcrumbGroup(function AdminsLogsBreadcrumb() {
+ return ;
+ }),
+});
+
+export default AdminsLogsPage;
diff --git a/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx
new file mode 100644
index 0000000000..79f953bd5a
--- /dev/null
+++ b/packages/vitnode/src/pages/admin/staff/moderators/logs.tsx
@@ -0,0 +1,39 @@
+import type { PluginRoutePageProps } from "@/routing";
+import type { AdminStaffLogsRouteData } from "@/tanstack/admin/staff/logs-route";
+import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search";
+
+import { AdminStaffLogsBreadcrumbContent } from "@/tanstack/admin/staff/breadcrumbs";
+import { loadAdminStaffLogsRoute } from "@/tanstack/admin/staff/logs-route";
+import { AdminStaffLogsRouteContent } from "@/tanstack/admin/staff/logs-screen";
+import {
+ defineAdminRoute,
+ routeBreadcrumbGroup,
+} from "@/tanstack/plugin-routes";
+
+const ModeratorsLogsPage = ({
+ loaderData,
+ navigate,
+ search,
+}: PluginRoutePageProps) => (
+
+);
+
+export const route = defineAdminRoute<
+ AdminStaffLogsRouteData,
+ ActivityRouteSearch
+>({
+ // `head` after `load`, always.
+ load: async ({ context, search, t }) =>
+ await loadAdminStaffLogsRoute({ ...context, search, t, type: "moderator" }),
+ head: ({ loaderData }) => ({ ...loaderData }),
+
+ breadcrumb: routeBreadcrumbGroup(function ModeratorsLogsBreadcrumb() {
+ return ;
+ }),
+});
+
+export default ModeratorsLogsPage;
diff --git a/packages/vitnode/src/routes.tsx b/packages/vitnode/src/routes.tsx
index 6a74580d85..9c56c1f056 100644
--- a/packages/vitnode/src/routes.tsx
+++ b/packages/vitnode/src/routes.tsx
@@ -35,6 +35,7 @@ import { ADMIN_SIGN_IN_NAMESPACES } from "./tanstack/admin/sign-in-route";
import { normalizeAdminSignInSearch } from "./tanstack/admin/sign-in-search";
import { ADMIN_STAFF_CREATE_NAMESPACES } from "./tanstack/admin/staff/create-route";
import { ADMIN_STAFF_EDIT_NAMESPACES } from "./tanstack/admin/staff/edit-route";
+import { ADMIN_STAFF_LOGS_NAMESPACES } from "./tanstack/admin/staff/logs-route";
import { ADMIN_STAFF_NAMESPACES } from "./tanstack/admin/staff/route";
import { normalizeStaffRouteSearch } from "./tanstack/admin/staff/route-search";
import { ADMIN_USER_NAMESPACES } from "./tanstack/admin/users/detail-route";
@@ -84,6 +85,7 @@ import {
import { normalizeSearchRouteSearch } from "./tanstack/search/route-search";
import { SETTINGS_NAMESPACES } from "./tanstack/settings/route";
import { normalizeSsoSettingsSearch } from "./tanstack/sso-connections/route-search";
+import { normalizeActivitySearch } from "./views/activity-logs/activity-search";
/**
* Every URL `@vitnode/core` owns, as the same kind of declaration a plugin
@@ -414,6 +416,14 @@ export const routes = defineRoutes([
pendingComponent: FormPendingSkeleton,
}),
+ page("/admin/core/staff/admins/logs", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/staff/admins/logs")),
+ messages: ADMIN_STAFF_LOGS_NAMESPACES,
+ pendingComponent: TablePendingSkeleton,
+ search: normalizeActivitySearch,
+ }),
+
page("/admin/core/staff/moderators", {
area: "admin",
component: lazy(() => import("./pages/admin/staff/moderators/index")),
@@ -436,6 +446,14 @@ export const routes = defineRoutes([
pendingComponent: FormPendingSkeleton,
}),
+ page("/admin/core/staff/moderators/logs", {
+ area: "admin",
+ component: lazy(() => import("./pages/admin/staff/moderators/logs")),
+ messages: ADMIN_STAFF_LOGS_NAMESPACES,
+ pendingComponent: TablePendingSkeleton,
+ search: normalizeActivitySearch,
+ }),
+
page("/admin/content/*", {
area: "admin",
component: lazy(() => import("./pages/admin/content")),
diff --git a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx
index 21811bcd67..d6d6e98ed6 100644
--- a/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx
+++ b/packages/vitnode/src/tanstack/admin/content/editorial/panels.tsx
@@ -1,5 +1,6 @@
import React from "react";
+import { ActivityContentPanel } from "@/views/admin/views/content/actions/activity-action";
import { DeliveryContentPanel } from "@/views/admin/views/content/actions/delivery-action";
import { contentDeliveryRequestLocale } from "@/views/admin/views/content/actions/delivery-model";
import { HistoryContentPanel } from "@/views/admin/views/content/actions/history-action";
@@ -8,6 +9,7 @@ import { ScheduleContentPanel } from "@/views/admin/views/content/actions/schedu
import type { ContentRowPanelProps } from "../slots";
+import { RouteMessages } from "../../../i18n/route-messages";
import { useContentTypeForm } from "../form/spec";
import { ContentEditorialHost } from "./host";
@@ -112,7 +114,39 @@ const DeliveryPanel = ({
);
+/**
+ * The record's audit trail - for every content type, editorial or not. Its own
+ * messages, because the content screen does not load the Activity Logs ones.
+ */
+const ActivityPanel = ({
+ entry,
+ finalFocus,
+ itemId,
+ onOpenChange,
+ open,
+ singular,
+}: ContentRowPanelProps) => (
+
+
+
+);
+
export const contentEditorialRowPanels = {
+ activity: ActivityPanel,
delivery: DeliveryPanel,
history: HistoryPanel,
preview: PreviewPanel,
diff --git a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx
index 6b2c1c3b20..dbe637c591 100644
--- a/packages/vitnode/src/tanstack/admin/content/row-actions.tsx
+++ b/packages/vitnode/src/tanstack/admin/content/row-actions.tsx
@@ -1,6 +1,7 @@
import { useQueryClient } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import {
+ ActivityIcon,
CalendarClockIcon,
CopyPlusIcon,
EllipsisIcon,
@@ -298,6 +299,7 @@ const ACTION_ICONS: Record<
Exclude,
React.ReactNode
> = {
+ activity: ,
delete: ,
delivery: ,
duplicate: ,
diff --git a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx
index 5b61cf3f92..d7896bfa7c 100644
--- a/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx
+++ b/packages/vitnode/src/tanstack/admin/staff/breadcrumbs.tsx
@@ -64,6 +64,19 @@ const StaffEditCrumb = ({ type }: StaffBreadcrumbProps) => {
);
};
+const StaffLogsCrumb = ({ type }: StaffBreadcrumbProps) => {
+ const labels = useStaffLabels(type);
+ const t = useTranslations("admin.staff.logs");
+
+ return (
+
+ );
+};
+
export const AdminStaffBreadcrumbContent = (props: StaffBreadcrumbProps) => (
@@ -85,3 +98,11 @@ export const AdminStaffEditBreadcrumbContent = (
);
+
+export const AdminStaffLogsBreadcrumbContent = (
+ props: StaffBreadcrumbProps,
+) => (
+
+
+
+);
diff --git a/packages/vitnode/src/tanstack/admin/staff/index.ts b/packages/vitnode/src/tanstack/admin/staff/index.ts
index a27d890ccd..95bfb120f9 100644
--- a/packages/vitnode/src/tanstack/admin/staff/index.ts
+++ b/packages/vitnode/src/tanstack/admin/staff/index.ts
@@ -3,6 +3,7 @@ export {
AdminStaffBreadcrumbContent,
AdminStaffCreateBreadcrumbContent,
AdminStaffEditBreadcrumbContent,
+ AdminStaffLogsBreadcrumbContent,
} from "./breadcrumbs";
export type { AdminStaffCreateRouteData } from "./create-route";
@@ -23,6 +24,13 @@ export {
} from "./edit-route";
export type { AdminStaffEditRouteProps } from "./edit-screen";
export { AdminStaffEditRouteContent } from "./edit-screen";
+export type { AdminStaffLogsRouteData } from "./logs-route";
+export {
+ ADMIN_STAFF_LOGS_NAMESPACES,
+ loadAdminStaffLogsRoute,
+} from "./logs-route";
+export type { AdminStaffLogsRouteProps } from "./logs-screen";
+export { AdminStaffLogsRouteContent } from "./logs-screen";
export {
adminStaffCatalogQuery,
adminStaffEntryQuery,
@@ -59,6 +67,7 @@ export {
staffCreateHref,
staffEditHref,
staffListHref,
+ staffLogsHref,
staffPermissionsForSubmit,
staffTypeFromSegment,
} from "@/views/admin/views/core/staff/staff-model";
diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx
new file mode 100644
index 0000000000..6e33b84858
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/staff/logs-route.tsx
@@ -0,0 +1,71 @@
+import type { PermissionStaffType } from "@/api/lib/permission-staff";
+import type { PluginRouteTranslator } from "@/routing";
+import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search";
+
+import {
+ activityFilterOptionsQueryOptions,
+ activityListQueryOptions,
+} from "@/views/activity-logs/activity-query";
+import { activityFiltersOf } from "@/views/activity-logs/activity-search";
+
+import type { AdminScreenContext } from "../screen";
+
+import { requireAdminPermission } from "../screen";
+
+/** `/admin/core/staff/{admins,moderators}/logs`, as everything a route needs and nothing it owns. */
+
+export const ADMIN_STAFF_LOGS_NAMESPACES = [
+ "admin.staff",
+ "core.activity_logs",
+ "core.global",
+] as const;
+
+export interface AdminStaffLogsRouteData {
+ description: string;
+ title: string;
+ type: PermissionStaffType;
+}
+
+export const ACTIVITY_LOGS_MODULE = "activity_logs";
+
+export const loadAdminStaffLogsRoute = async ({
+ adminAccess,
+ queryClient,
+ search,
+ t,
+ type,
+}: AdminScreenContext & {
+ search: ActivityRouteSearch;
+ t: PluginRouteTranslator;
+ type: PermissionStaffType;
+}): Promise => {
+ requireAdminPermission(adminAccess, {
+ module: ACTIVITY_LOGS_MODULE,
+ permission: "can_view",
+ });
+
+ // The filter options decide which plugin messages the screen loads, so they
+ // are awaited; the first page warms alongside and the screen keeps its
+ // skeleton local if it is not there yet.
+ await Promise.all([
+ queryClient.query({
+ ...activityFilterOptionsQueryOptions(),
+ staleTime: "static",
+ }),
+ queryClient
+ .infiniteQuery({
+ ...activityListQueryOptions({
+ ...activityFiltersOf(search),
+ staff: type,
+ }),
+ staleTime: "static",
+ })
+ .catch(() => undefined),
+ ]);
+
+ return {
+ description: t(`admin.staff.logs.desc.${type}`),
+ title: t(`admin.staff.logs.title.${type}`),
+ type,
+ };
+};
diff --git a/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx
new file mode 100644
index 0000000000..6cc93ae537
--- /dev/null
+++ b/packages/vitnode/src/tanstack/admin/staff/logs-screen.tsx
@@ -0,0 +1,174 @@
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { DownloadIcon, Settings2Icon } from "lucide-react";
+import React from "react";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+
+import type { PermissionStaffType } from "@/api/lib/permission-staff";
+import type { ActivityRouteSearch } from "@/views/activity-logs/activity-search";
+
+import { Button } from "@/components/ui/button";
+import { PageTitle } from "@/components/ui/page-title";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+ SheetTrigger,
+} from "@/components/ui/sheet";
+import { Spinner } from "@/components/ui/spinner";
+import { manifest } from "@/manifest";
+import { activityPluginNamespaces } from "@/views/activity-logs/activity-labels";
+import {
+ activityFilterOptionsQueryOptions,
+ downloadActivityExport,
+} from "@/views/activity-logs/activity-query";
+import { ActivityScreen } from "@/views/activity-logs/activity-screen";
+import {
+ activityFiltersOf,
+ activitySearchOf,
+} from "@/views/activity-logs/activity-search";
+
+import type { AdminTableNavigate } from "../table-search";
+import type { AdminStaffLogsRouteData } from "./logs-route";
+
+import { RouteMessages } from "../../i18n/route-messages";
+import { useAdminPermission } from "../permissions";
+import {
+ ACTIVITY_LOGS_MODULE,
+ ADMIN_STAFF_LOGS_NAMESPACES,
+} from "./logs-route";
+
+const ActivityRetentionForm = React.lazy(
+ async () => await import("@/views/activity-logs/activity-retention-form"),
+);
+
+const permission = (name: string) => ({
+ module: ACTIVITY_LOGS_MODULE,
+ permission: name,
+ plugin: manifest.id,
+});
+
+const ExportButton = ({
+ search,
+ type,
+}: {
+ search: ActivityRouteSearch;
+ type: PermissionStaffType;
+}) => {
+ const t = useTranslations("admin.staff.logs");
+ const tError = useTranslations("core.global.errors");
+ const [isExporting, setIsExporting] = React.useState(false);
+
+ return (
+ {
+ setIsExporting(true);
+ try {
+ await downloadActivityExport({
+ ...activityFiltersOf(search),
+ staff: type,
+ });
+ } catch {
+ toast.error(tError("title"), { description: t("export_error") });
+ } finally {
+ setIsExporting(false);
+ }
+ }}
+ variant="outline"
+ >
+
+ {t("export")}
+
+ );
+};
+
+const RetentionSheet = ({ canManage }: { canManage: boolean }) => {
+ const t = useTranslations("admin.staff.logs.settings");
+ const [isOpen, setIsOpen] = React.useState(false);
+
+ return (
+
+ }>
+
+ {t("open")}
+
+
+
+ {t("title")}
+
+ {t("desc")}
+
+
+
+
+
+ }
+ >
+ {
+ setIsOpen(false);
+ }}
+ />
+
+
+
+ );
+};
+
+export interface AdminStaffLogsRouteProps extends AdminStaffLogsRouteData {
+ navigate: AdminTableNavigate;
+ search: ActivityRouteSearch;
+}
+
+/** AdminCP -> Staff -> Administrators / Moderators -> Logs. */
+export const AdminStaffLogsRouteContent = ({
+ description,
+ navigate,
+ search,
+ title,
+ type,
+}: AdminStaffLogsRouteProps) => {
+ const { data: options } = useSuspenseQuery(
+ activityFilterOptionsQueryOptions(),
+ );
+ const canExport = useAdminPermission(permission("can_export"));
+ const canManageRetention = useAdminPermission(
+ permission("can_manage_retention"),
+ );
+
+ return (
+
+
+
+
+ {canExport ? : null}
+
+ >
+ }
+ entryId={search.log ?? null}
+ filters={activityFiltersOf(search)}
+ onNavigate={next => {
+ void navigate({
+ resetScroll: false,
+ search: activitySearchOf(next),
+ });
+ }}
+ staff={type}
+ />
+
+
+ );
+};
diff --git a/packages/vitnode/src/tanstack/admin/staff/screen.tsx b/packages/vitnode/src/tanstack/admin/staff/screen.tsx
index cc0497675a..476803c672 100644
--- a/packages/vitnode/src/tanstack/admin/staff/screen.tsx
+++ b/packages/vitnode/src/tanstack/admin/staff/screen.tsx
@@ -1,7 +1,8 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
-import { PlusIcon } from "lucide-react";
+import { HistoryIcon, PlusIcon } from "lucide-react";
import React from "react";
+import { useTranslations } from "use-intl";
import type { DataTableNavigation } from "@/components/table/navigation";
@@ -9,8 +10,12 @@ import { AdminStaffPermissionGate } from "@/components/staff-permission/provider
import { DataTableNavigationProvider } from "@/components/table/navigation";
import { buttonVariants } from "@/components/ui/button";
import { PageTitle } from "@/components/ui/page-title";
+import { manifest } from "@/manifest";
import { adminStaffPermissions } from "@/views/admin/views/core/shared/admin-permissions";
-import { staffCreateHref } from "@/views/admin/views/core/staff/staff-model";
+import {
+ staffCreateHref,
+ staffLogsHref,
+} from "@/views/admin/views/core/staff/staff-model";
import { StaffTableContent } from "@/views/admin/views/core/staff/table/staff-table-content";
import type { AdminTableNavigate } from "../table-search";
@@ -18,6 +23,7 @@ import type { AdminStaffRouteData } from "./route";
import type { StaffRouteSearch, UncheckedStaffSearch } from "./route-search";
import { RouteMessages } from "../../i18n/route-messages";
+import { ACTIVITY_LOGS_MODULE } from "./logs-route";
import { adminStaffQuery, useStaffDeleteCallback } from "./query";
import { ADMIN_STAFF_NAMESPACES } from "./route";
import { staffSearchFrom, staffSearchParams } from "./route-search";
@@ -27,6 +33,26 @@ export interface AdminStaffRouteProps extends AdminStaffRouteData {
search: UncheckedStaffSearch;
}
+const LogsLink = ({ type }: { type: AdminStaffRouteData["type"] }) => {
+ const t = useTranslations("admin.staff.logs");
+
+ return (
+
+
+
+ {t("open")}
+
+
+ );
+};
+
export const AdminStaffRouteContent = ({
adminUserId,
createLabel,
@@ -59,6 +85,7 @@ export const AdminStaffRouteContent = ({
+
diff --git a/packages/vitnode/src/tests/audit-recorder.ts b/packages/vitnode/src/tests/audit-recorder.ts
new file mode 100644
index 0000000000..f26f756a83
--- /dev/null
+++ b/packages/vitnode/src/tests/audit-recorder.ts
@@ -0,0 +1,31 @@
+import type { Table } from "drizzle-orm";
+
+import { getTableName } from "drizzle-orm";
+
+/**
+ * For statement-recording database mocks: answers inserts into
+ * `core_audit_logs` on its own, so the audit entry every audited write adds
+ * neither consumes the scripted results nor shows up among the recorded
+ * statements - and can be asserted on separately through `entries`.
+ */
+export const createAuditRecorder = () => {
+ const entries: Record[] = [];
+
+ return {
+ entries,
+ /** The audit insert builder, or `null` for any other table. */
+ insert: (table: unknown) => {
+ if (getTableName(table as Table) !== "core_audit_logs") return null;
+
+ return {
+ values: (value: Record) => {
+ entries.push(value);
+
+ return {
+ returning: async () => Promise.resolve([{ id: entries.length }]),
+ };
+ },
+ };
+ },
+ };
+};
diff --git a/packages/vitnode/src/tests/revisions-table.ts b/packages/vitnode/src/tests/revisions-table.ts
index 6d18e08db2..336e0b40a6 100644
--- a/packages/vitnode/src/tests/revisions-table.ts
+++ b/packages/vitnode/src/tests/revisions-table.ts
@@ -4,6 +4,8 @@ import { PgDialect } from "drizzle-orm/pg-core";
import type { ContentTranslationRevisionSnapshot } from "@/content/revisions";
+import { createAuditRecorder } from "./audit-recorder";
+
const dialect = new PgDialect();
const boundValue = (condition: SQL | undefined, column: string): unknown => {
@@ -76,18 +78,21 @@ export const createTestRevisionsTable = () => {
return query;
};
+ const audit = createAuditRecorder();
+
const db = {
- insert: () => ({
- values: (values: RevisionWrite) => ({
- returning: async () => {
- nextId += 1;
- written.push(values);
- rows.push({ ...values, id: nextId });
-
- return await Promise.resolve([{ id: nextId }]);
- },
- }),
- }),
+ insert: (table: unknown) =>
+ audit.insert(table) ?? {
+ values: (values: RevisionWrite) => ({
+ returning: async () => {
+ nextId += 1;
+ written.push(values);
+ rows.push({ ...values, id: nextId });
+
+ return await Promise.resolve([{ id: nextId }]);
+ },
+ }),
+ },
select,
transaction: async (body: (tx: unknown) => Promise) => await body(db),
};
@@ -110,5 +115,5 @@ export const createTestRevisionsTable = () => {
});
};
- return { db, seed, written };
+ return { audits: audit.entries, db, seed, written };
};
diff --git a/packages/vitnode/src/views/activity-logs/activity-changes.tsx b/packages/vitnode/src/views/activity-logs/activity-changes.tsx
new file mode 100644
index 0000000000..3a93f6d1e6
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-changes.tsx
@@ -0,0 +1,106 @@
+import { LockIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import type { ActivityDetail } from "./activity-query";
+
+const formatValue = (value: unknown): string => {
+ if (typeof value === "string") return value;
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+
+ return JSON.stringify(value);
+};
+
+const isEmpty = (value: unknown): boolean =>
+ value === null ||
+ value === undefined ||
+ value === "" ||
+ (Array.isArray(value) && value.length === 0);
+
+const ChangeValue = ({ value }: { value: unknown }) => {
+ const t = useTranslations("core.activity_logs.detail");
+
+ if (isEmpty(value)) {
+ return (
+ {t("empty_value")}
+ );
+ }
+
+ return (
+
+ {formatValue(value)}
+
+ );
+};
+
+export interface ActivityChangeRow {
+ after?: unknown;
+ before?: unknown;
+ label: string;
+ name: string;
+ redacted?: boolean;
+}
+
+/** Recorded changes as rows of field, before and after, rather than raw JSON. */
+export const activityChangeRows = (
+ changes: ActivityDetail["changes"],
+ label: (name: string) => string,
+): ActivityChangeRow[] =>
+ Object.entries(changes ?? {}).map(([name, change]) => ({
+ after: change.to,
+ before: change.from,
+ label: label(name),
+ name,
+ redacted: change.redacted === true,
+ }));
+
+/**
+ * Before/after pairs, one field per row. Stacked on a phone, side by side
+ * from `sm` up. A redacted field says that it changed and nothing more.
+ */
+export const ActivityChanges = ({ rows }: { rows: ActivityChangeRow[] }) => {
+ const t = useTranslations("core.activity_logs.detail");
+
+ if (rows.length === 0) {
+ return (
+
+ {t("no_changes")}
+
+ );
+ }
+
+ return (
+
+ {rows.map(row => (
+
+
{row.label}
+ {row.redacted ? (
+
+
+ {t("redacted")}
+
+ ) : (
+
+
+
+ {t("before")}
+
+
+
+
+
+ {t("after")}
+
+
+
+
+ )}
+
+ ))}
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx b/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx
new file mode 100644
index 0000000000..7fc6e4b52c
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-detail-pending.tsx
@@ -0,0 +1,25 @@
+import { useTranslations } from "use-intl";
+
+import { Skeleton } from "@/components/ui/skeleton";
+
+/** Shaped like the facts and the first change it stands in for. */
+export const ActivityDetailPending = () => {
+ const t = useTranslations("core.activity_logs.detail");
+
+ return (
+
+
+ {t("loading")}
+
+
+ {["actor", "date", "area", "plugin"].map(key => (
+
+
+
+
+ ))}
+
+
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-detail.tsx b/packages/vitnode/src/views/activity-logs/activity-detail.tsx
new file mode 100644
index 0000000000..5db84a678a
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-detail.tsx
@@ -0,0 +1,221 @@
+import { useQuery } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import { ExternalLinkIcon, FilterIcon } from "lucide-react";
+import { useTranslations } from "use-intl";
+
+import { DateFormat } from "@/components/date-format";
+import { Badge } from "@/components/ui/badge";
+import { Button, buttonVariants } from "@/components/ui/button";
+
+import type { ActivityFilterOptions } from "./activity-query";
+
+import { activityChangeRows, ActivityChanges } from "./activity-changes";
+import { ActivityDetailPending } from "./activity-detail-pending";
+import {
+ ActivityActor,
+ ActivityAreaBadge,
+ useActivityLabels,
+} from "./activity-labels";
+import { activityDetailQueryOptions } from "./activity-query";
+
+const Section = ({
+ children,
+ title,
+}: {
+ children: React.ReactNode;
+ title: string;
+}) => (
+
+);
+
+const Fact = ({
+ children,
+ label,
+}: {
+ children: React.ReactNode;
+ label: string;
+}) => (
+
+
+ {label}
+
+ {children}
+
+);
+
+const metadataValue = (value: unknown): string =>
+ typeof value === "string" ? value : JSON.stringify(value);
+
+/**
+ * The body of the detail sheet: who, when, where and what changed, with the
+ * content revision the entry points at when there is one.
+ */
+export const ActivityDetailContent = ({
+ id,
+ onFilterActor,
+ plugins,
+}: {
+ id: number;
+ /** Narrows the list to this entry's actor; omitted where there is no list. */
+ onFilterActor?: (actor: { id: number; name: string }) => void;
+ plugins: ActivityFilterOptions["plugins"];
+}) => {
+ const t = useTranslations("core.activity_logs.detail");
+ const labels = useActivityLabels(plugins);
+ const { data: entry, isError } = useQuery(activityDetailQueryOptions(id));
+
+ if (isError) {
+ return (
+
+ {t("not_found")}
+
+ );
+ }
+ if (!entry) return ;
+
+ const changes = activityChangeRows(entry.changes, name =>
+ labels.field(entry, name),
+ );
+ const revisionRows = (entry.revision?.fields ?? []).map(field => ({
+ after: field.after,
+ before: field.before,
+ label: labels.field(entry, field.name),
+ name: field.name,
+ }));
+ const { changedFields, locale, ...metadata } = entry.metadata as Record<
+ string,
+ unknown
+ > & {
+ changedFields?: unknown;
+ locale?: unknown;
+ };
+ const changedNames = Array.isArray(changedFields)
+ ? changedFields.filter((name): name is string => typeof name === "string")
+ : [];
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {labels.plugin(entry.pluginId)}
+
+
+ {labels.target(entry)}
+ {typeof locale === "string" ? (
+
+ {locale}
+
+ ) : null}
+
+
+ {entry.scope ? (
+
+
+ {entry.scope.family}:{entry.scope.id}
+
+
+ ) : null}
+
+
+
+ {entry.targetHref ? (
+
+
+ {t("open_target")}
+
+ ) : null}
+ {onFilterActor && entry.actor ? (
+ {
+ if (entry.actor) onFilterActor(entry.actor);
+ }}
+ size="sm"
+ variant="ghost"
+ >
+
+ {t("filter_actor")}
+
+ ) : null}
+
+
+
+ {changedNames.length > 0 &&
+ changes.length === 0 &&
+ revisionRows.length === 0 ? (
+
+ {changedNames.map(name => (
+
+ {labels.field(entry, name)}
+
+ ))}
+
+ ) : (
+
+ )}
+
+
+ {entry.revision ? (
+
+ {entry.revision.available && entry.revision.version !== null ? (
+
+ {t("revision_version", { version: entry.revision.version })}
+
+ ) : (
+
+ {t("revision_unavailable")}
+
+ )}
+
+ ) : null}
+
+ {Object.keys(metadata).length > 0 ? (
+
+
+ {Object.entries(metadata).map(([key, value]) => (
+
+
+ {metadataValue(value)}
+
+
+ ))}
+
+
+ ) : null}
+
+ {entry.ipAddress || entry.correlationId ? (
+
+
+ {entry.ipAddress ? (
+
+ {entry.ipAddress}
+
+ ) : null}
+ {entry.correlationId ? (
+
+
+ {entry.correlationId}
+
+
+ ) : null}
+
+
+ ) : null}
+
+ );
+};
+
+export default ActivityDetailContent;
diff --git a/packages/vitnode/src/views/activity-logs/activity-filters.tsx b/packages/vitnode/src/views/activity-logs/activity-filters.tsx
new file mode 100644
index 0000000000..6d01a91366
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-filters.tsx
@@ -0,0 +1,273 @@
+import { FilterXIcon, SearchIcon, XIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type { AuditStaffLog } from "@/lib/audit/const";
+
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ NativeSelect,
+ NativeSelectOption,
+} from "@/components/ui/native-select";
+import { AUDIT_AREAS, AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const";
+
+import type { ActivityFilterOptions, ActivityFilters } from "./activity-query";
+
+import { useActivityLabels } from "./activity-labels";
+
+/**
+ * Core's report and approval actions are only ever recorded in the moderator
+ * area, and core's other actions never are; plugin actions can be either.
+ */
+const isModerationAction = (action: string) =>
+ action.startsWith("report.") || action.startsWith("submission.");
+
+/** The moderator area has a log of its own. */
+const ADMIN_LOG_AREAS = AUDIT_AREAS.filter(area => area !== "moderator");
+
+const FilterSelect = ({
+ id,
+ label,
+ onChange,
+ options,
+ value,
+}: {
+ id: string;
+ label: string;
+ onChange: (value: string | undefined) => void;
+ options: { label: string; value: string }[];
+ value: string | undefined;
+}) => {
+ const t = useTranslations("core.activity_logs.filters");
+
+ return (
+
+ {label}
+ {
+ onChange(event.target.value || undefined);
+ }}
+ size="sm"
+ value={value ?? ""}
+ >
+ {t("all")}
+ {options.map(option => (
+
+ {option.label}
+
+ ))}
+
+
+ );
+};
+
+/**
+ * The Activity Logs filters. Every change goes straight to `onChange`, which
+ * the screen writes into the URL - so a filtered view can be bookmarked and
+ * shared, and the back button undoes a filter.
+ */
+export const ActivityFiltersBar = ({
+ actorName,
+ filters,
+ onChange,
+ options,
+ staff,
+}: {
+ /** The name of the actor `filters.actorId` narrows to, for its chip. */
+ actorName?: null | string;
+ filters: ActivityFilters;
+ onChange: (filters: ActivityFilters) => void;
+ options: ActivityFilterOptions;
+ /** The staff log being filtered - it decides which areas and actions apply. */
+ staff: AuditStaffLog;
+}) => {
+ const t = useTranslations("core.activity_logs.filters");
+ const tAreas = useTranslations("core.activity_logs.areas");
+ const labels = useActivityLabels(options.plugins);
+ // The draft belongs to the search it was typed against: once the URL's
+ // search changes (a reset, the back button), the field shows the URL again.
+ const [draft, setDraft] = React.useState({
+ for: filters.search,
+ value: filters.search ?? "",
+ });
+ const search =
+ draft.for === filters.search ? draft.value : (filters.search ?? "");
+ const setSearch = (value: string) => {
+ setDraft({ for: filters.search, value });
+ };
+
+ const set =
+ (key: TKey) =>
+ (value: ActivityFilters[TKey] | undefined) => {
+ onChange({ ...filters, [key]: value });
+ };
+
+ const actions = React.useMemo(() => {
+ const visible = options.actions.filter(
+ entry =>
+ (!filters.pluginId || entry.pluginId === filters.pluginId) &&
+ (staff === "moderator"
+ ? entry.pluginId !== AUDIT_CORE_PLUGIN_ID ||
+ isModerationAction(entry.action)
+ : !isModerationAction(entry.action)),
+ );
+ const unique = new Map();
+ for (const entry of visible) {
+ if (!unique.has(entry.action)) {
+ unique.set(
+ entry.action,
+ labels.action({
+ action: entry.action,
+ contentTypeId: null,
+ pluginId: entry.pluginId,
+ }),
+ );
+ }
+ }
+
+ return [...unique]
+ .map(([value, label]) => ({ label, value }))
+ .sort((a, b) => a.label.localeCompare(b.label));
+ }, [filters.pluginId, labels, options.actions, staff]);
+
+ const hasFilters = Object.values(filters).some(
+ value => value !== undefined && value !== "",
+ );
+
+ return (
+
+
+
+
+
+ {filters.actorId ? (
+
+
+ {t("actor", { name: actorName ?? `#${filters.actorId}` })}
+ {
+ set("actorId")(undefined);
+ }}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+
+ ) : null}
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-history.tsx b/packages/vitnode/src/views/activity-logs/activity-history.tsx
new file mode 100644
index 0000000000..7ffeefc158
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-history.tsx
@@ -0,0 +1,181 @@
+import { useInfiniteQuery } from "@tanstack/react-query";
+import { HistoryIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import { DateFormat } from "@/components/date-format";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+
+import { activityChangeRows, ActivityChanges } from "./activity-changes";
+import {
+ ActivityActor,
+ ActivityAreaBadge,
+ useActivityLabels,
+} from "./activity-labels";
+import { activityEntityQueryOptions } from "./activity-query";
+
+export interface ActivityHistoryProps {
+ /** The plugin the resource belongs to - for a content record, its content type's plugin. */
+ pluginId: string;
+ /** The resource's id, as recorded (`String(id)`). */
+ targetId: number | string;
+ /** The recorded target type - a content type id such as `blog.category` for content. */
+ targetType: string;
+}
+
+const HistoryPending = () => {
+ const t = useTranslations("core.activity_logs");
+
+ return (
+
+
+ {t("loading")}
+
+ {Array.from({ length: 4 }, (_, index) => (
+
+ ))}
+
+ );
+};
+
+/**
+ * The activity history of one resource, newest first - who created, changed,
+ * published or moderated it, and what moved. Reads the central Activity Logs
+ * through `GET /admin/activity-logs/entity`, which an admin may call with the
+ * Activity Logs permission or with `can_view` on the content type itself.
+ * AdminCP only.
+ *
+ * @example
+ * ```tsx
+ * import { ActivityHistory } from "@vitnode/core/views/activity-logs/activity-history";
+ *
+ * ;
+ * ```
+ */
+export const ActivityHistory = ({
+ pluginId,
+ targetId,
+ targetType,
+}: ActivityHistoryProps) => {
+ const t = useTranslations("core.activity_logs");
+ const labels = useActivityLabels();
+ const query = useInfiniteQuery(
+ activityEntityQueryOptions({
+ pluginId,
+ targetId: String(targetId),
+ targetType,
+ }),
+ );
+ const entries = React.useMemo(
+ () => query.data?.pages.flatMap(page => page.edges) ?? [],
+ [query.data],
+ );
+
+ if (query.isPending) return ;
+ if (query.isError) {
+ return (
+
+ {t("error_desc")}
+
+ );
+ }
+ if (entries.length === 0) {
+ return (
+
+
+
+
+
+ {t("history.empty")}
+
+
+ );
+ }
+
+ return (
+
+
+ {entries.map(entry => {
+ const rows = activityChangeRows(entry.changes, name =>
+ labels.field(entry, name),
+ );
+ const changedFields = Array.isArray(entry.metadata.changedFields)
+ ? entry.metadata.changedFields.filter(
+ (name): name is string => typeof name === "string",
+ )
+ : [];
+ const locale =
+ typeof entry.metadata.locale === "string"
+ ? entry.metadata.locale
+ : null;
+
+ return (
+
+
+
+
+
+
+
+ {labels.action(entry)}
+
+ {locale ?
{locale} : null}
+
+
+
+ {rows.length > 0 ? (
+
+
+ {t("detail.changes")}
+
+
+
+ ) : null}
+ {rows.length === 0 && changedFields.length > 0 ? (
+
+ {changedFields.map(name => (
+
+
+ {labels.field(entry, name)}
+
+
+ ))}
+
+ ) : null}
+
+
+ );
+ })}
+
+ {query.hasNextPage ? (
+
{
+ void query.fetchNextPage();
+ }}
+ variant="outline"
+ >
+ {t("history.load_more")}
+
+ ) : null}
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-labels.tsx b/packages/vitnode/src/views/activity-logs/activity-labels.tsx
new file mode 100644
index 0000000000..6394591f9b
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-labels.tsx
@@ -0,0 +1,221 @@
+import { Link } from "@tanstack/react-router";
+import { cn } from "cn";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type { AuditArea } from "@/lib/audit/const";
+
+import { Badge } from "@/components/ui/badge";
+import { UserFormat } from "@/components/user-format";
+import {
+ contentEntityKey,
+ contentTypeName,
+ humanizeFieldName,
+} from "@/content/admin/labels";
+import { AUDIT_CORE_PLUGIN_ID } from "@/lib/audit/const";
+
+import type { ActivityEntry, ActivityFilterOptions } from "./activity-query";
+
+/** The namespaces every Activity Logs screen reads. */
+export const ACTIVITY_NAMESPACES = [
+ "core.activity_logs",
+ "core.global",
+] as const;
+
+/**
+ * The plugin message subtrees an entry's label may come from: a plugin's own
+ * `audit` actions, and the `content` nouns and field names of its content
+ * types. Loaded with the screen, so a label never renders as a key.
+ */
+export const activityPluginNamespaces = (
+ plugins: readonly { id: string }[],
+): string[] =>
+ plugins
+ .filter(plugin => plugin.id !== AUDIT_CORE_PLUGIN_ID)
+ .flatMap(plugin => [`${plugin.id}.audit`, `${plugin.id}.content`]);
+
+/** A translator over any key, for keys only known at runtime. */
+interface LooseTranslator {
+ (key: string, values?: Record): string;
+ has: (key: string) => boolean;
+}
+
+/** `topic.moved` -> "Topic moved": what an action reads as with no translation. */
+export const humanizeAuditAction = (action: string): string =>
+ humanizeFieldName(action.replaceAll(".", " ").replaceAll("_", " "));
+
+/**
+ * Turns stored identifiers into words, in the reader's language - and never
+ * runs a plugin's code to do it, so an entry of a removed plugin still reads:
+ *
+ * - Content Engine actions use core's sentence with the content type's noun
+ * from the plugin's messages (`Updated Category`).
+ * - Core actions use core's own messages.
+ * - A plugin's action uses `{pluginId}.audit.actions.{action}`, when the plugin
+ * ships one.
+ * - Anything else is the action id made readable.
+ */
+export const useActivityLabels = (
+ plugins: ActivityFilterOptions["plugins"] = [],
+) => {
+ const t = useTranslations("core.activity_logs");
+ const tAll = useTranslations() as unknown as LooseTranslator;
+ const titles = React.useMemo(
+ () => new Map(plugins.map(plugin => [plugin.id, plugin.title])),
+ [plugins],
+ );
+
+ return React.useMemo(() => {
+ const has = (key: string) => {
+ try {
+ return tAll.has(key);
+ } catch {
+ return false;
+ }
+ };
+
+ const contentNoun = (pluginId: string, contentTypeId: null | string) => {
+ // A filter option names the action, not a record of one type.
+ if (!contentTypeId) return t("content_noun");
+ const key = `${pluginId}.content.${contentEntityKey(contentTypeId)}.label`;
+
+ return has(key)
+ ? tAll(key, { count: 1 })
+ : contentTypeName(contentTypeId);
+ };
+
+ const action = (
+ entry: Pick,
+ ): string => {
+ const coreKey = `core.activity_logs.actions.${entry.action}`;
+ if (entry.action.startsWith("content.") && has(coreKey)) {
+ return tAll(coreKey, {
+ type: contentNoun(entry.pluginId, entry.contentTypeId),
+ });
+ }
+ if (entry.pluginId === AUDIT_CORE_PLUGIN_ID && has(coreKey)) {
+ return tAll(coreKey);
+ }
+ const pluginKey = `${entry.pluginId}.audit.actions.${entry.action}`;
+ if (has(pluginKey)) return tAll(pluginKey);
+
+ return humanizeAuditAction(entry.action);
+ };
+
+ const field = (
+ entry: Pick,
+ name: string,
+ ): string => {
+ if (entry.contentTypeId) {
+ const key = `${entry.pluginId}.content.${contentEntityKey(entry.contentTypeId)}.fields.${name}`;
+ if (has(key)) return tAll(key);
+ }
+ const coreKey = `core.activity_logs.fields.${name}`;
+ if (has(coreKey)) return tAll(coreKey);
+
+ return humanizeFieldName(name);
+ };
+
+ const plugin = (pluginId: string): string => {
+ const title = titles.get(pluginId);
+ if (title) return title;
+ if (pluginId === AUDIT_CORE_PLUGIN_ID) return "VitNode";
+
+ return t("removed_plugin", { plugin: pluginId });
+ };
+
+ const area = (value: AuditArea): string => t(`areas.${value}`);
+
+ const target = (
+ entry: Pick,
+ ): string => {
+ if (entry.target.label) return entry.target.label;
+ const noun =
+ entry.contentTypeId && entry.target.type === entry.contentTypeId
+ ? contentNoun(entry.pluginId, entry.contentTypeId)
+ : humanizeFieldName(entry.target.type);
+
+ return entry.target.id ? `${noun} #${entry.target.id}` : noun;
+ };
+
+ return { action, area, field, plugin, target };
+ }, [t, tAll, titles]);
+};
+
+const AREA_VARIANTS = {
+ admin: "default",
+ moderator: "warning",
+ public: "secondary",
+ system: "outline",
+} as const satisfies Record;
+
+export const ActivityAreaBadge = ({ area }: { area: AuditArea }) => {
+ const t = useTranslations("core.activity_logs.areas");
+
+ return (
+
+ {t(area)}
+
+ );
+};
+
+/**
+ * The actor of an entry: the account as it is today, linked to its AdminCP
+ * page for an admin; the name the entry was written under once the account is
+ * gone; "System" for background work.
+ */
+export const ActivityActor = ({
+ className,
+ entry,
+ linkToAdmin = false,
+}: {
+ className?: string;
+ entry: Pick;
+ linkToAdmin?: boolean;
+}) => {
+ const t = useTranslations("core.activity_logs");
+
+ if (entry.actorType === "system") {
+ return (
+
+ {t("system_actor")}
+
+ );
+ }
+
+ if (!entry.actor) {
+ return (
+
+ {entry.actorName
+ ? t("deleted_actor", { name: entry.actorName })
+ : t("system_actor")}
+
+ );
+ }
+
+ const name = (
+
+ );
+
+ if (!linkToAdmin) {
+ return {name} ;
+ }
+
+ return (
+
+ {name}
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-query.ts b/packages/vitnode/src/views/activity-logs/activity-query.ts
new file mode 100644
index 0000000000..5229414f4e
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-query.ts
@@ -0,0 +1,242 @@
+import {
+ infiniteQueryOptions,
+ keepPreviousData,
+ queryOptions,
+} from "@tanstack/react-query";
+
+import type { AuditArea, AuditStaffLog } from "@/lib/audit/const";
+
+import { fetcherClient } from "@/lib/fetcher-client";
+import { OPERATIONAL_STALE_TIME } from "@/lib/query-freshness";
+import { manifest } from "@/manifest";
+import { fetcher } from "@/tanstack/fetcher";
+
+/** The filters an Activity Logs list is read with, as they sit in the URL. */
+export interface ActivityFilters {
+ action?: string;
+ actorId?: number;
+ area?: AuditArea;
+ from?: string;
+ pluginId?: string;
+ search?: string;
+ targetId?: string;
+ targetType?: string;
+ to?: string;
+}
+
+export class ActivityRequestError extends Error {
+ constructor(status: number, what: string) {
+ super(`Reading ${what} answered ${status}.`);
+ this.name = "ActivityRequestError";
+ this.status = status;
+ }
+
+ readonly status: number;
+}
+
+const ACTIVITY_ROOT = ["vitnode", "activity-logs"] as const;
+
+/** Every cached Activity Logs read - invalidate it after a write. */
+export const activityQueryRoot = () => ACTIVITY_ROOT;
+
+/** The filters of one staff log, as the API reads them. */
+export type ActivityStaffFilters = ActivityFilters & { staff: AuditStaffLog };
+
+const fetchPage = async (filters: ActivityStaffFilters, cursor?: string) => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ args: { query: { ...filters, ...(cursor ? { cursor } : {}) } },
+ method: "get",
+ module: "admin/activity-logs",
+ path: "/",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "the activity log");
+ }
+
+ return await response.json();
+};
+
+export type ActivityPage = Awaited>;
+
+export type ActivityEntry = ActivityPage["edges"][number];
+
+/**
+ * The list, page by page: a keyset cursor newest-first, so "Load more" is as
+ * cheap on the hundredth page as on the first. Keyed on the filters, and keeps
+ * the previous result on screen while a new filter loads.
+ */
+export const activityListQueryOptions = (filters: ActivityStaffFilters) =>
+ infiniteQueryOptions({
+ getNextPageParam: (last: ActivityPage) =>
+ last.pageInfo.hasNextPage
+ ? (last.pageInfo.endCursor ?? undefined)
+ : undefined,
+ initialPageParam: undefined as string | undefined,
+ placeholderData: keepPreviousData,
+ queryFn: async ({ pageParam }) => await fetchPage(filters, pageParam),
+ queryKey: [...activityQueryRoot(), "list", filters],
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+const fetchDetail = async (id: number) => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ args: { params: { id } },
+ method: "get",
+ module: "admin/activity-logs",
+ path: "/{id}",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "an activity entry");
+ }
+
+ return await response.json();
+};
+
+export type ActivityDetail = Awaited>;
+
+export const activityDetailQueryOptions = (id: number) =>
+ queryOptions({
+ queryFn: async () => await fetchDetail(id),
+ queryKey: [...activityQueryRoot(), "detail", id],
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+const fetchFilterOptions = async () => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ method: "get",
+ module: "admin/activity-logs",
+ path: "/filters",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "activity filters");
+ }
+
+ return await response.json();
+};
+
+export type ActivityFilterOptions = Awaited<
+ ReturnType
+>;
+
+/** The plugins and actions the filters offer. Changes only with a deploy. */
+export const activityFilterOptionsQueryOptions = () =>
+ queryOptions({
+ queryFn: fetchFilterOptions,
+ queryKey: [...activityQueryRoot(), "filters"],
+ retry: false,
+ staleTime: Infinity,
+ });
+
+const fetchEntityPage = async (
+ target: { pluginId: string; targetId: string; targetType: string },
+ cursor?: string,
+) => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ args: { query: { ...target, ...(cursor ? { cursor } : {}) } },
+ method: "get",
+ module: "admin/activity-logs",
+ path: "/entity",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "an entity's activity");
+ }
+
+ return await response.json();
+};
+
+export type ActivityEntityPage = Awaited>;
+
+export type ActivityEntityEntry = ActivityEntityPage["edges"][number];
+
+/** One resource's history, newest first - see `ActivityHistory`. */
+export const activityEntityQueryOptions = (target: {
+ pluginId: string;
+ targetId: string;
+ targetType: string;
+}) =>
+ infiniteQueryOptions({
+ getNextPageParam: (last: ActivityEntityPage) =>
+ last.pageInfo.hasNextPage
+ ? (last.pageInfo.endCursor ?? undefined)
+ : undefined,
+ initialPageParam: undefined as string | undefined,
+ queryFn: async ({ pageParam }) => await fetchEntityPage(target, pageParam),
+ queryKey: [...activityQueryRoot(), "entity", target],
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+const fetchSettings = async () => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ method: "get",
+ module: "admin/activity-logs",
+ path: "/settings",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "activity settings");
+ }
+
+ return await response.json();
+};
+
+export const activitySettingsQueryOptions = () =>
+ queryOptions({
+ queryFn: fetchSettings,
+ queryKey: [...activityQueryRoot(), "settings"],
+ retry: false,
+ staleTime: OPERATIONAL_STALE_TIME,
+ });
+
+/** Saves the retention period; the caller invalidates and toasts. */
+export const saveActivitySettings = async (retentionDays: number) => {
+ const response = await fetcher({
+ plugin: manifest.id,
+ args: { body: { retentionDays } },
+ method: "put",
+ module: "admin/activity-logs",
+ path: "/settings",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "activity settings");
+ }
+
+ return await response.json();
+};
+
+/**
+ * Downloads the CSV export of the current filters - the existing download
+ * pattern: fetched with the AdminCP session, saved through an object URL. The
+ * API checks `can_export` and leaves IP addresses out without
+ * `can_view_sensitive`. Browser-only.
+ */
+export const downloadActivityExport = async (
+ filters: ActivityStaffFilters,
+): Promise => {
+ const response = await fetcherClient({
+ plugin: manifest.id,
+ args: { query: filters },
+ method: "get",
+ module: "admin/activity-logs",
+ options: { credentials: "include" },
+ path: "/export",
+ });
+ if (!response.ok) {
+ throw new ActivityRequestError(response.status, "the activity export");
+ }
+
+ const objectUrl = URL.createObjectURL(await response.blob());
+ const anchor = document.createElement("a");
+ anchor.href = objectUrl;
+ anchor.download = `${filters.staff}-logs-${new Date().toISOString().slice(0, 10)}.csv`;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ URL.revokeObjectURL(objectUrl);
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx
new file mode 100644
index 0000000000..e96c0f21c7
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-retention-form.tsx
@@ -0,0 +1,107 @@
+import { useQueryClient, useSuspenseQuery } from "@tanstack/react-query";
+import { toast } from "sonner";
+import { useTranslations } from "use-intl";
+import { z } from "zod";
+
+import type { AutoFormOnSubmit } from "@/components/form/auto-form";
+
+import { AutoForm } from "@/components/form/auto-form";
+import { AutoFormSheetFooter } from "@/components/form/auto-form-sheet-footer";
+import { AutoFormSelect } from "@/components/form/fields/select";
+import { AUDIT_RETENTION_DAYS_OPTIONS } from "@/lib/audit/const";
+
+import {
+ activityQueryRoot,
+ activitySettingsQueryOptions,
+ saveActivitySettings,
+} from "./activity-query";
+
+const OPTIONS = AUDIT_RETENTION_DAYS_OPTIONS.map(String) as [
+ string,
+ ...string[],
+];
+
+/**
+ * The retention form, in the settings sheet. Saving refreshes the cached
+ * settings, toasts what will happen next and closes the sheet.
+ */
+export const ActivityRetentionForm = ({
+ canManage,
+ onSaved,
+}: {
+ canManage: boolean;
+ onSaved: () => void;
+}) => {
+ const t = useTranslations("admin.staff.logs.settings");
+ const tGlobal = useTranslations("core.global");
+ const tError = useTranslations("core.global.errors");
+ const queryClient = useQueryClient();
+ const { data } = useSuspenseQuery(activitySettingsQueryOptions());
+
+ const period = (days: number) =>
+ days === 0 ? t("forever") : t("days", { count: days });
+
+ const formSchema = z.object({
+ retentionDays: z.enum(OPTIONS).default(String(data.retentionDays)),
+ });
+
+ const onSubmit: AutoFormOnSubmit = async values => {
+ const retentionDays = Number(values.retentionDays);
+ try {
+ await saveActivitySettings(retentionDays);
+ } catch {
+ toast.error(tError("title"), { description: t("error") });
+
+ return;
+ }
+
+ await queryClient.invalidateQueries({
+ queryKey: activityQueryRoot(),
+ });
+ toast.success(t("saved"), {
+ description:
+ retentionDays === 0
+ ? t("saved_forever")
+ : t("saved_desc", { period: period(retentionDays) }),
+ });
+ onSaved();
+ };
+
+ return (
+ (
+ ({
+ label: period(Number(value)),
+ value,
+ }))}
+ />
+ ),
+ id: "retentionDays",
+ },
+ ]}
+ formSchema={formSchema}
+ layout={rendered => (
+ <>
+
+ {rendered.retentionDays}
+
+
+ >
+ )}
+ onSubmit={onSubmit}
+ />
+ );
+};
+
+export default ActivityRetentionForm;
diff --git a/packages/vitnode/src/views/activity-logs/activity-screen.tsx b/packages/vitnode/src/views/activity-logs/activity-screen.tsx
new file mode 100644
index 0000000000..8d54f640c1
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-screen.tsx
@@ -0,0 +1,152 @@
+import { useInfiniteQuery, useSuspenseQuery } from "@tanstack/react-query";
+import { CircleAlertIcon } from "lucide-react";
+import React from "react";
+import { useTranslations } from "use-intl";
+
+import type { AuditStaffLog } from "@/lib/audit/const";
+
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle,
+} from "@/components/ui/empty";
+import { Skeleton } from "@/components/ui/skeleton";
+
+import type { ActivityEntry, ActivityFilters } from "./activity-query";
+
+import { ActivityFiltersBar } from "./activity-filters";
+import {
+ activityFilterOptionsQueryOptions,
+ activityListQueryOptions,
+} from "./activity-query";
+import { ActivitySheet } from "./activity-sheet";
+import { ActivityTable } from "./activity-table";
+
+/** Rows shaped like the table's, for the first load only - a refetch keeps the data. */
+export const ActivityListPending = () => {
+ const t = useTranslations("core.activity_logs");
+
+ return (
+
+
+ {t("loading")}
+
+ {Array.from({ length: 8 }, (_, index) => (
+
+ ))}
+
+ );
+};
+
+export interface ActivityScreenProps {
+ /** Toolbar actions beside the filters - export and retention. */
+ actions?: React.ReactNode;
+ /** The entry whose sheet is open, from the URL. */
+ entryId: null | number;
+ filters: ActivityFilters;
+ /** Writes filters and the open entry back into the URL. */
+ onNavigate: (next: {
+ entryId: null | number;
+ filters: ActivityFilters;
+ }) => void;
+ /** Which staff log: admins' (with system and member activity) or moderators'. */
+ staff: AuditStaffLog;
+}
+
+/**
+ * Filters, the list and the detail sheet of one staff log - the admins' or the
+ * moderators' Activity Logs in AdminCP -> Staff.
+ */
+export const ActivityScreen = ({
+ actions,
+ entryId,
+ filters,
+ onNavigate,
+ staff,
+}: ActivityScreenProps) => {
+ const t = useTranslations("core.activity_logs");
+ const { data: options } = useSuspenseQuery(
+ activityFilterOptionsQueryOptions(),
+ );
+ const list = useInfiniteQuery(
+ activityListQueryOptions({ ...filters, staff }),
+ );
+ const entries = React.useMemo(
+ () => list.data?.pages.flatMap(page => page.edges) ?? [],
+ [list.data],
+ );
+ const [openedFrom, setOpenedFrom] = React.useState();
+ const [actorName, setActorName] = React.useState(null);
+
+ const actorFromList = filters.actorId
+ ? entries.find(entry => entry.actor?.id === filters.actorId)?.actor?.name
+ : undefined;
+
+ return (
+
+
+
+
{
+ onNavigate({ entryId: null, filters: next });
+ }}
+ options={options}
+ staff={staff}
+ />
+
+ {actions ? (
+
{actions}
+ ) : null}
+
+
+ {list.isPending ?
: null}
+ {list.isError && !list.data ? (
+
+
+
+
+
+ {t("error_title")}
+ {t("error_desc")}
+
+
+ ) : null}
+ {list.data ? (
+
{
+ void list.fetchNextPage();
+ }}
+ hasNextPage={list.hasNextPage}
+ isFetching={list.isFetching}
+ isFetchingNextPage={list.isFetchingNextPage}
+ onOpen={entry => {
+ setOpenedFrom(entry);
+ onNavigate({ entryId: entry.id, filters });
+ }}
+ plugins={options.plugins}
+ />
+ ) : null}
+
+ {
+ setActorName(actor.name);
+ onNavigate({
+ entryId: null,
+ filters: { ...filters, actorId: actor.id },
+ });
+ }}
+ onOpenChange={open => {
+ if (!open) onNavigate({ entryId: null, filters });
+ }}
+ plugins={options.plugins}
+ />
+
+ );
+};
diff --git a/packages/vitnode/src/views/activity-logs/activity-search.test.ts b/packages/vitnode/src/views/activity-logs/activity-search.test.ts
new file mode 100644
index 0000000000..e93661826e
--- /dev/null
+++ b/packages/vitnode/src/views/activity-logs/activity-search.test.ts
@@ -0,0 +1,47 @@
+import { describe, expect, it } from "vitest";
+
+import { normalizeActivitySearch } from "./activity-search";
+
+describe("normalizeActivitySearch", () => {
+ it("keeps every recognised filter", () => {
+ const search = {
+ action: "role.updated",
+ actorId: 7,
+ area: "admin",
+ from: "2026-01-01",
+ log: "12",
+ pluginId: "@vitnode/blog",
+ search: "News",
+ targetId: "42",
+ targetType: "blog.category",
+ to: "2026-01-31",
+ };
+
+ expect(normalizeActivitySearch(search)).toEqual({
+ ...search,
+ log: 12,
+ });
+ });
+
+ it.each([
+ ["action", "Role Updated"],
+ ["actorId", "-1"],
+ ["actorId", "1.5"],
+ ["area", "superadmin"],
+ ["from", "01/01/2026"],
+ ["log", "0"],
+ ["pluginId", "