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.
Context
Section titled “Context”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. |
Routes
Section titled “Routes”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.
Caller permissions on a route
Section titled “Caller permissions on a route”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
hasGrantsemantics 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
403withcode: "PLUGIN_ROUTE_FORBIDDEN". publicroutes skip the check entirely. On anoptionalroute an anonymous caller has no permissions, so it fails the check and gets403.- 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); }); }}Events
Section titled “Events”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.
WebSocket
Section titled “WebSocket”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.
Plugin-to-plugin calls
Section titled “Plugin-to-plugin calls”// ticketing-pluginctx.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.
File tunnel
Section titled “File tunnel”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.
Middleware
Section titled “Middleware”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.
Request limits
Section titled “Request limits”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.
Testing without a panel
Section titled “Testing without a panel”@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.
Related
Section titled “Related”- Plugin Manifest — the fields these hooks read
- Plugin Data & Permissions — config, storage, scoped database
- API reference — the panel REST API these routes sit beside
