Plugin development
Architecture
A GameAP plugin is a WASM module for the wasm32-wasip1 target (WASI preview 1), built as a reactor. The panel runs each plugin in an isolated wazero runtime: no file system, no network, no environment variables; the guest’s stdout and stderr are forwarded to the panel log (see Runtime limits).
The panel and the plugin exchange protobuf messages through WASM linear memory: the panel writes the request into guest memory using the malloc exported by the plugin, calls the function and reads the response. The Plugin API version is 1.
Message types and SDKs are published from the github.com/gameap/gameap-proto repository:
gameap-plugin-sdk(crates.io) — the full Rust SDK: the ABI layer, thePlugintrait, theregister_plugin!macro, typed host-function clients;@gameap/proto-as(npm) — AssemblyScript message types (without the ABI layer);@gameap/proto(npm) — TypeScript types (for tooling; building a WASM plugin in plain JS is not currently possible).
The plugin interface
A plugin implements the PluginService service from plugin.proto. The service methods:
| Method | Purpose |
|---|---|
GetInfo |
Returns the plugin metadata (PluginInfo) |
Initialize |
Initialization when the plugin is loaded |
Shutdown |
Shutdown when the plugin is unloaded |
HandleEvent |
Handling a panel event |
GetSubscribedEvents |
The list of event types the plugin subscribes to |
GetHTTPRoutes |
The list of the plugin’s HTTP routes |
HandleHTTPRequest |
Handling an HTTP request on a plugin route |
GetFrontendBundle |
The embedded frontend: JS bundle and CSS (optional) |
GetServerAbilities |
Registering the plugin’s server permissions (optional) |
GetAssets |
Static files the plugin contributes: translations served at /lang/ and frontend files served from the SPA root (optional) |
In the Rust SDK the interface is represented by the Plugin trait with neutral default implementations — in practice only get_info is mandatory. The register_plugin! macro generates all the required WASM exports, including malloc/free and the API version check.
PluginInfo metadata
| Field | Description |
|---|---|
id |
The plugin’s string identifier (see the requirements below) |
name |
Plugin name |
version |
Version (semver) |
description |
Short description |
author |
Author |
license |
License (for example, MIT) |
homepage |
Link to the plugin page |
required_permissions |
The permissions the plugin needs; installing grants exactly these (see Plugin permissions) |
api_version |
Plugin API version, must be "1" |
Requirements for id. Use a stable identifier made of base32 alphabet characters a-z2-7, without hyphens. The panel normalizes the id: a string with hyphens or other characters is replaced by a hash, which breaks the /api/plugins/{id}/... and /plugins/{id}/... paths. Avoid purely numeric ids as well: such an identifier is treated as a decimal numeric ID (id parsing first tries to parse the string as a number). Examples of valid ids from real plugins: hexeditor4jm2, ezvdsxmlu6fbk, dshdabjp2l73a.
Events
A plugin subscribes to panel events with the GetSubscribedEvents method. The event types (listed without the EVENT_TYPE_ prefix):
| Event | Cancellable | Delivery |
|---|---|---|
SERVER_PRE_START, SERVER_PRE_STOP, SERVER_PRE_RESTART, SERVER_PRE_INSTALL, SERVER_PRE_UPDATE, SERVER_PRE_REINSTALL, SERVER_PRE_DELETE |
Yes | Synchronous |
SERVER_POST_START, SERVER_POST_STOP, SERVER_POST_RESTART, SERVER_POST_INSTALL, SERVER_POST_UPDATE, SERVER_POST_REINSTALL, SERVER_POST_DELETE |
No | Asynchronous |
SERVER_CREATED, SERVER_UPDATED, SERVER_DELETED, SERVER_SETTINGS_CHANGED |
No | Asynchronous |
USER_PRE_DELETE |
Yes | Synchronous |
USER_CREATED, USER_UPDATED, USER_DELETED |
No | Asynchronous |
NODE_PRE_DELETE |
Yes | Synchronous |
NODE_CREATED, NODE_UPDATED, NODE_DELETED, NODE_ONLINE, NODE_OFFLINE |
No | Asynchronous |
DAEMON_TASK_CREATED, DAEMON_TASK_STARTED, DAEMON_TASK_COMPLETED, DAEMON_TASK_FAILED |
No | Asynchronous |
PLUGIN_LOADED, PLUGIN_UNLOADED, PLUGIN_ERROR |
No | Asynchronous |
Pre-events are delivered synchronously and block the operation: a plugin can cancel it by returning an EventResult with should_cancel = true and a message, or modify the data via modified_data. Post-events and the other types are delivered asynchronously and do not affect the operation.
The payload depends on the event type: server_event (ServerEventPayload, a full snapshot of the server) for SERVER_*, server_settings_event (ServerSettingsEventPayload: the server id and the saved settings) for SERVER_SETTINGS_CHANGED, task_event (TaskEventPayload, the daemon task data) for DAEMON_TASK_*, user_event (UserEventPayload) for USER_*, node_event (NodeEventPayload) for NODE_* and plugin_event (PluginEventPayload: the compact plugin id, name, version, status and error) for PLUGIN_*. PLUGIN_* events are never delivered to the plugin they describe.
The event handler timeout is 10 seconds; once it expires the runtime closes the module and the plugin is disabled, then reloaded automatically (see Runtime limits). Asynchronous events are delivered in the background with a total budget of 60 seconds per event and at most 64 concurrent deliveries.
Receiving events requires the listen_events permission when PLUGINS_PERMISSIONS_ENFORCE=true: without the grant the GetSubscribedEvents answer is ignored with a warning in the panel log, and the grant is re-checked before every delivery. With enforcement off (the default in 4.5.0) every plugin’s subscriptions are honoured.
Plugin HTTP routes
A plugin registers HTTP routes with the GetHTTPRoutes method; each route is an HTTPRoute message:
| Field | Description |
|---|---|
path |
The path, starts with /; path parameters of the form {name} are supported |
methods |
Methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS |
requires_auth |
Require an authenticated user |
admin_only |
Require an administrator |
description |
Route description |
Routes are available at /api/plugins/{plugin_id}/.... The request passed to the plugin contains the method, the relative path, the headers, path and query parameters, the body (no larger than 1 MB) and the user session (if the request is authenticated). The plugin returns an HTTPResponse with the status_code, headers and body fields.
Serving a node file to the browser
Instead of body, a route can answer with the file field — a FileRef message (node_id, path, filename) pointing at a file on a node. The panel streams that file to the client itself, so the bytes never pass through the plugin; this is the way to hand large files to the browser. path is an absolute node path in the same form gameap-nodefs accepts (.. segments are rejected); filename is the name offered to the client, empty means the base name of path.
The panel sets Content-Length and Content-Disposition (always an attachment); of the plugin’s headers only Content-Type, Content-Language, Cache-Control, Expires, Pragma, Last-Modified, ETag, Vary and the X-Plugin/X-Plugin-* headers reach the client. The client must be authenticated (401 otherwise, whatever requires_auth says), the plugin needs the files_read grant (403 without it), and the path is checked against the node path policy (403 when refused). Panels that predate this field ignore it and send an empty body.
Host functions
All calls from a plugin to the panel and the outside world go through host functions, grouped into 22 gameap-* modules:
| Module | Functions | Purpose |
|---|---|---|
gameap-log |
log |
Writing to the panel log |
gameap-cache |
get, set, delete |
The panel’s shared cache (keys prefixed with plugin:, each plugin gets its own namespace) |
gameap-crypto |
random_uint64, random_string, argon2_hash, argon2_verify |
Random value generators, Argon2 hashing |
gameap-http |
fetch |
Outbound HTTP requests with SSRF protection |
gameap-storage |
get, set, delete, list |
The plugin’s persistent key-value storage |
gameap-secrets |
get, set, delete, list_keys |
The plugin’s encrypted credential store |
gameap-servercontrol |
start_server, stop_server, restart_server, update_server, install_server, reinstall_server |
Game server control (returns a task_id) |
gameap-servers |
find_servers, get_server, save_server, delete_server |
Game servers |
gameap-users |
find_users, get_user |
Panel users |
gameap-nodes |
find_nodes, get_node, update_node, delete_node, create_setup_key, get_setup_key, revoke_setup_key |
Dedicated servers (nodes) and daemon enrollment setup keys |
gameap-games |
find_games, get_game |
Games |
gameap-gamemods |
find_game_mods, get_game_mod |
Game mods |
gameap-daemontasks |
find_daemon_tasks, create_daemon_task |
Daemon tasks |
gameap-serversettings |
find_server_settings, save_server_setting |
Game server settings |
gameap-nodefs |
read_dir, mk_dir, copy, move, download, upload, remove, get_file_info, chmod, hash, create_archive, extract_archive, start_create_archive, start_extract_archive, cancel_archive, get_archive_operation |
File operations on a node |
gameap-nodecmd |
execute_command |
Executing a command on a node |
gameap-ssh |
generate_key_pair, connect, disconnect, exec, start_exec, get_exec_operation, cancel_exec, write_file, read_file |
SSH connections to hosts the plugin names itself |
gameap-net |
send, recv, close |
I/O on connections opened by the panel (RCON/Query protocol extensions) |
gameap-scheduler |
add_task, remove_task, list_tasks |
Scheduled plugin tasks |
gameap-authz |
can, can_one_of, can_for_entity, can_any_for_entity, get_user_roles |
Checking users’ abilities and roles |
gameap-rbac |
set_user_roles, allow_user_abilities_for_entity, revoke_or_forbid_user_abilities_for_entity, get_roles, save_role, delete_role, get_permissions, get_roles_for_entity, assign_roles_for_entity, clear_roles_for_entity, allow, forbid, revoke |
Managing roles and abilities |
gameap-host |
get_grants, get_host_info |
Introspection: the plugin’s grants, the panel version, the Plugin API version, the instance id and the host modules instantiated for the plugin |
Details:
gameap-httpproxies requests through the panel with SSRF protection: by default only thehttpsscheme is allowed, private and service IPs are blocked, and the response body is limited to 10 MB. The policy is configured with thePLUGINS_HTTP_*environment variables (see Installation and management).gameap-storageis the plugin’s persistent storage, isolated byplugin_id, with optional binding of records to an entity (entity_type,entity_id). Use it for plugin settings: the separate configuration mechanism (configinInitializeRequest) is not used in the current version of the panel. Storage is bounded per plugin byPLUGINS_STORAGE_MAX_KEYS_PER_PLUGIN(10000),PLUGINS_STORAGE_MAX_VALUE(1M) andPLUGINS_STORAGE_MAX_TOTAL(64M). Payloads are stored in plaintext — credentials belong ingameap-secrets.gameap-cachevalues are capped byPLUGINS_CACHE_MAX_VALUE(1M); entries expire by TTL and are not deleted when the plugin is uninstalled.gameap-nodefsandgameap-nodecmdwork with files and commands on the dedicated server (node) through GameAP Daemon; every path is checked against the node path policy.gameap-nodeswrites (update_node,delete_node, the setup keys) require themanage_nodesgrant, checked by the module itself; reads are open to every plugin.gameap-sshis registered only withPLUGINS_SSH_ENABLED=true(defaultfalse),gameap-netonly withPLUGINS_NET_ENABLED=true(defaulttrue). A plugin that imports a module the panel did not register fails to load (see Backward compatibility).
Reading node files in chunks
download accepts an offset/length window. Without a window it is a whole-file read capped by PLUGINS_NODEFS_MAX_INLINE (32M): a larger file is refused with an error naming both sizes. With a window only the window has to fit the cap (a length above it is refused, not clamped), so a large file is read a piece at a time. The response echoes offset and carries total_size; length = 0 with an offset returns as much as the cap allows, so advance by len(content), not by the requested length. A caller pages while offset + len(content) < total_size; reading at or past the end of the file answers empty content rather than an error. upload payloads are capped by the same variable.
gameap-secrets
An encrypted store for the plugin’s own credentials (API keys, bot tokens), which the plaintext gameap-storage is not suited for. Every function requires the secrets grant; without it set/delete answer success = false, get answers found = false and list_keys an empty list, with the missing permission in error — the module stays importable.
- Values are encrypted with the panel’s
ENCRYPTION_KEY(AES-256-GCM); the ciphertext is bound to the owning plugin id and key, so a row copied elsewhere in the database no longer decrypts. - Without
ENCRYPTION_KEYa write is refused rather than stored in plaintext;PLUGINS_SECRETS_REQUIRE_ENCRYPTION=falseopts out. - Keys must match
^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$; quotas:PLUGINS_SECRETS_MAX_KEYS_PER_PLUGIN(64) andPLUGINS_SECRETS_MAX_VALUE(8K). - Secrets are private to the plugin that wrote them and are deleted together with its
gameap-storageentries when the plugin is uninstalled.
gameap-ssh
Opens SSH connections to hosts the plugin names itself and runs commands there — the one thing gameap-nodecmd cannot do, because it works through GameAP Daemon and a machine being provisioned does not have one yet. The module requires the ssh grant and PLUGINS_SSH_ENABLED=true on the panel (default false): with the switch off the module is not registered at all, and a plugin that imports it fails to load.
generate_key_paircreates a key pair of the requestedKeyType:KEY_TYPE_ED25519(also what an unset type means),KEY_TYPE_RSA_4096orKEY_TYPE_ECDSA_P256. Every function of the module, key generation included, needs the grant.- A host key policy is mandatory in
connect: eitheraccept_anyor a pin list (SHA-256 fingerprints or public keys), never both — the combination is rejected. The observed host key is always returned, so a first contact can pin it. An operator can forbidaccept_anypanel-wide withPLUGINS_SSH_ALLOW_ACCEPT_ANY_HOST_KEY=false(defaulttrue). execblocks within the guest call deadline; when the budget runs out it answers withcompleted = falseand anoperation_id, and the plugin is subscribed to the completion callback.start_execreturns theoperation_idimmediately; completion is pushed into the plugin when it exports theSSHExecEventsHandlerservice, andget_exec_operationpolls the status and output;cancel_execcancels a running command.write_file/read_filestream through a remotecat, so no SFTP subsystem is needed on a freshly installed machine.- Connections and operations live in the memory of the panel instance that created them: they do not survive a plugin reload, and on a multi-instance panel
get_exec_operationon another instance answersfound = false.disconnectcancels every operation still running on the connection; the plugin’s connections are released when it is unloaded. - Targets go through the same address policy as
gameap-http:PLUGINS_SSH_BLOCK_PRIVATE_IPS(defaulttrue) andPLUGINS_SSH_ALLOWED_HOSTS. Output per stream is capped byPLUGINS_SSH_MAX_OUTPUT_BYTES(1 MB) — the head is kept and the stream is flagged truncated.
Plugin permissions
Privileged host functions are gated on the plugin’s grants — the permissions an administrator allowed it. The valid names:
| Permission | Gates |
|---|---|
manage_servers |
gameap-servercontrol (every function), gameap-daemontasks.create_daemon_task, gameap-servers.save_server/delete_server, gameap-serversettings.save_server_setting |
node_commands |
gameap-nodecmd.execute_command; cmdexec daemon tasks additionally to manage_servers |
files_read |
gameap-nodefs reads: read_dir, download, get_file_info, hash, get_archive_operation; HTTPResponse.file |
files |
Every gameap-nodefs function, including writes and archive operations; includes files_read |
listen_events |
Event subscriptions |
manage_rbac |
Every gameap-rbac function |
secrets |
Every gameap-secrets function |
ssh |
Every gameap-ssh function, key generation included; the panel must also have PLUGINS_SSH_ENABLED=true |
manage_nodes |
gameap-nodes writes: update_node, delete_node, create_setup_key, get_setup_key, revoke_setup_key |
manage_games, manage_game_mods, manage_users |
Declared, but not required by any host function yet |
Read-only modules (gameap-users, gameap-games, gameap-gamemods, gameap-authz, gameap-servers.find_servers/get_server, gameap-nodes.find_nodes/get_node, …) and gameap-http, gameap-cache, gameap-storage, gameap-crypto, gameap-log, gameap-scheduler, gameap-net, gameap-host need no grant.
A plugin declares what it needs in PluginInfo.required_permissions. Installing — by upload, from the store or via PLUGINS_AUTOLOAD — grants exactly the declared permissions; unknown names are dropped. Updating never widens the grants: the new declaration is recorded in required_permissions while allowed_permissions is left as it was, so a build that starts requiring more is refused those calls until an administrator grants them in Administration → Plugins (the Permissions action of the plugin row) or with PUT /api/admin/plugins/{id}/permissions.
Every privileged call goes through the guard: the grant is checked first, then the per-plugin rate limit (a token bucket per class, see Runtime limits), and node paths are checked against the path policy. A refused call answers in the response’s error field — plugin permission <name> required, rate limited: gameap-<module> allows N calls/s (burst M) or path policy: <reason>: <path> — and is recorded in the audit log as access.denied (rate limits as plugin.hostcall.ratelimited) with the plugin as the actor. The module stays loaded; a plugin is never disabled for a refused call.
Enforcement is transitional: with PLUGINS_PERMISSIONS_ENFORCE=false (the default in 4.5.0) the guard’s permission checks pass, while the grants are still recorded and shown in the interface. The rate limits, the node path policy, the PLUGINS_SSH_ENABLED switch and the manage_nodes check inside gameap-nodes always apply. Declare and grant the permissions your plugin needs now, so nothing breaks once enforcement is switched on.
Grants are the enforcement mechanism, but a plugin still runs inside the panel with access to its data. That is why installing plugins is entrusted to administrators only — install plugins only from sources you trust.
Node path policy
Every path a plugin hands to gameap-nodefs, the work_dir of gameap-nodecmd and HTTPResponse.file is checked on the panel before it reaches the daemon. In every mode a path with a .. segment or a NUL byte is refused. PLUGINS_NODEFS_PATH_POLICY selects what else is allowed:
| Mode | Allowed paths |
|---|---|
unrestricted (default) |
Anything the daemon itself permits |
node_workpath |
Inside the node’s work path; relative paths are resolved under it |
server_dirs |
Inside the directory of a game server on that node (<work_path>/<server dir>) |
In both restricted modes the work_dir of gameap-nodecmd must be an absolute path — a relative one would be resolved by the daemon against its own working directory. A command that names no work_dir runs in the daemon’s default directory and is not checked.
Both restricted modes keep one directory open: the plugin’s own service directory <work_path>/.plugins/<compact plugin id>, resolved from each node’s work path and opened only to the plugin whose id names it. Put node-side scratch files (staged requests, results of long commands, archives built for download) there and the strictest mode still works. PLUGINS_NODEFS_ALLOWED_PATHS (comma-separated absolute roots) widens the restricted modes on every node. A refused call answers path policy: <reason>: <path> (403 for file references) and is audited; the plugin is never disabled for it.
Runtime limits
| Limit | Value |
|---|---|
| Concurrent calls to one plugin | 1 (calls are serialized) |
| Plugin call timeout | 30 s (module start — 60 s) |
| Event handler timeout | 10 s |
| Asynchronous event delivery | 60 s per event, at most 64 concurrent deliveries |
| Module linear memory | PLUGINS_RUNTIME_MAX_MEMORY, 256M (a larger declared maximum is clamped; a module whose initial memory exceeds the cap fails to load) |
Size of a .wasm file |
PLUGINS_RUNTIME_MAX_MODULE_SIZE, 128M; an HTTP upload is additionally capped at 100 MB |
| Body of an HTTP request to a plugin | 1 MB |
Body of a gameap-http response |
10 MB |
gameap-nodefs inline upload/download |
PLUGINS_NODEFS_MAX_INLINE, 32M |
gameap-storage |
10000 keys, 1M per value, 64M in total per plugin |
gameap-cache value |
PLUGINS_CACHE_MAX_VALUE, 1M |
gameap-secrets |
64 keys, 8K per value |
Sizes accept a unit suffix (512K, 64M, 1G); a plain number is bytes.
The expensive host functions are rate limited per plugin with a token bucket, by class:
| Class | Functions | Default |
|---|---|---|
nodecmd |
gameap-nodecmd.execute_command |
5/s, burst 20 |
servercontrol |
gameap-servercontrol.*, create_daemon_task, save_server, delete_server, save_server_setting |
5/s, burst 20 |
nodefs |
Every gameap-nodefs function |
50/s, burst 200 |
http |
gameap-http.fetch |
20/s, burst 50 |
rbac |
Every gameap-rbac function |
10/s, burst 50 |
ssh |
Every gameap-ssh function |
20/s, burst 60 |
PLUGINS_RATELIMIT_<CLASS>_RPS / _BURST tune them; RPS 0 disables a class. Buckets are per panel instance.
When a call timeout is exceeded (an event handler, an HTTP route, a scheduled task, a callback) or the guest terminates its own module (a panic ending in proc_exit), the runtime closes the module: the plugin gets status error with the reason in last_error, and the panel reloads it automatically after PLUGINS_RECOVERY_INITIAL_DELAY (30s), doubling the wait on every further failure up to PLUGINS_RECOVERY_MAX_DELAY (10m). After PLUGINS_RECOVERY_MAX_ATTEMPTS (5) consecutive reloads the plugin stays in status error until an administrator reloads it (Reload in Administration → Plugins, or POST /api/admin/plugins/{id}/reload) or the panel restarts. PLUGINS_RECOVERY_ENABLED=false switches the automatic reload off altogether: the disable is still recorded (status error, reason, audit entry), but the plugin comes back only through the same manual reload or a panel restart. Write the plugin accordingly: keep state in gameap-storage, not in module globals, and make Initialize idempotent — a reload starts a fresh module instance.
Guest stdout is forwarded to the panel log at debug level and stderr at warn level, so a panic message is visible; lines are cut at 4 KiB and each stream is limited to 200 lines per 10 seconds (drops are counted and reported). The file system and the network are not available from WASM — only through host functions.
Building a plugin with Rust
The structure of a minimal project (following plugin-hex-editor):
rust-toolchain.toml:
[toolchain]
channel = "1.94.0"
targets = ["wasm32-wasip1"]
Cargo.toml:
[lib]
crate-type = ["cdylib"]
[dependencies]
gameap-plugin-sdk = "0.1"
[profile.release]
opt-level = "z"
lto = true
strip = true
A minimal src/lib.rs — a plugin that only returns metadata and an embedded frontend:
#![cfg(target_arch = "wasm32")]
use gameap_plugin_sdk::proto::gameap::plugin as pb;
use gameap_plugin_sdk::{Plugin, PluginError, register_plugin};
const FRONTEND_JS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/plugin.js"));
const FRONTEND_CSS: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/plugin.css"));
#[derive(Default)]
struct MyPlugin;
impl Plugin for MyPlugin {
fn get_info(&mut self, _req: pb::GetInfoRequest) -> Result<pb::PluginInfo, PluginError> {
Ok(pb::PluginInfo {
id: "myplugin2j7d".into(),
name: "My Plugin".into(),
version: env!("CARGO_PKG_VERSION").into(),
description: "My first GameAP plugin".into(),
author: "Me".into(),
api_version: "1".into(),
..Default::default()
})
}
fn get_frontend_bundle(
&mut self,
_req: pb::GetFrontendBundleRequest,
) -> Result<pb::GetFrontendBundleResponse, PluginError> {
Ok(pb::GetFrontendBundleResponse {
bundle: FRONTEND_JS.to_vec(),
has_bundle: !FRONTEND_JS.is_empty(),
styles: FRONTEND_CSS.to_vec(),
has_styles: !FRONTEND_CSS.is_empty(),
})
}
}
register_plugin!(MyPlugin);
Building:
cargo build --target wasm32-wasip1 --release
# optional — size reduction (binaryen):
wasm-opt -Oz target/wasm32-wasip1/release/my_plugin.wasm -o my-plugin.wasm
The resulting .wasm file is installed through the panel interface or copied into the plugins/ directory (see Installation and management). The frontend is built separately and embedded into the .wasm (see Plugin frontend).
Development in AssemblyScript
There is no ready-made SDK for AssemblyScript yet: the @gameap/proto-as package provides only message types, and the ABI layer (packing pointers, error handling, exporting malloc/free) has to be written by hand. Because of AssemblyScript’s garbage collector, buffers passed to the host must be pinned (__pin/__unpin), and a JSON parser and basic utilities have to be implemented yourself or pulled in from third-party libraries. A working AssemblyScript plugin example is plugin-minecraft-modrinth.
Validating a plugin before installation
The panel can validate a .wasm file without installing it — the POST /api/admin/plugins/upload/dry-run endpoint (multipart, file field). The response returns the plugin metadata, HTTP routes, server permissions, event subscriptions, whether a frontend is present (has_frontend_bundle, frontend_bundle_size, has_frontend_styles) and a list of errors. It also reports the permissions: required_permissions (declared in the manifest), used_permissions (derived from the module’s host imports and its event subscriptions) and undeclared_permissions — used but not declared. Installing grants only the declared ones, so with PLUGINS_PERMISSIONS_ENFORCE=true those calls are refused at runtime; with the 4.5.0 default the checks pass and the panel only writes a warning to the log when the plugin loads. If a plugin with the same id is already installed, installed, installed_version and installed_source_type say so: uploading the file would replace it. The same check runs in the interface when a file is uploaded.
Backward compatibility
Plugin compatibility is guaranteed as far as the panel’s CI tests it: the pkg/plugin/compatrust matrix loads a fixed set of Rust plugin fixtures through plugin.Manager on HEAD, v4.4.1 and v4.3.5, requiring the same plugin builds to load and answer calls on each of those panel versions, and a green run is mandatory for any change to the plugin contracts (pkg/plugin/proto, pkg/plugin/sdk). Panel versions outside that matrix are not covered by it.
Conversely, a plugin that imports a host module the panel does not provide is rejected at load time with an error naming the module, rather than failing at call time. On a 4.5.0 panel the usual cause is gameap-ssh with PLUGINS_SSH_ENABLED=false or gameap-net with PLUGINS_NET_ENABLED=false — check the panel configuration first when a load fails with an unknown import. At runtime a plugin can ask the panel what it got with gameap-host.get_host_info (the panel version, the Plugin API version, the instance id and the host modules instantiated for it) and get_grants (its current permissions).
Plugin examples
- plugin-hex-editor — a minimal Rust plugin: metadata plus an embedded frontend, no host functions.
- plugin-goldsrc-addons — a Rust plugin with HTTP routes, file operations on a node (
nodefs), command execution (nodecmd) and panel API calls (RCON) from the frontend. - plugin-minecraft-modrinth — an AssemblyScript plugin: calls to an external API (modrinth.com) through
gameap-http, caching, persistent storage.