Catalyst plugins extend the platform with custom backend routes, frontend UI components, scheduled tasks, WebSocket handlers, and server-side data persistence. This document covers the complete plugin system — from architecture and types to building and deploying your own plugins.
- Architecture Overview
- Plugin Manifest
- Plugin Lifecycle
- Backend API
- Frontend Integration
- Plugin SDK
- Safety Consent & Permission Control
- Marketplace & Packaging
- Plugin Security
- Hot Reload
- Known Limitations
- Example Plugins
- Development Workflow
- Cross-References
┌─────────────────────────────────────────────────────┐
│ Frontend (React) │
│ │
│ ┌──────────────┐ ┌───────────┐ ┌──────────────┐ │
│ │ Admin Tabs │ │ Server │ │ Custom │ │
│ │ (sidebar) │ │ Tabs │ │ Routes │ │
│ └──────┬───────┘ └────┬──────┘ └──────┬───────┘ │
│ │ │ │ │
│ ┌──────▼───────────────▼▼────────────────▼───────┐ │
│ │ PluginProvider + PluginStore │ │
│ │ Zustand state + React hooks (usePlugins, │ │
│ │ usePluginTabs, usePluginRoutes, │ │
│ │ usePluginComponents) │ │
│ └──────────────────────┬────────────────────────┘ │
└─────────────────────────┼───────────────────────────┘
│ fetchPlugins()
│ POST /api/plugins/:name/enable
│ PUT /api/plugins/:name/config
▼
┌─────────────────────────────────────────────────────┐
│ Backend (Fastify) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ PluginLoader (singleton) │ │
│ │ • Discovery (3-pass: manifests, deps, topo) │ │
│ │ • Manifest validation (Zod) │ │
│ │ • Hot reload (chokidar file watcher) │ │
│ │ • Registry (Map<string, LoadedPlugin>) │ │
│ └───────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────────▼─────────────────────────────┐ │
│ │ PluginRegistry + Context │ │
│ │ • ScopedPluginDB (Prisma with field-level │ │
│ │ whitelisting) │ │
│ │ • Event system (EventEmitter) │ │
│ │ • WebSocket gateway integration │ │
│ │ • Task scheduler (node-cron) │ │
│ │ • RPC system (plugin-to-plugin API calls) │ │
│ └───────────────┬─────────────────────────────┘ │
│ │ │
│ ┌───────────────▼─────────────────────────────┐ │
│ │ Plugin Routes │ │
│ │ GET /api/plugins │ │
│ │ GET /api/plugins/:name │ │
│ │ POST /api/plugins/:name/enable │ │
│ │ POST /api/plugins/:name/reload │ │
│ │ PUT /api/plugins/:name/config │ │
│ │ GET /api/plugins/:name/frontend-manifest │ │
│ │ Custom routes registered by each plugin │ │
│ └─────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ catalyst-plugins/ │ │
│ │ • example-plugin/ • ticketing-plugin/ │ │
│ │ • egg-explorer/ │ │
│ └─────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────┘
| Decision | Rationale | Implication |
|---|---|---|
| Plugins share the Node.js process | Simplicity — no need for worker isolation | A crashing plugin can take down the entire server |
Routes registered at onLoad time |
Fastify requires routes before listen() |
Routes are always present but gated by enabledRef |
| Frontend code bundled at build time | ESM cache clearing doesn't work reliably | Plugins must be present at build time; no true runtime loading |
| JSON arrays for plugin storage | Simple key-value storage without separate tables | O(n) queries — not suitable for large datasets |
| Scoped DB with field-level whitelists | Security — plugins shouldn't access apiKeys or credentials tables |
Plugins have very limited write access (only status on servers, roleIds on users) |
Every plugin must have a plugin.json file at its root. This is the single source of truth validated by Zod.
{
"name": "my-plugin",
"version": "1.0.0",
"displayName": "My Plugin",
"description": "A plugin that does something useful",
"author": "Developer Name",
"catalystVersion": ">=1.0.0",
"permissions": [
"server.read",
"admin.read"
],
"backend": {
"entry": "backend/index.js"
},
"frontend": {
"entry": "frontend/index.ts"
},
"dependencies": {
"other-plugin": "1.0.0"
},
"config": {
"apiKey": {
"type": "string",
"default": "",
"description": "API key for external service"
}
},
"events": {
"my-event": {
"payload": { "data": "string" },
"description": "Description of this event"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
name |
string |
✅ | Unique identifier. Must match ^[a-z0-9-]+$, 1-50 chars. |
version |
string |
✅ | Semver format ^\d+\.\d+\.\d+$. |
displayName |
string |
✅ | Human-readable name shown in admin UI (max 100 chars). |
description |
string |
✅ | Brief description (max 500 chars). |
author |
string |
✅ | Author name (max 100 chars). |
catalystVersion |
string |
✅ | Minimum compatible Catalyst version. Supports >=, >, =, <, <=. |
permissions |
string[] |
❌ | Permission scopes the plugin requests. See Permission Model. |
permissionDescriptions |
Record<string, string> |
❌ | Reviewer-facing copy per declared scope (max 200 chars/key). Rendered verbatim in the safety-consent dialog and permission reviewers; keys not present in permissions fail validation. Built-in scopes ship panel copy already — use this for custom scopes or to refine wording. |
backend |
object |
❌ | { "entry": "backend/index.js" } — path to backend module. |
frontend |
object |
❌ | { "entry": "frontend/index.ts" } — path to frontend module. |
dependencies |
Record<string, string> |
❌ | Plugin name → version map. Validated at discovery. |
config |
Record<string, any> |
❌ | Free-form config schema. Types inferred by admin UI. |
events |
Record<string, object> |
❌ | Event name → { payload: object, description?: string }. |
The admin UI infers field types from the config schema. Supported types:
| Type | UI Widget | Example |
|---|---|---|
string |
Text input | {"type":"string","default":"hello"} |
number |
Number input | {"type":"number","default":42} |
boolean |
Toggle switch | {"type":"boolean","default":true} |
select |
Dropdown | {"type":"select","options":[{"label":"A","value":"a"},{"label":"B","value":"b"}]} |
text |
Textarea | {"type":"text","default":"long text"} |
password |
Password field (masked) | {"type":"password","default":""} |
Note: The backend config is
Record<string, any>(untyped). The SDK providesdefineConfig()andconfigField()helpers for type-safe config definitions.
Each plugin backend module exports an object with up to four lifecycle hooks:
// backend/index.js
export default {
async onLoad(context) { /* ... */ },
async onEnable(context) { /* ... */ },
async onDisable(context) { /* ... */ },
async onUnload(context) { /* ... */ },
};| Hook | Called When | Use Case | Can Register Routes? | Can Modify DB? |
|---|---|---|---|---|
onLoad |
Plugin is discovered and loaded (before server starts) | Route registration, initial storage setup | ✅ Yes | ✅ Yes |
onEnable |
Admin enables the plugin (or server starts with plugin pre-enabled) | Start cron tasks, register WS handlers, initialize connections | ❌ No | ✅ Yes |
onDisable |
Admin disables the plugin | Stop tasks, close connections, cleanup | ❌ No | ✅ Yes |
onUnload |
Plugin is being removed from the registry | Final cleanup | ❌ No | ✅ Yes |
Plugins have a two-phase loading model:
- Load → Routes are registered with Fastify, but they return
503 Service UnavailablebecauseenabledRef.value = false. - Enable →
enabledRef.value = true, routes begin responding normally.
This means:
- Routes are always present in Fastify's route table (disabled or not).
- Disabling a plugin doesn't unregister routes — it just flips the gate.
- You can install (load) a plugin without enabling it, useful for development and staged rollouts.
The PluginLoader performs a three-pass discovery on startup and via file watching:
- Pass 1 — Read manifests: Scans the plugins directory for
plugin.jsonfiles. Validates each with Zod. Checks version compatibility againstCATALYST_VERSION("1.0.0"). - Pass 2 — Validate dependencies: Ensures all declared dependencies exist and their versions are compatible using semver comparison.
- Pass 3 — Topological sort: Uses Kahn's algorithm with cycle detection. Plugins are loaded in dependency order. Circular dependencies are logged but not fatal — plugins in a cycle still load.
Plugins register routes using context.registerRoute(). Routes support both Express-style and Fastify-style handlers:
// Fastify-style (recommended)
ctx.registerRoute({
method: 'GET',
url: '/hello',
handler: async (request, reply) => {
return { success: true, message: 'Hello!' };
},
});
// Express-style (legacy)
ctx.registerRoute({
method: 'POST',
url: '/echo',
handler: async (request, reply, next) => {
// request, reply, next (express-style)
next();
},
});Route URL paths are scoped under /api/plugins/{plugin-name}/. For example, a route at url: '/hello' becomes GET /api/plugins/my-plugin/hello.
The PluginBackendContext object passed to all lifecycle hooks is a "god object" containing 30+ methods and properties:
| Property | Type | Description |
|---|---|---|
manifest |
PluginManifest |
Plugin metadata and config |
originalConfig |
Record<string, any> |
Immutable snapshot of the original config schema |
db |
ScopedPluginDB |
Database with table-level gating and field whitelists |
logger |
Pino.Logger |
Structured logger |
wsGateway |
WebSocketGateway |
WebSocket gateway (for custom handlers) |
| Method | Signature | Description |
|---|---|---|
registerRoute() |
(options: RouteOptions) => void |
Register an API route |
registerMiddleware() |
(handler, options?) => void |
Register middleware (global or per-route) |
onWebSocketMessage() |
(type: string, handler) => void |
Register WebSocket message handler (prefixed with plugin:{name}:) |
sendWebSocketMessage() |
(target: string, message: any) => void |
Send message to a client (broadcast with * or specific client ID) |
scheduleTask() |
(cron: string, handler) => void |
Register a cron task using node-cron syntax |
on() |
(event: string, handler) => void |
Listen to Catalyst events |
emit() |
(event: string, data: any) => void |
Emit a Catalyst event |
emitTyped() |
(event: string, data: any) => void |
Emit with schema validation (warns, doesn't throw) |
getConfig() |
(key: string) => any |
Get plugin config value |
setConfig() |
(key: string, value: any) => Promise<void> |
Update plugin config value |
getStorage() |
(key: string) => Promise<any> |
Persistent key-value storage |
setStorage() |
(key: string, value: any) => Promise<void> |
Persistent key-value storage |
deleteStorage() |
(key: string) => Promise<void> |
Remove storage key |
collection() |
(name: string) => PluginCollectionAPI |
Create a typed collection (MongoDB-like API) |
getDeclaredEvents() |
() => Record<string, any> |
Get declared event schemas |
exposeApi() |
(name: string, handler) => void |
Expose a plugin-to-plugin RPC API |
callPluginApi() |
(pluginName, apiName, params?) => Promise<any> |
Call another plugin's API |
The ScopedPluginDB provides a narrowed interface to the Prisma client. It's the most sophisticated security feature of the plugin system.
Tables are accessed via getters that throw if permissions aren't granted:
interface ScopedPluginDB {
servers: PrismaServerSelect; // requires server.read / server.write
users: PrismaUserSelect; // requires user.read / user.write
pluginStorage: PrismaPluginStorage;
plugin: PluginModel; // read-only (update blocked at runtime)
collection(name: string): PluginCollectionAPI;
}Blocked tables (throw Error at critical level):
credentialsapiKeysauditLogs
Blocked tables (throw Error at warn level):
node,role,session,invite
Any access to an unknown table falls through to a proxy that throws:
// This throws: "Access to this resource is not allowed"
context.db.$doesNotExist;Even with server.write or user.write permissions, plugins can only modify specific fields:
const SERVER_WRITE_WHITELIST = new Set(['status']);
const USER_WRITE_WHITELIST = new Set(['roleIds']);Collections provide a MongoDB-like document API backed by JSON arrays in the pluginStorage table:
const tickets = context.db.collection('tickets');
// Find all open tickets for a server
const openTickets = await tickets.find({
serverId: 'srv_123',
status: 'open'
});
// Insert a new ticket
const ticket = await tickets.insert({
serverId: 'srv_123',
subject: 'Help!',
body: 'My server is down',
status: 'open',
priority: 'high'
});
// Update with operators
await tickets.update(
{ _id: ticket._id },
{ $set: { status: 'resolved' } }
);
// Count
const count = await tickets.count({ status: 'open' });Supported operators: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, $exists, $regex, $or, $and (match engine)
Update operators: $set, $unset, $inc, $push, $pull
⚠️ Scalability Warning: Collections load the entire JSON array into memory for every query. For collections with thousands of documents, this can cause performance issues. Consider pagination at the application level.
The Plugin SDK provides createTypedCollection<T>() for type-safe collections:
import { createTypedCollection } from '@catalyst/plugin-sdk';
interface Ticket {
_id?: string;
_createdAt?: string;
_updatedAt?: string;
serverId: string;
subject: string;
body: string;
status: 'open' | 'resolved' | 'closed';
}
const tickets = createTypedCollection<Ticket>('tickets', context.db.collection('tickets'));
// Type-safe find
const result = await tickets.find({ serverId: 'srv_123' });
// result: Ticket[]Plugins can exchange events using a shared EventEmitter:
// Listen to Catalyst events
ctx.on('server:started', async (data) => {
ctx.logger.info(`Server ${data.serverId} started`);
});
ctx.on('server:stopped', async (data) => {
ctx.logger.info(`Server ${data.serverId} stopped`);
});
// Emit custom events
ctx.emit('my-plugin:data-ready', { serverId: 'srv_123', count: 42 });
// Type-safe emit (validates against declared schema)
ctx.emitTyped('my-plugin:data-ready', { serverId: 'srv_123', count: 42 });Declared events in the manifest enable schema validation:
{
"events": {
"ticket:created": {
"payload": { "ticketId": "string", "ticketNumber": "string" },
"description": "A new ticket was created"
}
}
}Plugin WebSocket messages are namespaced to prevent collisions:
ctx.onWebSocketMessage('ping', async (data, clientId) => {
// Messages arrive as `plugin:my-plugin:ping`
ctx.sendWebSocketMessage(clientId, {
type: 'pong',
timestamp: new Date().toISOString(),
});
});ctx.sendWebSocketMessage('*', message)— broadcast to all connected clientsctx.sendWebSocketMessage(clientId, message)— send to a specific client- Message types are automatically prefixed:
plugin:{pluginName}:{type}
Tasks use node-cron for scheduling:
// Runs every 5 minutes
ctx.scheduleTask('*/5 * * * *', async () => {
ctx.logger.info('Running scheduled task');
// Do work here
});
⚠️ Ephemeral Tasks: Tasks are registered in memory only. If the server restarts, tasks must be re-registered inonEnable(). There is no task persistence or retry logic.
Plugins can call each other's APIs via the registry:
// Expose an API
ctx.exposeApi('getTickets', async (params) => {
const tickets = await context.db.collection('tickets').find(params);
return tickets;
});
// Call another plugin's API
const result = await ctx.callPluginApi('ticketing-plugin', 'getTickets', {
serverId: 'srv_123',
});Requirements:
- The calling plugin must declare the
plugin.rpcpermission - Calls have a hardcoded 10-second timeout
- No retry or circuit breaker logic
- Errors propagate raw (no wrapping)
Plugins can add tabs to the admin panel sidebar. Each tab is a React component:
// frontend/index.ts
import { AdminTab } from './components';
export const tabs = [
{
id: 'my-plugin-admin',
label: 'My Plugin',
icon: 'Puzzle',
component: AdminTab,
location: 'admin',
order: 100,
requiredPermissions: ['admin.read'],
},
];Tab properties:
| Property | Type | Required | Description |
|---|---|---|---|
id |
string |
✅ | Unique tab identifier |
label |
string |
✅ | Display name in sidebar |
icon |
string |
❌ | Lucide icon name |
component |
React.ComponentType |
✅ | The tab's React component |
location |
'admin' or 'server' |
✅ | Where the tab appears |
order |
number |
❌ | Sort order (lower = first). Default: 50 |
requiredPermissions |
string[] |
❌ | Permissions required to see the tab |
Server tabs appear in server detail pages and receive a serverId prop:
export const tabs = [
{
id: 'my-plugin-server',
label: 'Plugin Panel',
icon: 'Zap',
component: ServerTab,
location: 'server',
order: 100,
requiredPermissions: ['server.read'],
},
];The component receives serverId as a prop:
export function ServerTab({ serverId }: { serverId: string }) {
const [data, setData] = useState<any>(null);
useEffect(() => {
fetch(`/api/plugins/my-plugin/server-data/${serverId}`)
.then(r => r.json())
.then(setData);
}, [serverId]);
return <div>Server {serverId} data: {JSON.stringify(data)}</div>;
}Plugins can register standalone pages accessible via /{plugin-name}:
export const routes = [
{
path: '/my-plugin',
component: UserPage,
requiredPermissions: ['server.read'],
},
];Routes are rendered by PluginRoutePage which matches the route name against the registered routes:
// PluginRoutePage.tsx
export default function PluginRoutePage() {
const { pluginRouteName } = useParams<{ pluginRouteName: string }>();
const routes = usePluginRoutes();
const matched = routes.find((r) => r.path === `/${pluginRouteName}`);
if (!matched) return <Navigate to="/dashboard" replace />;
const Component = matched.component;
return <Component />;
}Plugins can inject React components into designated areas (slots) in the host application:
// frontend/index.ts
export const slots = {
'server.header': HeaderComponent,
'server.footer': FooterComponent,
};Or use imperative registration:
export function registerSlots() {
return [
{ slot: 'server.header', component: HeaderComponent, order: 10 },
{ slot: 'server.footer', component: FooterComponent, order: 90 },
];
}Slot components are sorted by order (lower first) and rendered in designated usePluginSlots(slot) locations throughout the app.
The frontend uses a Zustand store for plugin state:
import { usePluginStore } from '~/plugins/store';
// Access state
const plugins = usePluginStore((state) => state.plugins);
const loading = usePluginStore((state) => state.loading);
const error = usePluginStore((state) => state.error);
// Actions
const setPlugins = usePluginStore((state) => state.setPlugins);
const addPlugin = usePluginStore((state) => state.addPlugin);
const removePlugin = usePluginStore((state) => state.removePlugin);
const updatePluginConfig = usePluginStore((state) => state.updatePluginConfig);
// Selectors
const getPlugin = usePluginStore((state) => state.getPlugin);
const getPluginsByLocation = usePluginStore((state) => state.getPluginsByLocation);
const getEnabledPlugins = usePluginStore((state) => state.getEnabledPlugins);Config updates are optimistic: they call the API first, then update local state on success. There is no rollback on failure.
| Hook | Return Type | Description |
|---|---|---|
usePlugins() |
LoadedPlugin[] |
All loaded plugins |
useEnabledPlugins() |
LoadedPlugin[] |
Enabled plugins only |
usePlugin(name) |
LoadedPlugin | undefined |
Specific plugin by name |
usePluginRoutes() |
PluginRouteConfig[] |
All routes from enabled plugins |
usePluginTabs(location) |
PluginTabConfig[] |
Tabs for 'admin' or 'server' location |
usePluginComponents(slot) |
React.ComponentType[] |
Components for a slot |
usePluginLoading() |
{ loading: boolean, error: string | null } |
Loading state |
usePluginContext() |
PluginContextValue |
Full context (throw if outside Provider) |
All hooks use useMemo() for performance. Components returned from usePluginComponents() are sorted by order.
The Plugin SDK lives in packages/plugin-sdk/ and provides scaffolding, types, and utilities.
# Scaffold a new plugin
cd packages/plugin-sdk
npx @catalyst/plugin-sdk create my-plugin --template fullstack| Template | Description | Use Case |
|---|---|---|
backend-only |
API routes + WebSocket handlers only | Backend integrations, data processing |
fullstack |
Backend + frontend tabs + components | Interactive plugins with UI |
minimal |
Single manifest + entry point | Simple functionality |
From @catalyst/plugin-sdk:
| Export | Type | Description |
|---|---|---|
PluginManifest |
interface | Plugin manifest type |
PluginLifecycle |
interface | Lifecycle hook types |
PluginCollectionAPI |
interface | Raw collection API |
PluginRouteHandler |
type | Route handler signature |
PluginMiddlewareHandler |
type | Middleware handler signature |
PluginWebSocketHandler |
type | WS handler signature |
PluginTaskHandler |
type | Cron task handler signature |
PluginEventHandler |
type | Event handler signature |
import { definePermissions } from '@catalyst/plugin-sdk';
// Produces the `permissions` + `permissionDescriptions` manifest block.
export const manifestPermissions = definePermissions(
'server.read',
{ token: 'leaderboard.write', description: 'Update leaderboard entries for tracked servers' },
);Built-in scopes already carry reviewer copy on the panel; only describe custom scopes (or refine builtin wording when the default is misleading). See Declaring capabilities reviewers understand.
import { defineConfig, configField } from '@catalyst/plugin-sdk/config';
const config = defineConfig({
apiKey: configField({
type: 'string',
default: '',
description: 'API key for external service',
}),
enabled: configField({
type: 'boolean',
default: true,
description: 'Enable the plugin',
}),
maxItems: configField({
type: 'number',
default: 100,
min: 1,
max: 1000,
}),
priority: configField({
type: 'select',
default: 'medium',
options: [
{ label: 'Critical', value: 'critical' },
{ label: 'High', value: 'high' },
{ label: 'Medium', value: 'medium' },
{ label: 'Low', value: 'low' },
],
}),
});
// Convert to Zod schema for validation
import { createConfigSchema } from '@catalyst/plugin-sdk/config';
const zodSchema = createConfigSchema(config);import { defineRoutes } from '@catalyst/plugin-sdk/routes';
const routes = defineRoutes((router) => {
router
.get('/hello', async (req, reply) => ({ message: 'Hello!' }))
.post('/echo', async (req, reply) => ({ echoed: req.body }))
.put('/update', async (req, reply) => ({ updated: true }))
.del('/delete', async (req, reply) => ({ deleted: true }));
});import { createTypedCollection } from '@catalyst/plugin-sdk/storage';
interface Ticket {
_id?: string;
_createdAt?: string;
_updatedAt?: string;
subject: string;
status: 'open' | 'closed';
}
const tickets = createTypedCollection<Ticket>('tickets', collection);
// Type-safe operations
const open = await tickets.find({ status: 'open' });
const created = await tickets.insert({ subject: 'Help', status: 'open' });
const updated = await tickets.update({ _id: created._id }, { $set: { status: 'closed' } });import {
createTestPlugin,
createMockLogger,
createMockContext,
TestPluginHarness,
} from '@catalyst/plugin-sdk/testing';
// Create a mock context with scoped DB, logger, etc.
const mockContext = createMockContext();
// Create a test harness
const harness = createTestPlugin(myPlugin, manifest, config);
const loaded = await harness.load();
// Assert routes were registered
assert(loaded.context.registerRoute.calls.length > 0);
// Test lifecycle
await harness.enable();
await harness.disable();
await harness.unload();Installing (first-enabling) a plugin is a trust decision, and the panel treats it as one. Two mechanisms work together:
- Safety disclaimer — recorded acceptance required before enabling.
- Effective grants — per-plugin, admin-revocable permission control.
Enabling a plugin requires accepting the safety disclaimer whenever any of these is true:
| Trigger | Consent reason |
|---|---|
| No acceptance has ever been recorded | never_accepted |
| The plugin's version changed since acceptance (new code) | plugin_updated |
| The manifest declares permissions not covered by the accepted snapshot | permissions_grew |
The panel's disclaimer wording was bumped (DISCLAIMER_VERSION) |
disclaimer_updated |
Acceptance records who accepted, when, and the exact permission snapshot accepted. Disabling never requires consent. Acceptances are audited in pluginActionAudit (safety.accepted).
Legacy grandfathering: plugins that were enabled before this feature shipped keep running after upgrade; a backfill acceptance with no accepting user (
legacyAcceptance) is recorded at startup and surfaced in the UI as "Review access" so admins can re-confirm or revoke.
- Enabling grants all declared permissions by default — plugins work out of the box.
- Admins can then revoke any declared permission per plugin via Plugins → Details → Permissions (
PUT /api/plugins/:name/permissions). Grant lists are validated subsets of the manifest; extra tokens are rejected. - Checks are live: scoped DB table access, whitelisted writes and plugin-to-plugin RPC re-read the grant list on every call, so revoking
server.writestops status changes mid-flight without a restart or reload. Re-granting restores access immediately. - Grants persist in the
Pluginrow (grantedPermissions) and survive restarts/reloads.
Enabling your plugin shows an admin a consent dialog listing exactly what it can do. Make that list meaningful:
- Declare only what you use. Every declared scope appears as explicit consent wording; unused declarations erode trust and enlarge the review surface.
- Describe custom scopes. The panel ships reviewer copy for built-in scopes (
server.read,plugin.rpc, …). For anything custom, addpermissionDescriptions:
{
"permissions": ["server.read", "leaderboard.write"],
"permissionDescriptions": {
"leaderboard.write": "Create and update leaderboard entries for tracked servers"
}
}Keys must reference declared permissions — mismatches are rejected at discovery time with an actionable error, so typos never reach production silently.
- Gate route handlers on user permissions (
ctx.requirePermission('server.read')) and rely on the scoped db for data gating: the first protects against unprivileged users, the second reflects what the admin granted your plugin — and revocations apply mid-flight.
Mounted routes, scheduled tasks and event listeners remain registered until the plugin is disabled — Fastify cannot unregister routes cleanly and tasks re-register in onEnable(). Data access behind those code paths is gated live, which is the meaningful lever; the disable button remains the complete off-switch.
All require admin.read for lists, admin.write for mutations:
| Endpoint | Purpose |
|---|---|
GET /api/plugins |
List with permission/consent state + capability counts |
GET /api/plugins/:name |
Full details incl. routes/tasks/events/WS/RPC inventory |
POST /api/plugins/:name/enable |
Body { enabled, safety?: { disclaimerVersion } }. Returns 409 SAFETY_CONSENT_REQUIRED when a fresh acceptance is needed |
PUT /api/plugins/:name/permissions |
Body { granted: string[] } ⊆ declared permissions |
Plugins ship as .catpkg.zip packages and install through the panel — no file shuffling, no rebuilds.
A zip archive whose entries sit at the root (or under one wrapper folder, which is flattened automatically). Allowed top-level content only: plugin.json, README.md, LICENSE, backend/, frontend/, assets/. Everything else is rejected at install time; traversal segments (../), absolute paths and archives exceeding size caps (256 MB compressed / 512 MB extracted / 20k entries) are refused before anything is written. The embedded plugin.json is validated with the full manifest schema pre-install.
Build one from your plugin directory with the SDK CLI:
npx @catalyst/plugin-sdk pack # → ./name-version.catpkg.zip + .sha256 sidecarInstall flow (trust model): installing downloads, checksum-verifies against the index-pinned sha256, stages and wires the plugin into the registry — but everything stays inert until an admin accepts the safety disclaimer and enables it. Version-bumped reinstalls re-trigger consent via the normal rules. Uninstall (POST /api/plugins/:name/uninstall, optional purgeData) removes code and optionally stored data.
Configure one or more marketplace indexes together via comma-separated PLUGIN_MARKETPLACE_URLS. The official catalyst-plugins index is always browsed first; custom URLs are fetched together with it and merged into a single listing (the newest semver wins when several sources list the same plugin name). Set PLUGIN_MARKETPLACE_DISABLE_OFFICIAL=true for air-gapped deployments that should browse only custom sources. Any host publishes a catalog by serving this JSON (cached 5 minutes per source; one failing source never blocks the others):
{
"schemaVersion": 1,
"plugins": [
{
"name": "awesome-plugin",
"displayName": "Awesome Plugin",
"description": "What it does",
"author": "someone",
"version": "1.2.0",
"downloadUrl": "https://cdn.example.com/awesome-plugin-1.2.0.catpkg.zip",
"sha256": "…hex digest of the archive…",
"homepage": "https://github.com/…",
"tags": ["discord", "notifications"]
}
]
}Admins browse/install from Plugins → Marketplace. sha256 pinning is strongly recommended for publishers.
Add more marketplaces directly in the panel: open Plugins → Marketplace and use the Marketplaces section to add an index URL with an optional label. Panel-added sources are browsed together with the official index and any PLUGIN_MARKETPLACE_URLS entries, can be toggled on/off, and can be removed again. The official and env-configured sources are shown as read-only.
Historically plugin UI compiled into the panel build — installed plugins couldn't render. The host prefers the build-time copy when one exists because it shares the host React instance, so hooks work. An installed plugin's self-contained bundle at /plugins-assets/<name>/frontend.mjs (cache-busted by version) is the fallback for plugins with no build-time copy (third-party marketplace installs). Note the trade-off: the fallback bundle inlines its own React whose hooks dispatcher is never set by the host renderer, so hook calls in it throw — interactive runtime-only plugin UI requires a panel rebuild to compile in. Contract:
- One ESM file built with Vite/Rollup lib mode, everything bundled inline (including React) — no bare imports, no import maps.
- Exports exactly what a build-time frontend module does:
default FrontendPluginDefinition, or legacyAdminTab/ServerTab/UserPage/slots. - Served authenticated, MIME-correct, same-origin ⇒ cookies flow and plugin API calls work as usual.
// vite.config.js for a plugin's frontend/
export default {
build: { lib: { entry: 'index.ts', formats: ['es'], fileName: () => 'frontend.mjs' }, outDir: 'dist' },
};
// rename dist/frontend.mjs into the package's frontend/ directoryBackend-only packages skip frontend.mjs entirely and just contribute API/cron/event functionality.
Plugins declare required permissions in their manifest. The scoped DB enforces these at runtime via table-level gating.
| Permission | Tables Accessible | Fields Modifiable |
|---|---|---|
server.read |
servers (read only) |
None |
server.write |
servers (read + limited write) |
status only |
user.read |
users (read only) |
None |
user.write |
users (read + limited write) |
roleIds only |
admin.read |
Admin data (read) | None |
admin.write |
Admin data (read + limited write) | None |
plugin.rpc |
Enables plugin-to-plugin API calls | N/A |
Wildcard permissions (server.*) apply to user-level permissions but NOT to plugin permissions (which use exact matching).
The ScopedPluginDBClient enforces security through:
- Getter-based table access — Unknown tables throw
Access denied. - Whitelist-based writes — Only
statuson servers androleIdson users are writable. - Explicitly blocked tables —
credentials,apiKeys,auditLogsare never accessible. - Proxy catch-all — Any unknown property access falls through to a proxy that throws.
⚠️ Security Gap: Theselectspread infindMany/findUniqueallows plugins to override restricted field selections. A plugin withserver.readcan potentially read fields beyond the whitelisted projection.
Backend:
- Each plugin runs in the same Node.js process. A plugin that throws an unhandled exception can crash the entire server.
- Route handlers are wrapped with
enabledRefchecks, but errors within handlers propagate up to Fastify's error handler. - The
onEnable/onDisablelifecycle hooks don't have try/catch wrapping — errors there toggle the enabled ref back but may leave partial state.
Frontend:
- Plugin frontend components share the main React tree. A crash in a plugin component can crash the entire page.
- No error boundaries are placed around dynamically loaded plugin components.
- The
PluginProviderdoes catch errors during frontend loading and reports them viareportSystemError(), but the failed component is not rendered (it returns empty arrays for tabs/routes/components).
Plugin entry paths in the manifest are NOT validated for path traversal at the frontend discovery level. The loader.ts uses import.meta.glob() which is resolved at build time. Backend entry paths are validated via path.resolve() against the canonical plugins directory before loading.
The PluginLoader uses chokidar to watch the plugins directory for file changes:
this.watcher = watch(this.pluginsDir, {
persistent: true,
ignoreInitial: true,
depth: 2,
});When a file changes, the loader:
- Extracts the plugin name from the file path
- Unloads the plugin (calls
onUnload, drops routes/tasks/sockets, removes the staged ESM copy) - Copies the plugin dir to a unique staged path under
.cache/backend - Re-imports the backend module from the staged copy (fresh ESM evaluation, including sibling imports)
- Re-registers routes and handlers
- Re-enables the plugin if it was previously enabled
Marketplace installs/updates use the same path via POST /api/plugins/install
and POST /api/plugins/:name/reload — no panel reboot is needed. The frontend
listens for plugin_updated admin events and swaps frontend.mjs bundles in
place (version query string, timestamp for same-version reloads).
To enable hot reload, ensure the PluginLoader is initialized with hotReload: true.
| Issue | Impact | Workaround |
|---|---|---|
| No true process isolation | A plugin crash can take down the server | Write defensive error handling; runtime: "isolated" is accepted but forced in-process until worker IPC is finished |
| Repo plugin UI is compiled in | Build-time (catalyst-plugins/*) frontends require a panel rebuild to change |
First-party plugins ship in the image; marketplace-only plugins fall back to frontend.mjs, where hook calls throw (isolated React copy) |
| Ecosystem/tooling maturity | Install pipeline & index protocol are new; official catalog is minimal | The official index is browsed by default; add custom PLUGIN_MARKETPLACE_URLS to extend it |
| Collection storage (legacy) not scalable | O(n) queries over JSON arrays | Set "storageEngine": "dedicated" for large collections |
| No row-level security | Plugins with server.read see ALL servers |
Filter results in the plugin; future host helpers may scope by requester |
| Task scheduling is process-local | Tasks lost on server restart | Re-register tasks in onEnable() (host clears on disable) |
| Config schema vs values | Admin UI needs field schemas; runtime needs plain values | Host unwraps schema → values in getConfig; configSchema is the original plugin.json |
| Host auth user id shape | request.user.userId (not .id) |
Use context.getUserId(request) |
| RPC circuit breaker | Repeated failures open a 30s circuit | Keep plugin APIs fast; handle thrown circuit errors |
| Component slots need host mounts | Only wired slots render | Use dashboard-widgets and sidebar-bottom today |
| Memory gate is process heap | One plugin can trip the gate for others | Keep plugins lean; tune memoryLimitMb carefully |
Location: catalyst-plugins/example-plugin/
Demonstrates all plugin capabilities:
Features:
- 3 custom API routes (
/hello,/echo,/stats) - WebSocket message handler (
plugin_example_ping→plugin_example_pong) - Cron task (runs every 5 minutes)
- Event listeners (
server:started,server:stopped) - Express-style middleware
- Frontend admin tab with stats display
- Frontend server tab with echo test
- Persistent storage (
initialized,installDate,taskRunCount,lastTaskRun)
Manifest:
{
"name": "example-plugin",
"version": "1.0.0",
"displayName": "Example Plugin",
"permissions": ["server.read", "server.write", "admin.read", "console.read"],
"config": {
"greeting": { "type": "string", "default": "Hello from Example Plugin!" },
"cronEnabled": { "type": "boolean", "default": true },
"webhookUrl": { "type": "string", "default": "" }
}
}Location: catalyst-plugins/ticketing-plugin/
Support ticketing built on the modern plugin SDK (createFrontendPlugin, collection storage, host auth).
Features:
- Full CRUD for tickets, comments, tags, templates
- Activity logging + status transition validation
- SLA response/resolution deadlines with auto-escalation and auto-close
- Bulk operations and CSV/JSON export
- WebSocket real-time updates (9 declared events)
- Admin tab, per-server tab, and
/ticketing-pluginuser page
Manifest:
{
"name": "ticketing-plugin",
"version": "3.0.0",
"permissions": ["server.read", "user.read"],
"config": {
"autoAssignEnabled": { "type": "boolean", "default": false },
"autoCloseDays": { "type": "number", "default": 30 },
"defaultPriority": { "type": "select", "options": ["critical","high","medium","low","minimal"] },
"responseSlaHours": { "type": "number", "default": 4 },
"resolutionSlaHours": { "type": "number", "default": 48 }
}
}Events declared:
ticket:created,ticket:updated,ticket:deleted,ticket:comment-addedticket:status-changed,ticket:assigned,ticket:escalatedticket:sla-breached,ticket:bulk-updated
Layout: modular backend (helpers / routes / jobs) + co-located frontend under catalyst-plugins/ticketing-plugin/frontend/.
Location: catalyst-plugins/egg-explorer/
A data-fetching plugin that integrates with external APIs:
Features:
- GitHub API client with rate-limit handling
- Background indexing with tree SHA caching
- Token-based authentication (config key
ghToken) - Cron-scheduled sync
- Self-throttling based on API rate limits
- Graceful degradation (cached data when API unavailable)
Patterns demonstrated:
- Using
ctx.getStorage()for caching (tree SHA, blob SHAs, egg index) - Module-level state (
let eggIndex = null,let isSyncing = false) - Rate limit monitoring and throttling
catalyst-plugins/my-plugin/
├── plugin.json # Manifest (required)
├── README.md # Documentation (recommended)
├── backend/
│ └── index.js # Lifecycle hooks + route registration
├── frontend/
│ ├── index.ts # Tab/route/slot definitions
│ └── components.tsx # React components
└── package.json # Plugin dependencies (optional)
- Create the manifest — Define name, version, permissions, config, and entry points.
- Implement backend — Register routes in
onLoad, register handlers inonEnable. - Implement frontend — Export tabs, routes, or slot components.
- Test — Enable the plugin in the admin panel and verify routes/handlers.
- Hot reload — File changes are detected automatically with no reboot.
- Place the plugin directory in
catalyst-plugins/(or ensure it's on the plugins search path). - Ensure the manifest is valid (Zod validation on discovery).
- Discover the backend via Marketplace install or
POST /api/plugins/:name/reload(no reboot). - Enable the plugin via admin panel or API:
POST /api/plugins/{name}/enable.
The PluginLoader is initialized with a hardcoded path traversal (../../..) to reach catalyst-plugins from dist/index.js. This assumes:
- The plugin directory is at the monorepo root level
- The backend builds from
catalyst-backend/dist/
⚠️ Fragility: This path is hardcoded. Moving the plugins directory requires updating the path incatalyst-backend/src/index.ts.
- Plugin System Guide (this document) — Internal architecture deep dive
- Plugin SDK README — Identified gaps and recommended improvements
- Development Guide — plugins — Detailed improvement plan
- API Reference — Plugin management endpoints (
/api/plugins/*) - Architecture Overview — System design and component relationships
- Security Policy — Security model for the entire platform