Skip to content

Plugin Backend

The backend entry (backend.entry in plugin.json) is an ES module. The panel imports it and calls the hooks on the default export.

export default {
async onLoad(ctx) {},
async onEnable(ctx) {},
async onDisable(ctx) {},
async onUnload(ctx) {},
};
Hook When Register routes?
onLoad The plugin is activated: an admin enabled it, or the panel started with it already enabled and its consent is current. Yes. This is the only hook that may register routes.
onEnable An admin enables it, or the panel starts with it already enabled No. Register tasks, WebSocket handlers, and event listeners here.
onDisable An admin disables it No. The host stops tasks and drops WebSocket handlers. Event listeners stay until unload.
onUnload Plugin removed from the registry (uninstall, or a reload) No. Last chance to release anything you opened yourself. Event listeners come off here.

Nothing executes before an accepted safety disclaimer. Discovery only reads plugin.json: the backend module is not imported and onLoad does not run until an admin enables the plugin. An update that changes the version or the declared permissions drops back to that state, so the new code stays inert until it is accepted again.

Routes stay mounted after disable and return 503 until the next enable. Tasks do not survive a restart; onEnable must register them again.

Every hook receives the same context.

Member What it does
manifest The validated manifest.
originalConfig The config block exactly as declared in plugin.json, before admin values were merged in.
installId Stable, opaque identity for this panel install. See Plugin licensing.
logger Pino logger, child-bound to the plugin name.
wsGateway Scoped WebSocket host. sendToAgent needs console.write or server.write; broadcastToAuthenticated and sendToClient are ungated. Host internals (client and agent maps) are not reachable.
db Scoped database. See Data.
registerRoute(options) Mount a route under /api/plugins/<name>.
registerMiddleware(handler, { scope }) scope is 'global' (default) or 'route'.
requirePermission(...tokens) Returns a preHandler that 403s unless the caller holds a token.
hasPermission(request, ...tokens) Soft check. * on the user satisfies it.
getUserId(request) request.user.userId (falling back to .id), or null.
getConfig(key) / setConfig(key, value) Plugin config values. See Data.
getStorage / setStorage / deleteStorage, collection(name) Plugin-scoped key-value store and document collections. See Data.
scheduleTask(cron, handler) Scheduled work. Host clears it on disable.
on(event, handler) / emit(event, data) Event bus shared by plugins and the host.
emitTyped(event, data) Same as emit, plus a warning when a declared payload field is missing or the event is undeclared. It never rejects the emit.
onWebSocketMessage(type, handler) Handle plugin:<name>:<type>.
sendWebSocketMessage(target, message) target is a client id, or '*' to broadcast.
exposeApi(name, handler) / callPluginApi(plugin, api, params) Plugin-to-plugin calls. Caller needs plugin.rpc.
fileTunnel Present on a normal panel. File operations on a node’s server directory, gated per operation.
auth Present on a normal panel. Sign-in bridge. See Sign-in.
getDeclaredEvents() The manifest events map.
ctx.registerRoute({
method: 'GET',
url: '/hello',
preHandler: ctx.requirePermission('server.read'),
handler: async (request) => {
return { success: true, userId: ctx.getUserId(request) };
},
});

url: '/hello' becomes GET /api/plugins/my-plugin/hello. The SDK builder produces the same objects:

import { defineRoutes } from '@catalyst/plugin-sdk';
const routes = defineRoutes((router) => {
router
.get('/hello', async () => ({ success: true }))
.post('/echo', async (request) => ({ success: true, echoed: request.body }))
.put('/items/:id', async () => ({ success: true }));
});
for (const route of routes) ctx.registerRoute(route);

Auth modes, set on config.auth:

Mode Behavior
required (default) No session or API key → 401. request.user is set.
optional A valid session sets request.user; anonymous callers still reach the handler.
public Session is never required. Use for OAuth redirects and callbacks.

optional and public only stick when the plugin holds a live routes.public grant. Without it the route is downgraded to required (a warning is logged), and revoking the grant re-secures the route on the next request.

config.requiredPermissions makes the host check the caller before your handler runs, so authorization does not depend on your code remembering it:

ctx.registerRoute({
method: 'POST',
url: '/settings',
config: { requiredPermissions: ['admin.write'] },
handler: async (request) => ({ success: true }),
});
  • The caller must hold at least one listed token (OR), evaluated with hasGrant semantics against the caller’s panel permissions — see the permission table.
  • The check runs after authentication and before any plugin hook. A caller who fails it gets 403 with code: "PLUGIN_ROUTE_FORBIDDEN".
  • public routes skip the check entirely. On an optional route an anonymous caller has no permissions, so it fails the check and gets 403.
  • Tokens are trimmed, deduped, shape-validated, and capped at 16. A token that fails the shape check (for example a scoped server.read:srv_1) is dropped and logged as a warning, because a dropped token makes the route less restricted than you asked for.
  • Declaring a token the plugin itself has not been granted is also logged. The route still registers, but it will deny every caller until the grant exists.
  • Omitting the list keeps the default: any authenticated caller passes.

requirePermission checks the user, not the plugin. The scoped database checks the plugin grant. You want both: a route that reads servers should use preHandler: ctx.requirePermission('server.read') and ctx.db.servers, so a normal user cannot call it and a revoked plugin cannot read. A token you declare in the manifest’s userPermissions works here too — an admin grants it to a role, and ctx.requirePermission('tickets.view') then passes for those users. See Data & permissions.

Return { success: true, ... } and use reply.status(code).send(...) for errors. The frontend client in @catalyst/plugin-sdk/frontend treats a body with success as the whole response.

scheduleTask takes a five-field cron expression. The panel does not call this a task in the server-owner sense — a task is a scheduled action on one server. This is in-process scheduled work, and it dies when the plugin is disabled or the process restarts.

async onEnable(ctx) {
if (ctx.getConfig('cronEnabled') !== false) {
ctx.scheduleTask('*/5 * * * *', async () => {
const n = (await ctx.getStorage('runs')) || 0;
await ctx.setStorage('runs', n + 1);
});
}
}

The host emits these from server power actions (start, stop, restart, kill). A node agent reporting a status change on its own does not emit them.

Event When Payload
server:status-changed A power action changes status { serverId, status, action, timestamp }
server:started Status becomes starting — a start or restart was accepted same
server:stopped Status becomes stopping or stopped same

server:started fires on start-accept with status: 'starting' — one event per accepted start or restart. The host also emits it for a running status, but no caller sends that today, so filter on status rather than counting firings. For confirmed-up, wait for the panel’s server_state_update frame or poll GET /api/servers/:id.

Your own events are plain strings. Prefix them with the plugin name (my-plugin:ready) so another plugin can subscribe without colliding. Declare them in the manifest events block.

ctx.onWebSocketMessage('ping', async (data, clientId) => {
if (clientId) {
ctx.sendWebSocketMessage(clientId, { type: 'pong', echo: data });
}
});

The host prefixes the type: clients send and receive plugin:my-plugin:ping. sendWebSocketMessage('*', message) broadcasts. Register handlers in onEnable; disable removes them.

To push a frame into a node’s agent console, use ctx.wsGateway.sendToAgent(nodeId, message). It throws when the grant is gone and re-reads it on every call, so a revocation applies without a reload.

// ticketing-plugin
ctx.exposeApi('getOpenCount', async ({ serverId }) => {
return ctx.collection('tickets').count({ serverId, status: 'open' });
});
// caller — manifest must include "plugin.rpc"
const count = await ctx.callPluginApi('ticketing-plugin', 'getOpenCount', {
serverId,
});

The grant is checked on every call. Calls time out at 10 seconds. After 5 consecutive failures the host opens a circuit for about 30 seconds; one probe is then allowed through. Handle that error instead of retrying in a loop.

ctx.fileTunnel.queueRequest runs a file operation on a server’s directory through the node agent. It is how fastdl-sync copies content between servers on the same node.

const result = await ctx.fileTunnel.queueRequest(
nodeId, // node the server runs on
'list', // see the operation table below
serverUuid, // server UUID, not the panel id
'/cstrike', // path inside the server directory
{}, // operation-specific data
);
if (!result.success) throw new Error(result.error);

Every operation is classified and gated on the plugin’s live grants:

Operation Class Needs
list, download, archive-contents read files.read, files.write, server.read, or server.write
upload, write, create, delete, rename, permissions, compress, decompress, install-url write files.write or server.write
anything else unknown denied

install-url makes the node fetch a URL into the server directory, so it counts as a write. An operation the classifier does not know fails closed — a plugin cannot reach a new agent capability before the host gate learns about it. Grants are re-read on every call, so a revocation applies to the next operation without a reload; the call throws Permission denied: … and the host logs a warning.

download returns bytes on result.body. upload takes those bytes as the last argument. Requests are capped per node and time out (60 seconds, longer for large uploads). The method is absent only when the host did not provide a tunnel — check ctx.fileTunnel before calling it.

The tunnel does not filter by server ownership. A plugin that holds a node id and a server UUID can read that server’s files. Declare files.read / files.write so the consent dialog and the gate agree, and check requirePermission plus ctx.db.servers before you touch a path.

registerMiddleware(handler, { scope }) takes a Fastify-shaped (request, reply, done) handler; pass an error to done(err). Register it from onEnable if it should only run while enabled.

Only the 'global' scope runs: the loader collects scope: 'global' middleware and runs it before the enabled check on every route of the plugin. scope: 'route' is accepted and stored but never applied, so leave it alone.

Every plugin route is wrapped in a gate with three limits:

Limit Value today Effect
Request timeout 30 s The handler is abandoned and the caller gets 504.
Concurrent requests 10 per plugin Further calls get 503 until one finishes.
Heap pressure 1024 MB process-wide Warn-only: the process heap is compared to this threshold and a throttle-logged warning is emitted. It never rejects a request, and it is not per-plugin usage.

These are the built-in defaults and they are what runs: the loader reads config.requestTimeoutMs, config.maxConcurrentRequests, and config.memoryLimitMb from a point where manifest.config already holds runtime values, not the field schemas, so a config entry of the same name does not change them. Treat the numbers above as fixed. The heap threshold is the only one with a working override: the PLUGIN_PROCESS_HEAP_LIMIT_MB environment variable on the backend.

@catalyst/plugin-sdk/testing exports createTestPlugin (load/enable/disable/ unload a plugin against a mock context) and createMockContext (records registerRoute calls, keeps collections in memory with $set, $unset, $inc, $push, $pull and dotted paths). createMockLicense covers the licensing flow. See Create a plugin.