Logo VitNode

Moderation

Moderation reference

Reference for VitNode moderation, covering the reports option, scope families, report targets, permissions, ModCP API routes, statuses, events, snapshot format and limits.

Exact names, types and behavior of the moderation APIs. For step-by-step guides, start at Moderation.

reports on a content type

defineContentType({ reports }) from @vitnode/core/content. Omitted or false registers nothing.

NameTypeRequiredDefaultDescription
titleFieldfield nameNoadmin.titleField when it is in publicApi.fields, else noneA text, textarea or slug field
snapshotFieldsfield namesNoPublic text, textarea, richText, enum and file fields, without the titleFields of kind text, textarea, richText, slug, number, boolean, enum, dateTime or file

reports: true uses both defaults. It throws without publicApi, for an unknown option, for reports.enabled, and for a field that does not exist or cannot be snapshotted.

The server-side model can scope reports with createContentModel(definition, { moderationScope: { column, family } }), where column is a base-table column holding the resource id.

Generated moderator permissions

Added to the content type's permissionModule in the moderator catalog, unless the plugin declares that moderator module itself. All of them carry the target's scope family when it has one.

PermissionDepends onAdded whenAllows
can_view_reportsAlwaysThe reports board, report details and related reports
can_manage_reportscan_view_reportsAlwaysTake, reassign, notes, resolve, reopen; edit and delete your own notes
can_edit_notescan_manage_reportsAlwaysEdit any moderator's notes
can_delete_notescan_manage_reportsAlwaysDelete any moderator's notes
can_view_hiddenThe target can be hiddenOpening hidden content on the public site
can_hidecan_view_hiddenThe target can be hiddenHide content and Restore content in the ModCP

Content actions also need can_manage_reports, because they are recorded in the report.

Scoped grants

A stored grant is { plugin, module, permission, scope? }. scope is one of:

{ family: "example.category", mode: "all" }
{ family: "example.category", mode: "selected", ids: ["1", "2"] }

Declare a scoped permission with { permission, dependsOn?, scope: "family.id" } in permissionStaff.moderator. Admin permissions cannot be scoped. Resource ids match /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.

PATCH /api/@vitnode/core/admin/staff/entry/moderator/{id} answers 400 with { error: "invalid_permissions", issues }. Each issue has a key and a type: missing_scope, empty_selection, invalid_resource, unknown_resource, unexpected_scope, wrong_family or dependency_scope. A grant wider than the caller's own permissions is 403.

GET /api/@vitnode/core/users/permissions returns the signed-in user's moderator grants, each with its scope.

defineModerationScope

From @vitnode/core/api/moderation. Register with buildApiPlugin({ moderation: { scopes } }).

NameTypeRequiredDescription
idstringYesLowercase and dot separated, unique across plugins
labelstringYesi18n key
descriptionstringNoi18n key shown in the picker
list(c, { cursor, limit, search }) => Promise<{ items, nextCursor }>YesOne page of resources for the AdminCP picker
find(c, ids) => Promise<items>YesThe resources with these ids that exist

An item is { id: string; label: string; context?: string[] }. The AdminCP reads them through GET /api/@vitnode/core/admin/staff/scope-resources/{family}?search=&cursor=, or ?ids=1,2 to label a saved selection, which also returns missing ids.

Custom report targets

Content Engine types register their target automatically. Anything else registers one with defineReportTarget and buildApiPlugin({ moderation: { reportTargets } }).

NameTypeRequiredDescription
typestringYesStable id stored in reports, such as forum.topic
labelstringYesi18n key for the kind of thing, an ICU plural called with count: 1
permissionModulestringYesModerator module the report permissions are added to
parseId(value) => string | nullYesValidates a client id before anything reads it
capture(c, { targetId, locale }) => Promise<capture | null>YesThe snapshot body, or null when the reporter cannot read the target
current(c, { targetId, locale }) => Promise<{ hidden, url } | null>YesThe target now, or null once deleted
scopestringNoScope family id
resolveScope(c, targetId) => Promise<string | undefined>With scopeThe resource id now, "" for none, undefined once deleted
actions{ hide?, unhide? }No{ permission, available(state), run(c, { actorUserId, targetId, tx }) }

run executes inside the report's transaction and returns { changed, effects }. effects runs after the commit. When run throws, the transaction rolls back and no timeline entry is written.

Report lifecycle

StatusReached by
newFiling a report, unassigning, or reopening an unassigned report
in_progressTaking or assigning a report, or reopening an assigned one
doneResolving with no_violation, action_taken or duplicate

Reasons are spam, abusive, rule_violation and other; other needs details. Every change except a note needs expectedVersion. A stale version answers 409 with { error: "conflict", current }, and taking a report someone else holds answers 409 already_claimed.

ModCP API routes

All under /api/@vitnode/core/moderation, using the user session. A guest gets 401, a user without a moderator entry 403, and a report outside the moderator's scopes 404.

Method and pathPermissionBody
POST /reportsSigned in{ pluginId, targetType, targetId, locale?, requestedLocale?, reason, description? }
GET /reportscan_view_reportsQuery status, assigned, search, page, pageSize
GET /reports/{id}can_view_reports
POST /reports/{id}/claimcan_manage_reports{ expectedVersion }
POST /reports/{id}/assigncan_manage_reports{ expectedVersion, assigneeId }
POST /reports/{id}/notescan_manage_reports{ body }: plain text or a rich-text document
POST /reports/{id}/notes/{noteId}/editcan_manage_reports and the note's author, or can_edit_notes{ body }
POST /reports/{id}/notes/{noteId}/deletecan_manage_reports and the note's author, or can_delete_notes
POST /reports/{id}/resolvecan_manage_reports{ expectedVersion, resolution, explanation? }
POST /reports/{id}/reopencan_manage_reports{ expectedVersion, reason }
POST /reports/{id}/actions/{action}can_manage_reports and the action's permission{ expectedVersion }
GET /reports/{id}/assigneescan_manage_reports

Events

EventPayload
moderation.report.created{ reportId, pluginId, targetType, targetId, reason }
moderation.report.assigned{ reportId, assigneeId, previousAssigneeId }
moderation.report.action{ reportId, action, pluginId, targetType, targetId }
moderation.report.resolved{ reportId, resolution }
moderation.report.reopened{ reportId }
moderation.report.note_updated{ reportId, noteId }
moderation.report.note_deleted{ reportId, noteId }

Adding a note emits no event. Hiding through a report also emits the content type's own hidden or unhidden event. See Built-in events.

Snapshot format

Stored in core_moderation_reports.snapshot as JSON, version: 1:

FieldDescription
capturedAtWhen the server captured it
target{ pluginId, type, id }
titleThe display title
fields{ name, label, kind, value } with kind text, richText, file or value
locale{ requested, served, fallback }
source{ version, updatedAt, publishedAt } of the captured state
authors{ id, name } of the record's authors
scopeThe scope resource at capture time, as evidence
urlThe public address at capture time
truncatedtrue when text was cut to fit

The snapshot is read in one read-only transaction, so its fields and source describe the same state. The ModCP renders every snapshot with a generic viewer that needs no plugin code, and rich text goes through the same element allowlist as the public site.

Limits

LimitValue
Report details2000 characters, at least 10 for other
Plain-text notes, reopen reasons3 to 2000 characters
Resolution explanationsOptional, up to 2000 characters
Rich-text notes64 KB, not empty
One text value in a snapshot20,000 characters
Whole snapshot256 KB; rich text falls back to plain text, then fields are dropped
Reports per user20 per hour
Open reports per reporter and target1
Queue page size25 by default, up to 100

File fields keep { id, name, mimeType, size, url } only. The file's bytes are never copied, so a file deleted later cannot be opened from the report.

Database

The migration 20261011193321_add_moderation_reports adds core_moderation_reports and core_moderation_report_events. Neither references the reported content, so deleting content keeps its reports. Reporter, assignee and resolver are ON DELETE SET NULL, so deleting an account keeps the evidence. The unique openKey column enforces one open report per reporter and target, even under concurrent submissions.