Permissions
Declare a VitNode staff permission with defineStaffPermissions, guard a create endpoint with it and check that a restricted admin is refused until granted.
Staff permissions decide what each AdminCP administrator or moderator may do. A plugin declares its own, the AdminCP lets you grant them, and routes check them on the server. In this lesson you declare Create notes for site-notes and add POST /api/site-notes/admin/notes, which only administrators holding it can call.
Before you begin
- The
site_notestable from Database, withpnpm devrunning. - A second account to restrict. Register one at
/registerin a private window; only the first account became the administrator.
Declare the permission
Create src/staff-permissions.ts. defineStaffPermissions takes the plugin's manifest and the permissions it declares, and returns typed references to them:
import { defineStaffPermissions } from "@vitnode/core/lib/staff-permissions";
import { manifest } from "@/manifest.gen";
export const staffPermissions = defineStaffPermissions(manifest, {
admin: { notes: ["can_create"] },
});admin holds permissions for AdminCP administrators, notes is the permission module, and can_create the permission. Together with the plugin id they form site-notes:notes:can_create. Nobody can grant a permission that is not declared, so only unrestricted administrators would ever pass its check.
Then register the declarations in src/config.api.ts. This is an excerpt; keep the rest of the file:
import { staffPermissions } from "@/staff-permissions";
export const siteNotesApiPlugin = () =>
buildApiPlugin({
manifest,
modules: [helloModule, notesModule],
permissionStaff: staffPermissions,
});Keep the declarations in their own file rather than in config.api.ts. The file imports no server code, so routes and the AdminCP navigation can import it without a circular import.
Moderators work on the site, not in the AdminCP, so their permissions go under moderator. A plugin can declare both, for example letting moderators delete notes they find on the site:
export const staffPermissions = defineStaffPermissions(manifest, {
admin: { notes: ["can_create"] },
moderator: { notes: ["can_delete"] },
});You grant site-notes:notes:can_delete in Staff ā Moderators, and VitNode checks it against the signed-in member. Moderator permissions shows the routes that use it; this lesson continues with can_create only.
Label it
Open src/locales/en.json. Give the plugin a display name as title inside the site-notes object, and add the permission labels at the root, next to that object rather than inside it. This excerpt shows only the new keys:
{
"site-notes": {
"title": "Site Notes"
},
"site-notes:notes": "Notes",
"site-notes:notes:can_create": "Create notes"
}The AdminCP permission editor shows these names: Site Notes in its plugin list, then Notes and Create notes. Without them it shows the raw site-notes and keys.
Guard the create route
Admin endpoints live under an admin module, so their URLs contain /admin/. VitNode requires an AdminCP session for every such URL; adminStaffPermission adds the per-permission check. Create src/api/modules/admin/notes/routes/create.route.ts:
import { z } from "@hono/zod-openapi";
import { buildRoute } from "@/api/builders";
import { site_notes } from "@/database/notes";
import { staffPermissions } from "@/staff-permissions";
import { zodNote } from "../../../notes/notes.schema";
export const createNoteRoute = buildRoute({
adminStaffPermission: staffPermissions.admin.notes.canCreate,
route: {
method: "post",
path: "/",
request: {
body: {
content: {
"application/json": {
schema: z.object({
title: z.string().trim().min(3).max(200),
body: z.string().trim().min(1).max(2000)
})
}
}
}
},
responses: {
201: {
content: { "application/json": { schema: zodNote } },
description: "The created note."
}
}
},
handler: async (c) => {
const values = c.req.valid("json");
const [note] = await c
.get("db")
.insert(site_notes)
.values(values)
.returning({ id: site_notes.id, title: site_notes.title, body: site_notes.body });
return c.json(note, 201);
}
});Each declared permission becomes a camelCase reference under its module: can_create is staffPermissions.admin.notes.canCreate. A typo, or a permission the plugin does not declare, is a compile error. Anyone without the permission gets 403 before the handler runs, and an invalid body gets 400.
Mount the admin module
Create the two modules that put the route at admin/notes:
import { buildModule } from "@/api/builders";
import { createNoteRoute } from "./routes/create.route";
export const adminNotesModule = buildModule({
name: "notes",
routes: [createNoteRoute]
});import { buildModule } from "@/api/builders";
import { adminNotesModule } from "./notes/notes.module";
export const adminModule = buildModule({
name: "admin",
routes: [],
modules: [adminNotesModule]
});Then register adminModule in src/config.api.ts:
import { adminModule } from "./api/modules/admin/admin.module";
modules: [helloModule, notesModule],
modules: [helloModule, notesModule, adminModule], Restrict the second account
In the AdminCP, open Staff ā Administrators (/admin/core/staff/admins) and choose Add Administrator. Pick your second account, keep Restricted and save without ticking anything. That account can now sign in to the AdminCP but holds no permissions.
Check the result
Add a temporary button that calls the route with fetcher. Create src/pages/check-create-note.tsx:
import { Button } from "@vitnode/core/components/ui/button";
import { fetcher } from "@vitnode/core/tanstack/fetcher";
import { useState } from "react";
import { manifest } from "@/manifest.gen";
export const CheckCreateNote = () => {
const [status, setStatus] = useState<number>();
const [isPending, setIsPending] = useState(false);
const onClick = async () => {
setIsPending(true);
try {
const response = await fetcher({
plugin: manifest.id,
method: "post",
module: "admin/notes",
path: "/",
args: {
body: {
title: "Parking lot repaint",
body: "The lot is closed on Saturday morning."
}
}
});
setStatus(response.status);
} finally {
setIsPending(false);
}
};
return (
<div className="flex items-center gap-3">
<Button isLoading={isPending} onClick={onClick}>
Create a test note
</Button>
{status ? <output>{status}</output> : null}
</div>
);
};Render <CheckCreateNote /> above the list in src/pages/notes-page.tsx. The browser sends the AdminCP session cookie from every page, so sign in to the AdminCP as each account, open /site-notes/notes in that browser and press the button:
| Who | Result |
|---|---|
| Signed out of the AdminCP | 403 |
| The restricted account | 403 |
| The first account (unrestricted Administrator) | 201, and the note appears at /site-notes/notes |
Now edit the restricted account in Staff ā Administrators, choose Site Notes in the plugin list, tick Create notes under Notes and save. The same button pressed as that account shows 201. Remove CheckCreateNote when you are done.

The check happens on the server. Hiding a button or a sidebar link only tidies the UI, and the AdminCP page lesson does both on top of this guard.
Depend on another permission
When one permission only makes sense with another, declare the dependency. The editor locks the dependent permission until you tick its prerequisite:
export const staffPermissions = defineStaffPermissions(manifest, {
admin: {
notes: [
"can_view",
{ permission: "can_create", dependsOn: ["can_view"] },
{ permission: "can_delete", dependsOn: ["can_view"] },
],
},
});Each permission you add needs its own label and its own adminStaffPermission on the routes it protects. Permission references covers the naming rule, other plugins' permissions and Content Engine permissions.
Check a permission in code
adminStaffPermission and moderatorStaffPermission cover a whole route. They decide who may call it, not which notes that person may touch: a guard is not resource authorization. For a decision inside a handler, such as letting staff edit any note but members only their own, call checkStaffPermission(c, { type, plugin, module, permission }) from @vitnode/core/api/lib/check-staff-permission; it resolves to true or false. staffPermissionArgs(ref) from @vitnode/core/lib/staff-permissions turns a reference into those plugin, module and permission fields.
In AdminCP components, useAdminPermission({ plugin, module, permission }) from @vitnode/core/tanstack/admin answers the same question for the signed-in administrator, for showing or hiding controls.
Moderator permissions
A moderator acts on the site with their normal member session, so moderator routes live in a public module, not under admin. These examples declare two moderator permissions:
export const staffPermissions = defineStaffPermissions(manifest, {
admin: { notes: ["can_create"] },
moderator: { notes: ["can_delete", "can_view_details"] },
});{
"site-notes:notes:can_delete": "Delete notes",
"site-notes:notes:can_view_details": "See note details"
}Grant them in Staff ā Moderators (/admin/core/staff/moderators). Choose Add Moderator, pick the member and save. The moderator's edit page opens; keep Restricted, choose Site Notes and tick the permissions.
Pick the check by what a missing permission should do:
| You want | Use |
|---|---|
| Refuse the whole route | moderatorStaffPermission on buildRoute |
| Refuse one option of a route | assertStaffPermission in the handler |
| Answer everyone, moderators with more | checkStaffPermission in the handler |
All three check the signed-in member, and a signed-out visitor never passes.
Guard a whole route
moderatorStaffPermission works like adminStaffPermission, but checks the member's site session and takes a reference from staffPermissions.moderator. An admin reference, even one with the same module and name, is a compile error there. Anyone who is not a moderator with Delete notes gets 403 before the handler runs:
import { z } from "@hono/zod-openapi";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
import { site_notes } from "@/database/notes";
import { staffPermissions } from "@/staff-permissions";
export const deleteNoteRoute = buildRoute({
moderatorStaffPermission: staffPermissions.moderator.notes.canDelete,
route: {
method: "delete",
path: "/{id}",
request: {
params: z.object({ id: z.coerce.number().int().positive() })
},
responses: {
204: { description: "The note is gone." },
403: { description: "The member is not a moderator with Delete notes." },
404: { description: "No note has this id." }
}
},
handler: async (c) => {
const { id } = c.req.valid("param");
const [deleted] = await c
.get("db")
.delete(site_notes)
.where(eq(site_notes.id, id))
.returning({ id: site_notes.id });
if (!deleted) return c.body(null, 404);
return c.body(null, 204);
}
});Add it to the routes of notesModule. Call it with fetcher, for example from a Delete button on the notes page:
const response = await fetcher({
plugin: manifest.id,
method: "delete",
module: "notes",
path: "/{id}",
args: { params: { id: note.id } }
});Signed in as the moderator, response.status is 204; for any other member it is 403.
Show moderators more
The public note route answers everyone, and adds when the note was written for moderators with See note details. checkStaffPermission resolves to true or false instead of refusing:
import { z } from "@hono/zod-openapi";
import { checkStaffPermission } from "@vitnode/core/api/lib/check-staff-permission";
import { staffPermissionArgs } from "@vitnode/core/lib/staff-permissions";
import { eq } from "drizzle-orm";
import { buildRoute } from "@/api/builders";
import { site_notes } from "@/database/notes";
import { staffPermissions } from "@/staff-permissions";
import { zodNote } from "../notes.schema";
export const showNoteRoute = buildRoute({
route: {
method: "get",
path: "/{id}",
request: {
params: z.object({ id: z.coerce.number().int().positive() })
},
responses: {
200: {
content: {
"application/json": {
schema: zodNote.extend({ createdAt: z.string().optional() })
}
},
description: "The note. Moderators with See note details also get createdAt."
},
404: { description: "No note has this id." }
}
},
handler: async (c) => {
const { id } = c.req.valid("param");
const [note] = await c.get("db").select().from(site_notes).where(eq(site_notes.id, id));
if (!note) return c.body(null, 404);
const canViewDetails = await checkStaffPermission(c, {
type: "moderator",
...staffPermissionArgs(staffPermissions.moderator.notes.canViewDetails)
});
return c.json(
{
id: note.id,
title: note.title,
body: note.body,
...(canViewDetails ? { createdAt: note.createdAt.toISOString() } : {})
},
200
);
}
});- The extra field is optional in the schema, so the fetcher types it as
string | undefinedand the page shows it only when it is there. staffPermissionArgsspreads the reference'splugin,moduleandpermission, so the check names the same declared permission a route guard would. Passtypeyourself.- Don't cache the moderator's answer for everyone. A response that depends on who asks must not go into a shared cache entry; cache the public part, then add the extras.
Refuse one option
assertStaffPermission takes the same arguments and throws 403 instead of returning false. Use it when a member asks for something only moderators may have, here the details behind ?details=1, while the plain request stays public:
import { assertStaffPermission } from "@vitnode/core/api/lib/check-staff-permission";
// request:
request: {
params: z.object({ id: z.coerce.number().int().positive() }),
query: z.object({ details: z.literal("1").optional() }),
},
// handler, before reading the note:
const { details } = c.req.valid("query");
if (details) {
await assertStaffPermission(c, {
type: "moderator",
...staffPermissionArgs(staffPermissions.moderator.notes.canViewDetails),
});
}Call it before the handler reads or changes anything, so a refused request has no side effects. Add 403 to the route's responses too.
Moderator permissions can also be limited to some resources, such as some forum sections, with scope on the entry. Scoped moderator permissions covers the scope families and the AdminCP picker. moderatorStaffPermission and a check without a resource pass only for grants on all resources; to allow a moderator of one section, pass resource to checkStaffPermission or assertStaffPermission in the handler.
Content Engine content types declare their own permissions (can_view, can_create, can_edit, ...). Declaring the same module in defineStaffPermissions replaces them, so use your own module names for custom permissions. To guard a route with a content type's generated permission, see Content Engine permissions.