Staff Permissions
Declare plugin-based staff permissions and check them on the backend and frontend.
VitNode has two kinds of staff: moderators and admins. A staff entry links either a whole role or a single user to a staff group (stored in core_moderators_permissions / core_admin_permissions). On top of that, plugins can declare granular permissions that determine what each staff entry is actually allowed to do.
Permissions are:
- Plugin-based - every plugin declares its own catalog in its API config.
- Grouped by module and split into a
moderatorand anadminset. - Granted per staff entry - toggled from the admin panel and stored in the entry's
dataJSON column.
Declaring permissions
Add a permissionStaff object to your plugin's buildApiPlugin config. Each side (moderator, admin) is keyed by module, and each module lists permission ids:
import { buildApiPlugin } from "@vitnode/core/api/lib/plugin";
export const blogApiPlugin = () =>
buildApiPlugin({
pluginId: "@vitnode/blog",
modules: [postsModule, categoriesModule],
permissionStaff: {
moderator: {
posts: ["can_edit", "can_delete"],
},
admin: {
posts: ["can_create", "can_edit", "can_delete"],
categories: ["can_manage"],
},
},
});Dependent permissions
A permission can depend on other permissions in the same module. Declare it as an object with dependsOn instead of a plain string, and the editor keeps it hidden until every permission it depends on is enabled - a can_view gate that reveals the rest is the typical use:
permissionStaff: {
admin: {
posts: [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_edit", dependsOn: ["can_view"] },
{ permission: "can_delete", dependsOn: ["can_view"] },
],
},
},dependsOn is generic, not tied to can_view: it accepts any permission id from the same module, multiple gates, and dependency chains (a → b → c). Plain strings and dependsOn objects can be mixed freely in the same module.
When a gate is switched off in the editor, everything that (transitively) depends on it is hidden and un-granted in the same step. The dependency is also enforced on the backend when saving, so a forged or stale request can't persist can_create without its can_view.
Visibility only - not a runtime gate
dependsOn controls what an admin can see and grant; it does not change
how individual checks evaluate. checkStaffPermission still tests each
permission on its own. Keep guarding every action explicitly (see
Backend).
Permission labels (i18n)
Each permission needs a human-readable label. Add it to your plugin's locale file as a flat top-level key following the {pluginId}:{module}:{permission} convention. You may also add a {pluginId}:{module} key for the module heading:
{
"@vitnode/blog": { "title": "Blog" },
"@vitnode/blog:posts": "Posts",
"@vitnode/blog:posts:can_create": "Create posts",
"@vitnode/blog:posts:can_edit": "Edit posts",
"@vitnode/blog:posts:can_delete": "Delete posts",
"@vitnode/blog:categories": "Categories",
"@vitnode/blog:categories:can_manage": "Manage categories"
}Where permissions are edited
Staff entries are listed under Admin → Core → Staff. Use Add moderators/admins to create an entry for a role or a specific user (searchable by name or email); after creating it you land straight on its permission editor. The edit (pencil) action on a row reopens that editor, where you first pick an access level:
- Unrestricted - the entry is granted every permission for its staff type,
including ones added later.
resolveStaffPermissionsreturnsroot: truefor it, so all checks pass. - Restricted - choose exactly which permissions apply, shown as one tab per plugin (with "Enable all" / "Disable all" per tab). The admin set is shown for administrator entries, the moderator set for moderator entries. Permissions that depend on another stay hidden until their gate is enabled.
The staff list also shows an Unrestricted / Restricted badge per entry.
Backend
Use the helpers from @vitnode/core/api/lib/check-staff-permission inside a route handler. They read the current user from the request context (c.get("user")) and resolve the effective permissions across the user's direct grant and all of their roles (primary + secondary). A user whose role is root always passes.
Guarding a route
The simplest way to gate an admin route is to declare adminStaffPermission on buildRoute. It guards the route with a 403 before the handler runs, so the handler stays focused on its logic. plugin defaults to the route's own pluginId, so you usually only pass module and permission:
import { buildRoute } from "@vitnode/core/api/lib/route";
export const deletePostRoute = buildRoute({
pluginId: "@vitnode/blog",
adminStaffPermission: { module: "posts", permission: "can_delete" },
route: {
method: "delete",
path: "/{id}",
// ...
},
handler: async c => {
// ...delete the post - the permission is already enforced
},
});adminStaffPermission always guards the admin staff type. For moderator
routes, or checks that depend on runtime data, assert manually (below).
Asserting manually
For checks that can't be declared statically - for example an extra permission
required only for some targets - call assertStaffPermission inside the
handler. It throws HTTPException(403) when the permission is missing:
import { assertStaffPermission } from "@vitnode/core/api/lib/check-staff-permission";
await assertStaffPermission(c, {
type: "admin",
plugin: "@vitnode/blog",
module: "posts",
permission: "can_delete",
});Checking without throwing
checkStaffPermission returns a boolean instead:
import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission";
const canEdit = await checkStaffPermission(c, {
type: "moderator",
plugin: "@vitnode/blog",
module: "posts",
permission: "can_edit",
});For the full set (e.g. to return it from an endpoint), use resolveStaffPermissions(c, { type, user }), which returns { root, permissions }.
Frontend
Admin panel (server components)
getSessionAdminApi() returns the admin's permissions set. Combine it with the pure hasStaffPermission helper:
import { getSessionAdminApi } from "@vitnode/core/lib/api/get-session-admin-api";
import { hasStaffPermission } from "@vitnode/core/api/lib/staff-permission";
export default async function Page() {
const session = await getSessionAdminApi();
if (!session) return null;
const canView = hasStaffPermission(session.permissions, {
plugin: "@vitnode/blog",
module: "categories",
permission: "can_view",
});
return canView ? <Categories /> : null;
}Or use the checkAdminPermissionApi shortcut, which resolves the session and
checks a single permission in one call (plugin defaults to @vitnode/core):
import { checkAdminPermissionApi } from "@vitnode/core/lib/api/get-session-admin-api";
const canView = await checkAdminPermissionApi({
plugin: "@vitnode/blog",
module: "categories",
permission: "can_view",
});Admin panel (client components)
The current admin's effective permissions are loaded with the admin session and provided through a context. Gate UI with the useAdminStaffPermission hook or the <AdminStaffPermissionGate> component from @vitnode/core/components/staff-permission/provider:
"use client";
import {
AdminStaffPermissionGate,
useAdminStaffPermission,
} from "@vitnode/core/components/staff-permission/provider";
const DeleteButton = () => {
const canDelete = useAdminStaffPermission({
plugin: "@vitnode/blog",
module: "posts",
permission: "can_delete",
});
if (!canDelete) return null;
return <button>Delete</button>;
};
// or declaratively:
<AdminStaffPermissionGate
plugin="@vitnode/blog"
module="posts"
permission="can_delete"
>
<button>Delete</button>
</AdminStaffPermissionGate>;Admin sidebar nav
Admin nav items can be gated by a permission so the sidebar entry is hidden from
admins who lack it. Add a permission ({ module, permission } - the plugin
is inferred from the declaring plugin) to any nav item in buildPlugin:
import { buildPlugin } from "@vitnode/core/lib/plugin";
import { ListIcon } from "lucide-react";
export const blogPlugin = () =>
buildPlugin({
pluginId: "@vitnode/blog",
admin: {
nav: [
{
id: "categories",
href: "/admin/blog/categories",
icon: <ListIcon />,
permission: { module: "categories", permission: "can_view" },
},
],
},
});The same permission field works on sub-items. A parent group disappears once
all of its children are hidden. Items without a permission are always shown.
Gate the page too
Hiding the nav entry does not protect the route. Guard the page itself (e.g.
notFound() when checkAdminPermissionApi is false) and the underlying
action on the backend with assertStaffPermission.
Public site (moderators)
For moderator actions on the public (non-admin) site, use checkModeratorPermissionApi (or getModeratorPermissionsApi for the whole set) from @vitnode/core/lib/api/get-moderator-permissions-api in a Server Component:
import { checkModeratorPermissionApi } from "@vitnode/core/lib/api/get-moderator-permissions-api";
export const PostActions = async () => {
const canEdit = await checkModeratorPermissionApi({
plugin: "@vitnode/blog",
module: "posts",
permission: "can_edit",
});
return canEdit ? <EditPostButton /> : null;
};Never trust the frontend
Frontend checks only hide UI. Always guard the underlying action on the
backend with assertStaffPermission as well.