Last Updated: October 2026
This document describes every stage a plugin passes through — from initial upload to deletion — including the error resilience mechanisms that protect the engine.
Lifecycle Overview
┌─────────────┐
│ .tgz file │
└──────┬──────┘
│
▼
┌─────────────────┐
│ 1. INSTALL │ Validate → Extract → npm install → DB record
└────────┬────────┘
│ isActive: false
▼
┌─────────────────┐
│ 2. ACTIVATE │ Version gate → register() → DB update → WS broadcast
└────────┬────────┘
│ isActive: true
┌───────────┼────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────┐ ┌──────────────────┐
│ 3. DEACTIVATE│ │4. UPGRADE│ │ 5. AUTO-DISABLE │
│ (manual) │ │ │ │ (circuit breaker)│
└──────┬───────┘ └────┬─────┘ └────────┬─────────┘
│ │ │
│ │ re-activates │ isActive: false
▼ ▼ │ lastError set
┌──────────────┐ ┌──────────────┐ ▼
│ isActive: │ │ isActive: │ ┌──────────────┐
│ false │ │ true (new v) │ │ 6. DELETE │
└──────┬───────┘ └──────────────┘ │ (optional) │
│ └──────────────┘
▼
┌──────────────┐
│ 6. DELETE │
│ (optional) │
└──────────────┘
1. Install
Endpoint: POST /api/v1/admin/plugins/upload (super-admin only, multipart form-data)
Steps
- The uploaded archive is validated before extraction:
- Extension must be
.tgzor.tar.gz - Archive entries are scanned before extraction: path-traversal patterns (
../, absolute paths) and entries other than regular files and directories are rejected - At most 5,000 files, 20 directory levels and 50 MB uncompressed
- Extension must be
- The archive is extracted to a temporary directory, and the extracted tree is scanned again. Symlinks, hard links and special files are rejected.
- Package validation:
package.jsonmust exist and havenameandversionpackage.jsonmust contain apokerEnginePluginfield with validproviderTypeanddisplayNameproviderTypemust match^[a-z][a-z0-9_-]*$providerTypemust be unique — a plugin with the same type must not already be installed
- The files are copied to
plugins/{providerType}/. npm install --omit=dev --ignore-scriptsis run inside that directory (60-second timeout). A failure is logged and installation continues.- A
Pluginrecord is created in the database withisActive: false.
Result: Plugin is installed but not running. No code is imported. The admin UI lists the plugin as inactive.
Error States
| Error | Result |
|---|---|
| Path-traversal or unsafe entry detected | Archive rejected before extraction |
| Size, file-count or depth limit exceeded | Temporary files cleaned up, rejected |
Invalid package.json | Temporary files cleaned up, rejected |
Duplicate providerType | Temporary files cleaned up, rejected |
npm install fails | Logged as a warning; installation continues |
2. Activate
Endpoint: POST /api/v1/admin/plugins/:id/activate (super-admin only)
Steps
- Version gate — if set,
apiVersionmust fall within1.0.0–1.999.999. IfminEngineVersionormaxEngineVersionare set, the running engine version is checked. - The plugin’s server entry (
dist/server/index.js) is dynamically imported with a cache-busting query parameter (?v={timestamp}). register(registry)is called with a 10-second timeout. If the timeout expires, activation rolls back.process.exit()is blocked while the module loads and whileregister()runs. The call is logged and ignored, and activation continues.- A
PaymentSystemrow is created for each registered provider type that has none. - The plugin’s own
server:routes:*andserver:readycallbacks are replayed with the arguments recorded at startup, so these hooks work for plugins activated at runtime.server:bootis not replayed. If a replayed callback throws, activation rolls back. - The
Pluginrecord is updated:isActive: true,lastError: null. - A
plugin:activatedserver action fires. - A
plugin:activatedWebSocket event is broadcast to all connected clients with{ providerType, bundleUrl }. - Connected clients receive the WebSocket event and dynamically inject the frontend bundle (
dist/client/index.js). - If
hasHealthCheck: true,healthCheck(registry)runs in the background. An unhealthy result is recorded but does not block activation.
Result: Plugin is running. Routes respond, hooks are registered, frontend components are rendered.
Rollback on Failure
If register() throws or times out, or a replayed lifecycle callback throws:
- All hooks registered so far are removed
- All routes registered so far are unmounted
- All providers registered so far are unregistered
- The
Pluginrecord is updated:isActive: false,lastError: <error message> - No WebSocket event is broadcast
3. Deactivate
Endpoint: POST /api/v1/admin/plugins/:id/deactivate (super-admin only)
Steps
- If any
PaymentSystemwhosetypeequals the plugin’sproviderTypeis active for deposits or withdrawals, deactivation is blocked with a 409 error. Disable the payment system first. - A
plugin:deactivatingserver action fires. This is the plugin’s last chance to clean up, because its own callbacks are removed next. - All registered hooks (actions and filters) are removed from the server hook registry.
- All registered routes are unmounted from the plugin router dispatcher.
- All registered providers are unregistered from the provider registry.
- The
Pluginrecord is updated:isActive: false. - A
plugin:deactivatedserver action fires. The deactivated plugin’s own callbacks no longer receive it. - A
plugin:deactivatedWebSocket event is broadcast to all connected clients. - Connected clients unregister all slot fills, pages, and client hooks registered by this
providerType.
Result: Plugin is stopped. Routes return 404, hooks no longer fire, frontend components are removed — without a page reload.
4. Upgrade
Endpoint: POST /api/v1/admin/plugins/:id/upgrade (super-admin only, multipart form-data)
An atomic swap that replaces the plugin files while preserving settings and the database record.
Steps
- The new archive is validated identically to the install flow, except that the
providerTypemust match the installed plugin’s. - The files are copied to
plugins/{providerType}-next. npm install --omit=dev --ignore-scriptsis run for the new version. A failure is logged and the upgrade continues.- If the plugin was active, its hooks, routes and providers are removed. The database record stays active.
- Atomic swap:
plugins/{providerType}→plugins/{providerType}-bak(old version backed up)plugins/{providerType}-next→plugins/{providerType}(new version moved into place)
- The new version is imported and
register()runs with the new code. - The
Pluginrecord is updated with the new version and manifest fields.isActiveand settings are preserved. plugins/{providerType}-bakis deleted.- A
plugin:updatedWebSocket event is broadcast to connected clients.
Upgrade does not fire the plugin:activated server action, replay server:routes:* or server:ready, or attach the new version’s healthCheck export. Restart the server after upgrading a plugin that relies on those.
Rollback on Failure
If the swap or the new version’s register() fails:
plugins/{providerType}-bakis moved back toplugins/{providerType}(old version restored)- If the plugin was active before the upgrade attempt, the old version’s
register()runs again - The
Pluginrecord retains the oldversionandisActivestate
Result: Either the new version is running, or the old version is fully restored. The file system is never left in a partially-upgraded state.
5. Auto-Disable (Circuit Breaker)
The circuit breaker automatically disables a plugin that is generating too many errors.
Trigger Conditions
| Source | Error Count |
|---|---|
| Hook callback throws | +1 per error |
Health check returns healthy: false | +10 |
| Unhandled promise rejection attributed to plugin | +10 |
| Uncaught exception attributed to plugin | +10 |
Threshold: 50 errors within a 5-minute window. The count resets once the window is more than 5 minutes old.
Attribution
Process-level errors (unhandledRejection, uncaughtException) are attributed to a plugin by inspecting the stack trace for the pattern plugins/{directory}/. If a loaded plugin’s providerType matches that directory name, the error is counted against it.
On Threshold Exceeded
- All plugin hooks are removed.
- All plugin routes are unmounted.
- All plugin providers are unregistered.
Pluginrecord updated:isActive: false,lastError: 'Auto-disabled: exceeded 50 errors in 5 minutes'.plugin:auto-disabledserver action fires. The disabled plugin’s own callbacks no longer receive it.plugin:auto-disabledWebSocket event broadcast to clients.
Result: Plugin is disabled. Manual re-activation via the admin panel is required. Investigate the lastError field and server logs before re-activating.
6. Delete
Endpoint: DELETE /api/v1/admin/plugins/:id (super-admin only)
The plugin must be inactive before it can be deleted.
Steps
- The plugin’s install directory is removed from the filesystem.
- The
Plugindatabase record is deleted. - All
PluginSettingrecords for thisproviderTypeare cascade-deleted.
Result: Plugin is fully removed. Settings cannot be recovered. Re-uploading the same providerType will start with an empty settings store.
Startup Loading
On server start, loadPlugins() loads plugins from three sources in order:
PAYMENT_PLUGINSenvironment variable — Comma-separated package names. These are loaded fromnode_modules(not theplugins/directory) and are useful for bundling a payment provider with the deployment. The package name is used as theproviderType, and version gates are not applied. Skipped if a matchingPluginrecord is inactive.plugins/directory — Auto-discovered directories containing apackage.jsonwithpokerEnginePlugin.providerTypeand an existing entry file. Skipped if thePluginrecord is inactive. On success, aPluginrecord is created or updated withisActive: true.- Database records —
Pluginrows whereisActive: truethat were not already loaded by the earlier steps.
A plugin already loaded by an earlier step is not loaded again.
If a plugin fails to load at startup:
- Its partial registrations are rolled back
- If it was loaded from a database record,
isActiveis set tofalseandlastErroris set toStartup load failed: <reason> - The engine continues starting — a failed plugin does not prevent the server from starting
Safe Mode
Set SAFE_MODE=true in the environment to skip all plugin loading at startup:
SAFE_MODE=true node dist/index.js
Useful when a plugin is causing the server to fail to start. Once the server is running, the problematic plugin can be deactivated or deleted via the admin panel, and SAFE_MODE removed. GET /api/v1/admin/plugins returns safeMode so the admin panel can show this state.
WebSocket Events
| Event | Direction | Payload | When |
|---|---|---|---|
plugin:activated | Server → Client | { providerType: string, bundleUrl?: string } | After activation |
plugin:deactivated | Server → Client | { providerType: string } | After deactivation |
plugin:updated | Server → Client | { providerType: string, bundleUrl?: string } | After upgrade |
plugin:auto-disabled | Server → Client | { providerType: string, reason: string } | Circuit breaker triggered |
The bundleUrl field is set when hasFrontend: true and contains the URL of the client bundle to inject (e.g., /plugins/{providerType}/dist/client/index.js).
Clients subscribe via usePluginSync(), which is mounted in the root App component.
Database Models
Plugin
id UUID (PK)
name string (unique) — from pokerEnginePlugin.displayName
packageName string — from package.json "name"
version string — from package.json "version"
providerType string (unique) — from pokerEnginePlugin.providerType
description string?
installPath string — absolute path to the server entry file
configFields JSON — from pokerEnginePlugin.configFields
hasFrontend boolean
hasHealthCheck boolean
frontendBundlePath string?
isActive boolean — false after install, true after activate
lastError string? — set on failure or circuit breaker trigger
lastHealthCheck DateTime?
healthStatus string? — "healthy" | "unhealthy"
installedAt DateTime
updatedAt DateTime
installedBy string — userId of the admin who installed, or "system" for auto-discovered plugins
PluginSetting
id UUID (PK)
providerType string — FK to Plugin.providerType (cascade delete)
key string
value string — AES-encrypted for password fields
updatedAt DateTime
UNIQUE(providerType, key)
Error Reference
| Error | Admin Panel Message | Resolution |
|---|---|---|
| Archive contains path traversal, links or special files | Upload rejected | Rebuild archive with regular files and safe paths |
| Extracted size > 50 MB | Upload rejected | Reduce bundle size; exclude node_modules |
| More than 5,000 files or 20 directory levels | Upload rejected | Reduce the package contents |
Invalid package.json | Upload rejected | Add valid pokerEnginePlugin field |
Duplicate providerType | Upload rejected | Delete existing plugin first, or upgrade |
npm install failed | None (logged as a warning) | Check that dependencies resolve correctly |
apiVersion unsupported | Activation blocked | Update plugin to use apiVersion: "1.0.0" |
| Engine version mismatch | Activation blocked | Update minEngineVersion/maxEngineVersion |
register() timeout | Activation rolled back | Reduce work done in register(); defer to actions |
register() threw | Activation rolled back | Check lastError field and server logs |
Replayed server:routes:* or server:ready callback threw | Activation rolled back | Check lastError field and server logs |
| Active payment system exists | Deactivation blocked | Disable the payment system before deactivating |
| Plugin is active | Delete blocked | Deactivate first |
| Circuit breaker triggered | Auto-disabled | Check lastError, fix plugin, re-activate |
Related
| Resource | Link |
|---|---|
| Plugin Development Guide | developer/plugins |
| Server Hooks Reference | developer/plugins-hooks-server |
| Admin: Plugins | admin/plugins |
| Plugin manager source | src/core/plugins/plugin-manager.service.ts |
| Circuit breaker source | src/core/plugins/circuit-breaker.ts |
| Plugin loader source | src/core/plugins/plugin-loader.ts |