Plugin Data & Permissions
A plugin has three stores, and a narrow window onto panel data. Use the
stores for your own records. Use context.db only to read servers and users
you were granted.
Config
Section titled “Config”getConfig(key) returns the value an admin saved, or the default from
plugin.json. It never returns the schema object.
const greeting = ctx.getConfig('greeting') ?? 'Hello';const enabled = ctx.getConfig('cronEnabled'); // boolean, not { type, default }await ctx.setConfig('greeting', 'Hi');setConfig persists the value on the plugin row. The admin UI keeps rendering
the schema. Keys must match the manifest config map; see
Manifest.
Put secrets in a password field. Do not hard-code them.
Key-value storage
Section titled “Key-value storage”getStorage / setStorage / deleteStorage are a JSON blob per key, scoped
to the plugin. Missing keys return null.
const first = await ctx.getStorage('initialized');if (!first) { await ctx.setStorage('initialized', true); await ctx.setStorage('installDate', new Date().toISOString());}await ctx.deleteStorage('installDate');Fine for flags and small counters. Not fine for a list that grows without a bound — use a collection.
Collections
Section titled “Collections”const tickets = ctx.collection('tickets');
const created = await tickets.insert({ serverId: 'srv_123', subject: 'Server will not start', status: 'open',});
const open = await tickets.find( { serverId: 'srv_123', status: 'open' }, { sort: { createdAt: -1 }, limit: 50, skip: 0 },);
await tickets.update({ _id: created._id }, { $set: { status: 'resolved' } });await tickets.delete({ _id: created._id });const n = await tickets.count({ status: 'open' });insert adds _id, _createdAt, and _updatedAt. Filters support dotted
paths (sla.resolutionBreached) and these operators: $eq, $ne, $gt,
$gte, $lt, $lte, $in, $nin, $exists, $regex, $or, $and.
Updates accept $set, $unset, $inc, $push, and $pull. $set keys
may be dotted.
Typed wrapper:
import { createTypedCollection } from '@catalyst/plugin-sdk/storage';
interface Ticket { _id?: string; serverId: string; subject: string; status: 'open' | 'resolved';}
const tickets = createTypedCollection<Ticket>('tickets', ctx.collection('tickets'));Which engine
Section titled “Which engine”storageEngine |
Behavior | Use when |
|---|---|---|
omitted or "legacy" |
One JSON array per collection, loaded fully for every query | Under about 1,000 documents |
"dedicated" |
One database row per document | Anything you would paginate |
ticketing-plugin
and
discord-oauth
set "storageEngine": "dedicated". The collection API is the same either way.
Pick dedicated unless you know the collection stays small.
There is no row-level security. server.read returns every server on the
panel, not the ones the calling user owns. Filter in your handler after
requirePermission, and do not assume the database did it for you.
Reading panel data
Section titled “Reading panel data”ctx.db.servers requires a live server.read grant. ctx.db.users requires
user.read. Any other table throws. The .write tokens grant no table access
on their own, so a plugin that updates servers declares server.read and
server.write. credentials, API keys, and audit logs are blocked even for
*-adjacent custom tokens; * itself only adds servers and users.
Reads always project a fixed field set. Requesting other fields drops them.
Server fields: id, name, uuid, description, status, createdAt,
updatedAt, nodeId, ownerId, templateId, locationId,
allocatedMemoryMb, allocatedCpuCores, allocatedDiskMb, allocatedSwapMb,
containerId, containerName, networkMode, primaryPort, primaryIp,
environment, startupCommand, suspendedAt, suspensionReason.
User fields: id, username, email, name, image, banned,
createdAt, updatedAt, firstName, lastName, role.
const servers = await ctx.db.servers.findMany({ select: { id: true, name: true, status: true, uuid: true, nodeId: true }, orderBy: { name: 'asc' }, take: 25,});const one = await ctx.db.servers.findUnique({ where: { id } });const count = await ctx.db.servers.count();update signatures are (id, data), not Prisma’s { where, data }.
await ctx.db.servers.update(id, { status: 'suspended' });server.write keeps status and discards every other field. If nothing
whitelisted remains, the call throws. user.write keeps nothing — the
whitelist is empty on purpose, so a plugin cannot write roleIds and grant
itself a role. Role changes go through the auth bridge (roles.assign), which
is audited. See Sign-in plugins.
Grants are re-read on every call. An admin revoking server.read makes the
next findMany throw. You do not need to reload the plugin.
What enabling checks
Section titled “What enabling checks”Enabling (POST /api/plugins/:name/enable) requires a current safety
acceptance when any of these is true:
| Reason | Meaning |
|---|---|
never_accepted |
No acceptance on record |
plugin_updated |
version changed since acceptance |
permissions_grew |
The manifest declares a permission the accepted snapshot does not cover |
disclaimer_updated |
The panel disclaimer text version changed |
The enable body is { "enabled": true, "safety": { "disclaimerVersion": "1" } }.
When consent is missing the panel responds 409 with code: "SAFETY_CONSENT_REQUIRED" and the permission list the dialog renders. The
panel UI sends this; call it yourself only if you are driving enable through
the API.
Disabling never requires consent. Uninstall (POST /api/plugins/:name/uninstall)
accepts { "purgeData": true } to also delete stored keys, collections, and
the plugin row. Omit purgeData to remove the code and leave the data.
Admins can revoke individual declared permissions after enable
(PUT /api/plugins/:name/permissions with { "granted": ["server.read"] }).
The list must be a subset of the manifest. Revocation is the lever for data
access; it does not unload your routes.
Two permission namespaces
Section titled “Two permission namespaces”permissions in plugin.json is the plugin’s own capability list — what an
admin grants to the plugin so it can call host APIs. userPermissions is the
other direction: tokens you invent for panel users, which an admin grants to
a role.
"userPermissions": [ { "id": "tickets.view", "label": "View the ticket queue" }]- The role editor lists them under Plugins, and only while your plugin is enabled. Ids that are also built-in permissions are left to the built-in categories, so use the built-in token directly in that case.
- A role grant reaches your backend routes:
ctx.requirePermission('tickets.view')succeeds for a user holding that token, exactly like a built-in one. PUT /api/plugins/:name/permissionsdoes not touch them. It replaces the plugin’s own grants, and rejects tokens that are not inpermissions.
See Manifest for the declaration rules and Plugin Frontend for gating tabs, pages and slots on the same ids.
Related
Section titled “Related”- Plugin Backend — where
ctx.dband storage are used - Plugin Manifest — config fields, declared permissions, and
userPermissions - API reference — the panel API this data mirrors
