Skip to content

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.

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.

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.

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'));
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.

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.

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.

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/permissions does not touch them. It replaces the plugin’s own grants, and rejects tokens that are not in permissions.

See Manifest for the declaration rules and Plugin Frontend for gating tabs, pages and slots on the same ids.