Moderation
Scoped moderator permissions
Limit VitNode moderator permissions to selected resources, such as some forum sections or categories, with defineModerationScope and a content model's moderationScope.
A scoped moderator permission applies to every resource of a scope family, or only to the resources an administrator picks. "View reports in every section" and "hide topics only in section B" are two grants of scoped permissions. This guide uses the example plugin, whose article reports are scoped by category.
Before you begin
Scopes build on staff permissions and on reports. If your content type does not report yet, start with Enable reports.
Register a scope family
A scope family tells the AdminCP which resources exist. Create it on the server with defineModerationScope:
import { defineModerationScope } from "@vitnode/core/api/moderation";
import { and, asc, gt, ilike, inArray } from "drizzle-orm";
import { example_categories } from "@/database/categories";
export const exampleCategoryScope = defineModerationScope({
id: "example.category",
label: "@vitnode/example.moderation.category.label",
description: "@vitnode/example.moderation.category.desc",
list: async (c, { cursor, limit, search }) => {
const rows = await c
.get("db")
.select({ id: example_categories.id, name: example_categories.name })
.from(example_categories)
.where(
and(
cursor ? gt(example_categories.id, Number(cursor)) : undefined,
search ? ilike(example_categories.name, `%${search}%`) : undefined,
),
)
.orderBy(asc(example_categories.id))
.limit(limit + 1);
const page = rows.slice(0, limit);
return {
items: page.map(row => ({ id: String(row.id), label: row.name })),
nextCursor: rows.length > limit ? String(page.at(-1)?.id) : null,
};
},
find: async (c, ids) => {
const numeric = ids.map(Number).filter(id => Number.isSafeInteger(id));
if (numeric.length === 0) return [];
const rows = await c
.get("db")
.select({ id: example_categories.id, name: example_categories.name })
.from(example_categories)
.where(inArray(example_categories.id, numeric));
return rows.map(row => ({ id: String(row.id), label: row.name }));
},
});list feeds the picker one page at a time. find labels a saved selection and lets the API refuse an id that does not exist. Return context, such as ["Games"], on each resource to show where a nested resource sits. Both callbacks read the database, so the family never reaches the browser.
Add it to the plugin
Pass the family to buildApiPlugin, and translate its label:
buildApiPlugin({
pluginId: CONFIG_PLUGIN.pluginId,
moderation: { scopes: [exampleCategoryScope] },
modules: [/* ... */],
});{
"@vitnode/example": {
"moderation": {
"category": {
"label": "Categories",
"desc": "Limit this permission to articles in some categories."
}
}
}
}Family ids are global. Two plugins registering the same id, or a permission naming a family nobody registers, stops the API at boot with a ModerationRegistryError.
Tell the content model where a record sits
On the server-side model, name the base-table column that holds the resource id:
export const articleContent = createContentModel(articleContentType, {
references: { category: () => example_categories.id },
moderationScope: { column: "category", family: "example.category" },
});The report permissions of example.article are now scoped by example.category. The server reads the column itself, so a client can never claim a record is in another category. The definition in content/article.ts stays browser-safe, without callbacks.
Check the result
Rebuild with pnpm build:plugins and restart pnpm dev. Open AdminCP → Staff → Moderators, edit a moderator and select the example plugin. View reports and Claim, assign, annotate and resolve reports now show a scope under the checkbox, and Change opens the category picker. See Configure moderators.
How grants combine
A user's effective moderator permissions come from their own staff entry and the entries of every role they hold:
- Grants of one permission merge. "View reports in A" from a role and "view reports in B" from the user give "view reports in A and B". "All resources" wins over a selection.
- Permissions never combine across each other. A role with "hide in A" and a user entry with "view in B" never allow hiding in B. Each permission keeps its own scope.
- Dependencies are checked per resource. Managing reports depends on viewing reports, so "manage in A" needs "view in A". Saving "manage in B" with only "view in A" is refused, and if broken data ever gets through, the effective scope of "manage" is cut down to where "view" is held.
- Root roles and unrestricted entries still allow everything, in every scope.
isModerator in the session means "has a moderator entry". It grants no permission by itself, and the ModCP shows only what the grants allow.
Compatibility with unscoped grants
Unscoped permissions work as before. A grant without a scope allows the permission everywhere.
When a plugin later makes an existing permission scoped, grants saved before that change have no scope. They keep meaning all resources, and the editor shows them as All resources. A new save always needs an explicit scope. The API answers 400 with missing_scope instead of guessing that a missing scope means "everything".
Check a scope in your own code
Pass the resource when you check a scoped permission:
const allowed = await checkStaffPermission(c, {
type: "moderator",
plugin: "@vitnode/example",
module: "article",
permission: "can_view_reports",
resource: { family: "example.category", id: "2" },
});A check without resource only passes for an unscoped or an All resources grant, so older code can never be widened by a selection. When a record moves to another resource, call syncModerationReportScope(c, { pluginId, targetType, targetId }) from @vitnode/core/api/moderation after the move, so its reports follow it. The ModCP also re-reads the scope every time it opens a report.