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.
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
titleField | field name | No | admin.titleField when it is in publicApi.fields, else none | A text, textarea or slug field |
snapshotFields | field names | No | Public text, textarea, richText, enum and file fields, without the title | Fields 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.
| Permission | Depends on | Added when | Allows |
|---|---|---|---|
can_view_reports | Always | The reports board, report details and related reports | |
can_manage_reports | can_view_reports | Always | Take, reassign, notes, resolve, reopen; edit and delete your own notes |
can_edit_notes | can_manage_reports | Always | Edit any moderator's notes |
can_delete_notes | can_manage_reports | Always | Delete any moderator's notes |
can_view_hidden | The target can be hidden | Opening hidden content on the public site | |
can_hide | can_view_hidden | The target can be hidden | Hide 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 } }).
| Name | Type | Required | Description |
|---|---|---|---|
id | string | Yes | Lowercase and dot separated, unique across plugins |
label | string | Yes | i18n key |
description | string | No | i18n key shown in the picker |
list | (c, { cursor, limit, search }) => Promise<{ items, nextCursor }> | Yes | One page of resources for the AdminCP picker |
find | (c, ids) => Promise<items> | Yes | The 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 } }).
| Name | Type | Required | Description |
|---|---|---|---|
type | string | Yes | Stable id stored in reports, such as forum.topic |
label | string | Yes | i18n key for the kind of thing, an ICU plural called with count: 1 |
permissionModule | string | Yes | Moderator module the report permissions are added to |
parseId | (value) => string | null | Yes | Validates a client id before anything reads it |
capture | (c, { targetId, locale }) => Promise<capture | null> | Yes | The snapshot body, or null when the reporter cannot read the target |
current | (c, { targetId, locale }) => Promise<{ hidden, url } | null> | Yes | The target now, or null once deleted |
scope | string | No | Scope family id |
resolveScope | (c, targetId) => Promise<string | undefined> | With scope | The 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".
| Name | Type | Required | Default | Description |
|---|---|---|---|---|
target | ContentTarget | Yes | The reported record | |
typeLabel | string | Yes | What the item is called, already translated, for "Report typeLabel" | |
describedBy | string | No | Id of the element naming the item, read after the label so list buttons differ | |
locale | string | null | No | null | The locale the reader was served, for moderators |
requestedLocale | string | null | No | null | The locale the reader asked for, when the content fell back to another |
preview | ReportPreview | No | What the reader is reporting, shown at the top of the dialog: { title, imageUrl?, meta? }, already translated | |
className | string | No | Classes 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
| Status | Reached by |
|---|---|
new | Filing a report, unassigning, or reopening an unassigned report |
in_progress | Taking or assigning a report, or reopening an assigned one |
done | Resolving 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 path | Permission | Body |
|---|---|---|
GET /reasons | Anyone | Returns { reasons } in display order, each { id, code, requiresDetails, title, description } with every translation as { languageCode, value }[] |
POST /reports | Signed in | { pluginId, targetType, targetId, locale?, requestedLocale?, reason, description? }, where reason is a reason code |
GET /reports | can_view_reports | Query status, assigned, search, page, pageSize |
GET /reports/{id} | can_view_reports | |
POST /reports/{id}/claim | can_manage_reports | { expectedVersion } |
POST /reports/{id}/assign | can_manage_reports | { expectedVersion, assigneeId } |
POST /reports/{id}/notes | can_manage_reports | { body }: plain text or a rich-text document |
POST /reports/{id}/notes/{noteId}/edit | can_manage_reports and the note's author, or can_edit_notes | { body } |
POST /reports/{id}/notes/{noteId}/delete | can_manage_reports and the note's author, or can_delete_notes | |
POST /reports/{id}/resolve | can_manage_reports | { expectedVersion, resolution, explanation? } |
POST /reports/{id}/reopen | can_manage_reports | { expectedVersion, reason } |
POST /reports/{id}/actions/{action} | can_manage_reports and the action's permission | { expectedVersion } |
GET /reports/{id}/assignees | can_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 path | Permission | Body | Success |
|---|---|---|---|
GET / | can_view | { reasons } with position | |
POST /create | can_create | { title, description, requiresDetails } | 201 { id }, added last |
PATCH /{id} | can_edit | Any of { title, description, requiresDetails } | { id } |
PUT /reorder | can_edit | { ids }: every reason id once, in the new order | 200 |
DELETE /{id} | can_delete | 200 |
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.
error | Status | When |
|---|---|---|
limit_reached | 409 | Creating an 11th reason |
last_reason | 409 | Deleting the only reason left |
mismatch | 400 | ids misses, repeats or invents a reason |
not_found | 404 | No 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 path | Permission | Input | Success |
|---|---|---|---|
GET /approvals | can_approve | Query status (default pending), contentTypeId, containerId, page, pageSize | { items, page, pageSize, total } |
GET /approvals/filters | can_approve | { contentTypes, containers } the moderator may review | |
GET /approvals/mine | Signed in | Query page, pageSize, pluginId (optional, one plugin's submissions only) | The member's own pending, approved and rejected submissions, newest first |
GET /approvals/{id} | can_approve | The submission with values, baseValues, current, fields, record and history | |
POST /approvals/{id}/approve | can_approve | { id, status } | |
POST /approvals/{id}/reject | can_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 }:
error | Status | When |
|---|---|---|
notFound | 404 | No such submission in the moderator's scope |
forbidden | 403 | The record now lives in a container where the moderator cannot approve |
decided | 409 | The submission is no longer pending |
gone | 409 | The record was deleted. The submission becomes cancelled |
stale | 409 | The 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
| Event | Payload |
|---|---|
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:
| Field | Description |
|---|---|
capturedAt | When the server captured it |
target | { pluginId, type, id } |
title | The 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 |
scope | The scope resource at capture time, as evidence |
url | The public address at capture time |
truncated | true 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
| Limit | Value |
|---|---|
| Report details | 2000 characters, at least 10 when the reason requires details |
| Report reasons | 1 to 10; titles up to 100 characters, descriptions up to 255 |
| Plain-text notes, reopen reasons | 3 to 2000 characters |
| Resolution explanations | Optional, up to 2000 characters |
| Rich-text notes | 64 KB, not empty |
| One text value in a snapshot | 20,000 characters |
| Whole snapshot | 256 KB; rich text falls back to plain text, then fields are dropped |
| Reports per user | 20 per hour |
| Open reports per reporter and target | 1 |
| Queue page size | 25 by default, up to 100 |
| Approval queue page size | 25 by default, up to 100 |
| Rejection reason | Optional, up to 2000 characters |
| Pending submissions per record | 1 |
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.