Documentation

Moderation

Scoped moderator permissions

Limit VitNode moderator permissions to selected resources, such as some forum sections or categories, with defineModerationScope, a content model's moderationScope, or the container-scoped can_edit and can_approve of a container collection with policies.

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:

plugins/example/src/api/lib/moderation.ts
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:

plugins/example/src/config.api.ts
buildApiPlugin({
  manifest,
  moderation: { scopes: [exampleCategoryScope] }, 
  modules: [/* ... */],
});
plugins/example/src/locales/en.json
{
  "@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:

plugins/example/src/database/articles.ts
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

Save the file while pnpm dev runs. 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.

Container-scoped permissions

A container collection with publishing policies brings its own scope family, so there is nothing to define. The family id is the collection's id, such as forum.forums, and its label is the collection's title.

Every content type filed in that collection with public write, through field.container() or field.relation({ container: true }), gets two moderator permissions scoped by it:

PermissionAdded whenAllows
can_editpublicWrite.update is onEdit any record in the container from the public site, at any age; create where create: "moderators"
can_approvepublicWrite is onSee and decide approvals there, and skip approval for your own posts

They merge with the report permissions when the content type also has reports: true. Label them in the plugin's locale file. can_edit shares its key with the admin permission, and a missing label shows the raw name:

plugins/forum/src/locales/en.json
{
  "@vitnode/forum:topic:can_edit": "Edit topics",
  "@vitnode/forum:topic:can_approve": "Approve topics",
  "@vitnode/forum:comment:can_edit": "Edit comments",
  "@vitnode/forum:comment:can_approve": "Approve comments"
}

Pick forums or whole subtrees

A resource is either one container or one container with everything below it:

ResourcePicker labelCovers
"12"HelpContainer 12 only, never its children
"12:tree"Help › *Container 12 and every container below it, including ones created later

The :tree entry is listed only for a container that has children. A record in container C is covered by a grant that names C, C:tree or the :tree of any ancestor, by All resources, by an unscoped grant, or by a root role. The server resolves the record's container on every check, and again when a decision is made, so a topic moved to another forum follows its new scope.

A moderator entry on its own grants nothing. Someone flagged as staff, with no can_edit or can_approve for a forum, edits and approves exactly as much there as any member: nothing beyond their own posts.

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.