Last Updated: October 2026
Plugins are self-contained Node.js packages that extend the poker engine at runtime — without modifying core code or restarting the server. A plugin can register API routes, hook into server events, inject UI components into named slots, add navigation pages, and store encrypted settings through the admin panel.
What a Plugin Can Do
| Capability | Description |
|---|---|
| API routes | Register Express route handlers at /api/v1/plugins/{providerType}/... |
| Server hooks | Listen to and modify server lifecycle events via actions and filters |
| Settings | Store key/value pairs in the database; password fields are AES-encrypted at rest |
| Payment provider | Register a custom payment provider visible in the cashier |
| User provisioning | Create or look up users via the internal user API |
| UI slot fills | Inject React components into named slots in the admin and player interfaces |
| Custom pages | Register full admin or player pages with automatic navigation injection |
| Client hooks | Register browser-side filters and actions for UI customisation |
| Health checks | Expose a health function polled by the engine every 5 minutes |
Manifest Format
Every plugin must have a package.json with a top-level pokerEnginePlugin field.
{
"name": "my-plugin",
"version": "1.0.0",
"main": "dist/server/index.js",
"pokerEnginePlugin": {
"providerType": "my-plugin",
"displayName": "My Plugin",
"description": "A brief description shown in the admin panel.",
"apiVersion": "1.0.0",
"minEngineVersion": "0.0.15",
"maxEngineVersion": "0.999.999",
"hasFrontend": true,
"hasHealthCheck": true,
"configFields": [
{ "key": "apiKey", "label": "API Key", "type": "text", "required": true },
{ "key": "apiSecret", "label": "API Secret", "type": "password" },
{ "key": "maxRetries", "label": "Max Retries", "type": "number" },
{ "key": "publicLabel", "label": "Public Label", "type": "text", "clientVisible": true },
{ "key": "enabled", "label": "Enabled", "type": "toggle" },
{
"key": "environment",
"label": "Environment",
"type": "select",
"options": [
{ "value": "sandbox", "label": "Sandbox" },
{ "value": "production", "label": "Production" }
]
},
{ "key": "notes", "label": "Notes", "type": "textarea" }
]
}
}
pokerEnginePlugin Fields
| Field | Type | Required | Description |
|---|---|---|---|
providerType | string | ✓ | Unique slug. Must match ^[a-z][a-z0-9_-]*$ (lowercase letters, digits, hyphens, underscores; must start with a letter) and stay the same across upgrades. |
displayName | string | ✓ | Human-readable name shown in the admin panel. |
description | string | Brief description shown in the plugin list. | |
apiVersion | string | Plugin API version. Short forms such as 1.0 are accepted. Must fall within 1.0.0–1.999.999. If omitted, the check is skipped. | |
minEngineVersion | string | Minimum engine version required (e.g., 0.0.15). Activation is blocked if the engine is older. If omitted, there is no lower bound. | |
maxEngineVersion | string | Maximum engine version supported. Activation is blocked if the engine is newer. If omitted, there is no upper bound. | |
hasFrontend | boolean | Set to true if dist/client/index.js exists and should be injected. | |
hasHealthCheck | boolean | Set to true if the server entry exports a healthCheck() function. | |
configFields | array | Form fields rendered in the plugin settings UI. See below. |
Plugins uploaded through the admin panel always load dist/server/index.js. Plugins discovered in the plugins/ directory load the package’s main file, falling back to dist/server/index.js.
configFields Types
| Type | Description | Extra Fields |
|---|---|---|
text | Single-line text input | — |
password | Password input — AES-encrypted at rest, masked in the UI | — |
number | Numeric input | — |
toggle | Boolean on/off switch | — |
select | Dropdown select | options: [{ value: string, label: string }][] |
textarea | Multi-line text input | — |
All field types accept these properties:
| Property | Type | Required | Description |
|---|---|---|---|
key | string | ✓ | Setting key used to read and write the value |
label | string | Label shown in the admin settings form. Falls back to key in validation messages. | |
type | string | ✓ | One of the types in the table above |
required | boolean | If true, a validation error is shown when the field is blank | |
clientVisible | boolean | If true, non-admin plugin pages receive this setting through /api/v1/config/plugin-settings/{providerType}. Use only for non-secret fields; password fields are never exposed. | |
description | string | Hint shown below the field | |
placeholder | string | Input placeholder | |
options | array | select only | Array of { value, label } objects. Plain strings are also accepted. |
Server Entry
The server entry (dist/server/index.js) must export a register function. Optionally it may also export healthCheck.
// dist/server/index.js
export function register(registry) {
registry.logger.info('My plugin registering');
registry.registerRoute('GET', '/status', async (req, res) => {
res.json({ ok: true });
});
registry.addAction('plugin:settings:saved', (providerType, settings) => {
if (providerType !== registry.providerType) return;
registry.logger.info('Settings updated', settings);
});
}
export async function healthCheck(registry) {
const apiKey = await registry.settings.get('apiKey');
if (!apiKey) {
return { healthy: false, message: 'API key is not configured' };
}
return { healthy: true, message: 'Connected' };
}
register(registry)
- Called once at activation time.
- Timeout: 10 seconds. If it does not return within 10 seconds, activation rolls back and the plugin is left inactive.
process.exit()is blocked while the module loads and whileregister()runs. The call is logged and ignored; it does not throw or stop activation.- All side effects (routes, hooks) are tracked and automatically cleaned up on deactivation.
healthCheck(registry)
- Called in the background after activation and after each settings save when
hasHealthCheckistrue, and every 5 minutes for any loaded plugin that exportshealthCheck. - Timeout: 5 seconds. A timeout or thrown error counts as unhealthy.
- Returns a
HealthCheckResult:
{
healthy: boolean;
message?: string; // Displayed in the admin panel
details?: unknown; // Optional structured data
}
An unhealthy result adds 10 to the circuit breaker count. At 50 errors within a 5-minute window the plugin is auto-disabled.
PluginRegistry API
The registry object passed to register() exposes the following namespaced API.
Hooks
// Register an action (fire-and-forget — no return value)
registry.addAction('plugin:settings:saved', (providerType, settings) => {
// Called after this plugin's settings are saved
}, priority);
// Register a filter (must return the transformed value)
registry.addFilter('plugin:settings:validate', (result, settings, providerType) => {
if (providerType !== registry.providerType) return result;
if (!settings.apiKey?.startsWith('sk_')) {
result.errors.push('API key must start with "sk_"');
}
return result;
}, priority);
priorityis optional (default: 10). Lower numbers run first.- Filters must return the (possibly modified) value.
- All hooks are automatically removed when the plugin is deactivated.
Filters are synchronous: All built-in filter hooks, including
plugin:settings:validate, run on the synchronous path, so do not return a Promise from these callbacks. If a filter callback returns a Promise, the engine logs a warning and the Promise object itself (not the resolved value) becomes the next callback’s input.
registry.doAction(hook, ...args) — Plugin-to-Plugin Communication
Plugins can fire their own namespaced hooks for inter-plugin communication:
// Fire a custom hook (other plugins can listen with addAction)
await registry.doAction('plugin:my-plugin:order-completed', { orderId: '123' });
doAction() is restricted to hooks prefixed with plugin:{providerType}: — a plugin cannot fire hooks outside its own namespace. Attempts to fire e.g. plugin:settings:saved are blocked: the engine logs a warning and no callbacks run.
See Server Hooks Reference for the full list of available hooks.
Routes
// GET is public by default: /api/v1/plugins/{providerType}/status
registry.registerRoute('GET', '/status', async (req, res) => {
res.json({ status: 'ok' });
});
// POST, PUT, PATCH and DELETE require authentication and CSRF by default
registry.registerRoute('POST', '/action', async (req, res) => {
res.json({ userId: req.user.sub });
});
// Set the access policy explicitly as the first argument after the path
registry.registerRoute('GET', '/me', { access: 'authenticated' }, async (req, res) => {
res.json({ userId: req.user.sub });
});
registry.registerRoute('POST', '/webhook', { access: 'webhook' }, async (req, res) => {
// No engine auth or CSRF. Verify the sender's signature yourself.
res.json({ received: true });
});
Routes mount at /api/v1/plugins/{providerType}{path}. Supported methods are GET, POST, PUT, PATCH and DELETE, and each route needs at least one handler. Standard Express middleware is supported.
access | Added by the engine | Default for |
|---|---|---|
public | Nothing | GET |
authenticated | authenticate, plus csrfProtection on POST, PUT, PATCH, DELETE | POST, PUT, PATCH, DELETE |
webhook | Nothing | — |
Settings
// Read a single setting (returns string | null)
const apiKey = await registry.settings.get('apiKey');
// Write a single setting
await registry.settings.set('lastSync', new Date().toISOString());
// Read all settings for this plugin (returns Record<string, string>)
const all = await registry.settings.getAll();
// Delete a setting
await registry.settings.delete('tempToken');
Settings are stored in PluginSetting. Password fields are decrypted transparently when read.
Users
// Look up a user by external ID (returns user object or null)
const user = await registry.users.findByExternalId('my-plugin', 'ext-123');
// Create a user with an external mapping (idempotent)
const { user, isNew } = await registry.users.createWithExternalMapping({
partnerId: 'my-plugin',
externalUserId: 'ext-123',
username: 'player1',
email: 'player1@example.com',
// Optional: firstName, lastName, metadata
});
// Generate a one-time login code for SSO (returns the code string)
const code = await registry.users.generateAuthCode(userId, 'my-plugin');
// Link an existing engine user to an external ID
await registry.users.linkExternalMapping(userId, 'my-plugin', 'ext-456');
// Look up an external mapping by engine user ID
const mapping = await registry.users.findExternalMappingByUserId(userId, 'my-plugin');
// Batch variant for several users
const mappings = await registry.users.findExternalMappingsByUserIds(userIds, 'my-plugin');
// Exchange a code from generateAuthCode() for tokens
const { mode, tokens, user: authedUser } = await registry.users.exchangeAuthCode(code, 'my-plugin', req.ip, req.headers['user-agent']);
generateAuthCode(userId, partnerId?, mode?) takes an optional mode of 'cookie' or 'token' (default 'token').
Auth
// Set the engine's auth cookies from tokens your plugin obtained
registry.auth.setAuthCookies(res, tokens);
registry.auth.telegram exposes the engine’s Telegram account helpers: login, link, unlink, status, listAccounts and adminUnlink.
Cashier
// Create the payment system row if it does not exist (returns { id, created })
const { id: paymentSystemId } = await registry.cashier.ensurePaymentSystem({
type: registry.providerType,
name: 'My Provider',
});
// Initiate a deposit
const deposit = await registry.cashier.initiateDeposit({
userId,
paymentSystemId,
amount: 100,
currency: 'USD',
});
// Request a withdrawal. External providers require an idempotencyKey.
const withdrawal = await registry.cashier.requestWithdrawal({
userId,
paymentSystemId,
amount: 50,
currency: 'USD',
destination: 'account-ref',
idempotencyKey: requestId,
});
// List all payment systems
const systems = await registry.cashier.getPaymentSystems();
// Get a specific payment system by its database ID
const system = await registry.cashier.getPaymentSystem(paymentSystemId);
Lower-level helpers for providers that manage their own flow:
| Method | Purpose |
|---|---|
createPendingDeposit(input) | Create a pending deposit transaction; returns { id } |
confirmDeposit(transactionId, externalId?) | Confirm a pending deposit and credit the user |
processPaymentWebhook(input) | Run the engine’s webhook pipeline from a plugin route |
isTransactionProcessed(externalId, paymentSystemId?) | Whether a transaction with this external ID exists |
findPendingDepositByExternalId(externalId, paymentSystemId?) | Find a pending deposit |
findPaymentTransactionById(transactionId) | Read one transaction |
listPaymentTransactions(input) | List a payment system’s transactions (default 500, maximum 1000) |
getUserBalance(userId) | Read the user’s balances |
getOrCreateDepositCode(userId, paymentSystemId, currency) | Per-user deposit code |
lookupUserByDepositCode(code, paymentSystemId) | Resolve a deposit code to a user ID |
createWithdrawalRequest(input) | Create a withdrawal transaction; returns { id } |
approveWithdrawal(transactionId, externalId?) | Settle a withdrawal |
rejectWithdrawal(transactionId, reason?) | Reject a withdrawal |
Config
// Read an allowed engine env var (synchronous, returns string | undefined)
// Allowed: NODE_ENV, PORT, PUBLIC_URL, APP_URL, APP_NAME,
// and any var prefixed with PLUGIN_{PROVIDER_TYPE}_
const env = registry.config.get('NODE_ENV');
const custom = registry.config.get('PLUGIN_MY_PLUGIN_API_URL');
// Blocked keys return undefined and log a warning.
// Read this plugin's PaymentSystem record from the database (if one exists)
const paymentSystem = await registry.config.getPluginConfig(registry.providerType);
// Returns a PaymentSystemConfig object or null — NOT the same as settings.getAll().
Logger
registry.logger.info('Plugin started', { version: '1.0.0' });
registry.logger.warn('Rate limit approaching');
registry.logger.error('API call failed', { error: e.message });
Middleware
// Engine middleware exposed to plugins for use in registerRoute handlers
registry.middleware.authenticate // Validates JWT, populates req.user
registry.middleware.csrfProtection // CSRF token validation
Pass { access: 'public' } and add them yourself when you need custom middleware ahead of them, such as a rate limiter:
registry.registerRoute('POST', '/action',
{ access: 'public' },
myRateLimiter,
registry.middleware.authenticate,
registry.middleware.csrfProtection,
async (req, res) => {
// req.user is populated
res.json({ userId: req.user.sub });
}
);
Payment Provider Registration
registry.registerProvider(registry.providerType, MyPaymentProviderClass);
registry.registerProviderMeta(registry.providerType, {
displayName: 'My Provider',
description: 'Shown in the admin provider list',
currencies: ['USD', 'EUR'],
defaultActiveDeposit: false,
defaultActiveWithdraw: false,
});
registry.registerProviderType(registry.providerType);
After register() completes, the engine creates a PaymentSystem row for each registered type that has none. Deposits and withdrawals default to disabled unless defaultActiveDeposit / defaultActiveWithdraw are set. See Payment Providers for the provider interface.
providerType
registry.providerType // e.g., 'my-plugin'
Frontend Entry
If hasFrontend: true, the engine serves dist/client/ at /plugins/{providerType}/dist/client/. Only that directory is exposed. Connected clients load index.js on activation, and the app loads all active bundles at startup from GET /api/v1/config/plugin-scripts.
The frontend entry must be an IIFE that registers components via window.__pokerEngine:
// dist/client/index.js
(function () {
const engine = window.__pokerEngine;
if (!engine) {
console.warn('[my-plugin] window.__pokerEngine not found — skipping');
return;
}
const { React, fills, pages, hooks } = engine;
// Register a slot fill
fills.register('layout.main.header.end', MyHeaderBadge, {
priority: 10,
providerType: 'my-plugin',
});
// Register a page
pages.register({
path: '/dashboard',
component: MyDashboardPage,
area: 'user',
nav: { label: 'My Dashboard', icon: '⭐', order: 100 },
providerType: 'my-plugin',
});
// Register a client hook
hooks.addFilter('nav:items:user', (items) => {
return items.map((item) =>
item.providerType === 'my-plugin'
? { ...item, badge: 'Beta' }
: item
);
}, { providerType: 'my-plugin', priority: 10 });
})();
window.__pokerEngine
| Property | Type | Description |
|---|---|---|
version | string | Plugin API version (currently '1.0.0') |
React | object | The React library instance shared with the host app |
ReactDOM | object | { createRoot } — ReactDOM rendering API |
fills | SlotFillRegistry | Register UI components into named slots |
pages | PageRegistry | Register full admin/user pages with nav items |
hooks | ClientHookRegistry | Register browser-side filters and actions |
api | object | { baseUrl, buildUrl(endpoint, params?) } for building API URLs |
auth | object | { login(user, expiresIn) } completes a plugin-initiated login |
startup | object | { registerPreRenderHook(hook) } runs work before initial routing; reserved for auth plugins |
The window.__pokerEngine object, api, auth and startup are frozen after initialisation.
Use the React instance from the engine — do not bundle your own React. Bundling a separate React instance causes hook errors and multiple React roots.
Slot Fills
Slot fills inject React components into named slots in the host UI.
fills.register(slotName, Component, options);
Parameters:
| Parameter | Type | Description |
|---|---|---|
slotName | string | Name of the slot to fill |
Component | ReactComponent | Your React component |
options.priority | number | Render order — lower numbers render first (default: 10) |
options.providerType | string | Your plugin’s providerType — required for cleanup |
function MyHeaderBadge() {
const { React } = window.__pokerEngine;
return React.createElement('span', { className: 'badge' }, 'Beta');
}
fills.register('layout.main.header.end', MyHeaderBadge, {
priority: 10,
providerType: 'my-plugin',
});
Each fill is wrapped in an error boundary. A crash in your component will not crash the host application.
See Slot Fills Reference for all available slots and the props each slot passes to fills.
Page Registration
Pages create full-page components accessible from the sidebar navigation.
pages.register({
path: '/reports', // Must start with '/'. Becomes /admin/plugins/{providerType}/page/reports
component: SettingsPage,
area: 'admin', // 'admin' or 'user'
nav: {
label: 'My Plugin Settings',
icon: '⚙',
order: 100, // Sort order (default: 10)
badge: 'New', // Optional badge text
},
permissions: [], // Optional; checked on admin pages only
providerType: 'my-plugin',
});
Page component props (PluginPageProps):
{
providerType: string; // Your plugin's providerType
settings: Record<string, string>; // Only clientVisible settings; empty when signed out
currentUser: { id: string; username: string; role: string } | null;
navigate: (path: string) => void; // Client-side navigation
apiBase: string; // Base URL for this plugin's routes
}
Page routes:
| Area | Route | Use case |
|---|---|---|
admin | /admin/plugins/{providerType}/page/{path} | Admin pages with optional nav and permission checks. permissions entries are admin feature keys; a user needs all of them. |
user | /ext/{providerType}/{path} | Authenticated user pages |
user | /plugins/{providerType}/public/{path} | Public bootstrap/login pages that must load before a PokerEngine session exists |
Client Hooks
Client hooks run in the browser. Plugins can consume host-provided hooks such as nav:items:user, or define their own plugin-specific hooks for inter-plugin coordination.
const { hooks } = window.__pokerEngine;
// Host hook — modify user-facing plugin nav items before render
hooks.addFilter('nav:items:user', (items) => {
return items.filter(item => item.providerType !== 'legacy-plugin');
}, { providerType: 'my-plugin', priority: 10 });
// Plugin-defined hook — other plugins may listen for this event
hooks.addAction('my-plugin:dashboard-opened', (payload) => {
console.log('Dashboard opened', payload);
}, { providerType: 'my-plugin' });
await hooks.doAction('my-plugin:dashboard-opened', { source: 'sidebar' });
All hooks registered with your providerType are automatically removed on deactivation.
Settings Management
Settings are stored in PluginSetting (keyed by providerType + key). The admin UI is automatically generated from configFields in your manifest — no UI code required.
Save Flow
- Built-in validation (required fields, numeric values, select options).
plugin:settings:validatefilter fires for this plugin’s own callbacks only — add custom validation here.- If the error list is not empty, the save is rejected with
VALIDATION_ERROR. - Password fields are AES-encrypted before storage. A password submitted blank or as
********keeps its existing value and is not rewritten. plugin:settings:savedaction fires — reinitialise your client here.- If
hasHealthCheck: true, a health check runs in the background.
Custom Validation
// Arguments: (result, settings, providerType) — note: settings comes before providerType
registry.addFilter('plugin:settings:validate', (result, settings) => {
if (!settings.apiKey?.startsWith('sk_')) {
result.errors.push('API key must start with "sk_"');
}
return result;
});
The save is rejected only when result.errors is not empty, so setting result.valid = false alone has no effect. A preserved password arrives as ********, so skip format checks on that value.
Reacting to Saves
registry.addAction('plugin:settings:saved', (providerType, settings) => {
if (providerType !== registry.providerType) return;
reinitialiseApiClient(settings.apiKey);
});
This action fires for every plugin’s save, so keep the providerType guard. settings holds only the values written by this save: a preserved password is omitted, so read it with registry.settings.get().
Health Checks
export async function healthCheck(registry) {
const settings = await registry.settings.getAll();
if (!settings.apiKey) {
return {
healthy: false,
message: 'API key not configured. Go to Admin → Plugins → Settings.',
};
}
try {
const response = await fetch('https://api.example.com/ping', {
headers: { Authorization: `Bearer ${settings.apiKey}` },
signal: AbortSignal.timeout(3000),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return { healthy: true, message: 'Connected' };
} catch (e) {
return { healthy: false, message: `Cannot reach API: ${e.message}` };
}
}
Results appear in the admin plugin list:
| Indicator | Meaning |
|---|---|
| Grey | No health data yet |
| Green | Last check returned healthy: true |
| Red | Last check returned healthy: false |
Packaging
Distribute your plugin as a .tgz created with npm pack:
# In your plugin root directory
npm pack
# Produces: my-plugin-1.0.0.tgz
Include:
package.json(withpokerEnginePluginfield)dist/server/index.js(compiled server entry)dist/client/index.js(compiled frontend entry, ifhasFrontend: true)- Any runtime assets (templates, locales, etc.)
Exclude (via .npmignore):
src/— TypeScript sourcenode_modules/— the engine runsnpm installafter extraction*.test.*,*.spec.*.envand any secrets
The engine runs npm install --omit=dev --ignore-scripts after extracting the archive (60-second timeout), so all runtime dependencies must be in dependencies (not devDependencies). A failed install is logged and does not stop the plugin from being installed, so check the server logs if the plugin then fails to activate.
Archives are limited to 50 MB uncompressed, 5,000 files and 20 directory levels. Symlinks, hard links and special files are rejected.
Vite Dev Proxy
client/vite.config.ts already includes a /plugins proxy entry, so the frontend dev server forwards plugin bundle requests to the backend:
// client/vite.config.ts
server: {
proxy: {
'/plugins': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
Production builds serve plugin files directly from the backend.
Related Files
| File | Purpose |
|---|---|
src/core/plugins/plugin-loader.ts | Discovers and loads plugins at startup; builds the registry object |
src/core/plugins/plugin-lifecycle.service.ts | Replays route hooks and server:ready for plugins activated at runtime |
src/core/plugins/process-guards.ts | Blocks process.exit() and attributes process-level errors to plugins |
src/core/plugins/plugin-manager.service.ts | Install, activate, deactivate, upgrade, delete |
src/core/plugins/plugin-router-dispatcher.ts | Mounts plugin routes at /api/v1/plugins/... |
src/core/plugins/circuit-breaker.ts | Auto-disables plugins that exceed the error threshold |
src/core/plugins/plugin-health.ts | Periodic and on-demand health checks |
src/core/hooks/hook-registry.ts | Server-side action/filter registry |
client/src/core/slot-fill/ | Slot/Fill component and registry |
client/src/core/pages/ | Page registration and navigation injection |
client/src/core/hooks/ | Client-side hook registry |
client/src/hooks/usePluginSync.ts | WebSocket event → frontend plugin sync |
client/src/main.tsx | window.__pokerEngine global exposure |
plugins/demo-plugin/ | Reference implementation |
prisma/schema.prisma | Plugin and PluginSetting models |