Documentation

Moderation

Moderation reference

Reference for VitNode moderation, covering the reports option, scope families, report targets, permissions, ModCP report and approval 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. A content type that reaches its container through a relation, such as a forum comment through its topic, has no such column, so its report permissions stay unscoped.

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.

Public write adds can_edit to the same module. In a container collection with publishing policies, can_edit and can_approve are scoped by the collection's family, whose id is the container definition id and whose resources are container ids and "{id}:tree" subtrees. See Container-scoped permissions. Generated entries merge with the report permissions; only a module the plugin declares itself in permissionStaff.moderator is left as declared.

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 UI

Public pages report a target with the components from @vitnode/core/tanstack/moderation. Both load their own messages (REPORT_NAMESPACES) and lazy-load the form, and both show a Sign in link instead of the form to guests. For a walkthrough, see Enable reports.

contentTarget

contentTarget(pluginId, definition, id) from @vitnode/core/content returns the ContentTarget of one record: { pluginId, type, id }, where type is definition.id and id is the record id as a string. A custom report target uses the same shape with its own type.

ReportButton

A ghost button labelled Report that opens the dialog. Its accessible name is "Report typeLabel".

NameTypeRequiredDefaultDescription
targetContentTargetYesThe reported record
typeLabelstringYesWhat the item is called, already translated, for "Report typeLabel"
describedBystringNoId of the element naming the item, read after the label so list buttons differ
localestring | nullNonullThe locale the reader was served, for moderators
requestedLocalestring | nullNonullThe locale the reader asked for, when the content fell back to another
previewReportPreviewNoWhat the reader is reporting, shown at the top of the dialog: { title, imageUrl?, meta? }, already translated
classNamestringNoClasses for the button

ReportDialog

The same dialog, controlled by you. Use it for your own trigger, such as a menu item, or to share one dialog across a long list. It takes target, typeLabel, locale, requestedLocale and preview like ReportButton, plus open: boolean and onOpenChange(open: boolean).

import type { ContentTarget } from "@vitnode/core/content";

import { ReportDialog } from "@vitnode/core/tanstack/moderation";

const [reporting, setReporting] = useState<ContentTarget | null>(null);

{reporting ? (
  <ReportDialog
    onOpenChange={open => {
      if (!open) setReporting(null);
    }}
    open
    target={reporting}
    typeLabel={t("report_type")}
  />
) : null}

The dialog has three screens: pick a reason, add details, and Report sent. Picking a reason with a click, tap, Space or Enter moves on; arrow keys only move the selection. A failure shows a toast with a description: already reported (409 duplicate), no longer available (404), too many reports (429), session ended (401), reason just removed (400 reason_not_found, back to the reasons), missing details (400 details_required) and anything else.

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

A report stores its reason's code. Admins manage the reasons in Report reasons; the four starter codes are spam, abusive, rule_violation and other, and only a reason with requiresDetails 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
GET /reasonsAnyoneReturns { reasons } in display order, each { id, code, requiresDetails, title, description } with every translation as { languageCode, value }[]
POST /reportsSigned in{ pluginId, targetType, targetId, locale?, requestedLocale?, reason, description? }, where reason is a reason code
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

Report reasons

The AdminCP routes behind Report reasons, under /api/@vitnode/core/admin/report-reasons, using the AdminCP session. Each needs the matching report_reasons admin permission.

Method and pathPermissionBodySuccess
GET /can_view{ reasons } with position
POST /createcan_create{ title, description, requiresDetails }201 { id }, added last
PATCH /{id}can_editAny of { title, description, requiresDetails }{ id }
PUT /reordercan_edit{ ids }: every reason id once, in the new order200
DELETE /{id}can_delete200

title and description are { languageCode, value }[]. title needs at least one value. Empty values are dropped, and a missing language falls back to the default language in the dialog. A new reason gets a generated code.

errorStatusWhen
limit_reached409Creating an 11th reason
last_reason409Deleting the only reason left
mismatch400ids misses, repeats or invents a reason
not_found404No reason with that id

Approval routes

Also under /api/@vitnode/core/moderation, for approvals. The queue routes need a moderator entry and show only submissions in containers where the moderator holds can_approve; anything else answers 404, so its existence is not revealed. Every response is Cache-Control: private, no-store.

Method and pathPermissionInputSuccess
GET /approvalscan_approveQuery status (default pending), contentTypeId, containerId, page, pageSize{ items, page, pageSize, total }
GET /approvals/filterscan_approve{ contentTypes, containers } the moderator may review
GET /approvals/mineSigned inQuery page, pageSize, pluginId (optional, one plugin's submissions only)The member's own pending, approved and rejected submissions, newest first
GET /approvals/{id}can_approveThe submission with values, baseValues, current, fields, record and history
POST /approvals/{id}/approvecan_approve{ id, status }
POST /approvals/{id}/rejectcan_approve{ reason? }, up to 2000 characters{ id, status }

The pending queue is oldest first; decided statuses are newest decision first. A list item has id, kind, status, contentTypeId, pluginId, itemId, title, author, reviewer, container ({ id, name, slug, path }), reason, createdAt and decidedAt.

A refused decision answers { error, message }:

errorStatusWhen
notFound404No such submission in the moderator's scope
forbidden403The record now lives in a container where the moderator cannot approve
decided409The submission is no longer pending
gone409The record was deleted. The submission becomes cancelled
stale409The record changed since the proposal, or the proposal no longer validates

Statuses are pending, approved, rejected, superseded (replaced by the author's newer proposal) and cancelled (the record was deleted first). The constants are CONTENT_SUBMISSION_STATUSES, CONTENT_SUBMISSION_KINDS and CONTENT_SUBMISSION_DECISION_CODES from @vitnode/core/content. Server code can decide one with decideContentSubmission(c, { decision, id, reason?, reviewerId }) from @vitnode/core/content/server, which throws ContentSubmissionDecisionError with the same code and status.

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 }
moderation.report_reason.created{ reasonId, code }
moderation.report_reason.updated{ reasonId, code }
moderation.report_reason.deleted{ reasonId, code }
moderation.report_reason.reordered{ reasonIds } in the new order
content.approval.submitted{ submissionId, replacedSubmissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId }
content.approval.approved{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId }
content.approval.rejected{ submissionId, pluginId, contentTypeId, contentId, containerId, kind, authorId, reviewerId, reason }

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 when the reason requires details
Report reasons1 to 10; titles up to 100 characters, descriptions up to 255
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
Approval queue page size25 by default, up to 100
Rejection reasonOptional, up to 2000 characters
Pending submissions per record1

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.

The migration 20261019161102_add_moderation_report_reasons adds core_moderation_report_reasons with a unique code, requiresDetails and position. Titles and descriptions live in core_languages_words. core_moderation_reports.reason holds the reason's code without a foreign key, so deleting a reason keeps its reports. db:prepare and db:migrate add the four starter reasons only while the table is empty.

Approvals live in core_content_submissions, one row per submission. Rows are never deleted with the content, author and reviewer are ON DELETE SET NULL, and a partial unique index allows one pending submission per record. Container policy overrides are stored in core_content_containers.policies.