Reference
Plugins
Plugins
Plugins extend an eIOU node with optional add-on code — event listeners, new services, new repositories, CLI or API extensions — without modifying core. They live on disk, are discovered automatically on boot, and are disabled by default until the operator explicitly enables them.
Table of Contents
- Overview
- Directory Layout
- Manifest Schema
- Database Isolation
- Plugin Signatures
- Lifecycle
- Installing Plugins
- Upgrading Plugins
- Managing Plugins in the GUI
- Managing Plugins from the CLI
- Managing Plugins over the REST API
- Sandboxed Plugin Authoring
- Events a Plugin Can Subscribe To
- Writing a Plugin
- Extending the CLI and REST API
- Extending the GUI
- Registering Payback-Method Rail Types
- Testing a Plugin
- Safety Model and Limitations
- Troubleshooting
- Related Documentation
Overview
A plugin is a directory containing a plugin.json manifest and an entry class
implementing Eiou\Contracts\PluginInterface. The PluginLoader service
scans /etc/eiou/plugins/, reads each manifest, autoloads the entry class via
the declared PSR-4 map, and calls its lifecycle methods during Application
boot.
What plugins can do
Plugins declare what they want in plugin.json — core’s IPC
forwarder bridges each declared surface into the plugin’s
__dispatch.php at runtime. The full surface list:
- Subscribe to core events (sync, delivery, chain-drop, transaction,
contact, P2P, plugin lifecycle) via the manifest’s
subscribes_tofield - Contribute filter values for host hooks (e.g.
gui.dashboard.widgets,gui.contact.actions) viafilter_hooks - Inject HTML at named render slots (e.g.
gui.dashboard.after) viarender_hooks - Add top-level GUI tabs (
tabs), POST action handlers (gui_actions), and CSS/JS asset enqueues (gui_assets) - Add admin-scoped REST endpoints (
api_routes) at/api/v1/plugins/<plugin>/<action> - Add top-level CLI subcommands (
cli_commands) — operators invoke aseiou <plugin> ... - Expose non-admin HTTP endpoints (
public_routes) under/p/<plugin>/<action>for customer-bearer-token-authenticated traffic (permitted by default, but off per plugin until the operator turns that plugin’s routes on) - Reach a curated subset of core services via
core_call()declared in thecore_servicesallow-list (Logger.*, the*LookupServicefamily for read-only facades over node identity / transactions / contacts / runtime config,PluginEventPublisher.publishfor namespaced cross-plugin events,WalletOutboundService.sendfor autonomous outbound transfers,PaymentRequestService.createfor billing contacts,ContainerLifecycleService.start/stopSidecarfor companion-container orchestration; full table in Plugin-callable surface — policy) - Own database tables under their
database.userblock, accessed through a per-plugin MySQL user with grants scoped to those tables - Register new payback-method rail types (niche regional networks,
new chains, custom gift-card systems, …) via
PaybackMethodTypeRegistryso they appear in the GUI type picker and route through validation/masking/precision like core types. Core ships 29 reserved rail ids (bank_wire,custom,btc,venmo,evm,solana,tron,lightning,paypal,revolut,wise,cashapp,zelle,pix,xrp,stellar,monero,utxo_alt,upi,mobile_payment,alipay,wechat_pay,ton,cardano,algorand,interac,exchange_p2p,mercadopago,paynow); plugins pick an unclaimed id. See Reserved rail ids below
What plugins cannot do (by design)
- Run in the wallet pool process. Sandboxed plugins live in their own
PHP-FPM pool as their own Unix user; the wallet pool sees them only
through the IPC forwarder’s HTTP calls to the plugin’s
__dispatch.php. - Read core tables (
contacts,transactions,api_keys,balances,payback_methods, …) or other plugins’ tables via raw SQL — the plugin’s own MySQL user has grants only on itsowned_tables. Core data is reached through thecore_call()gateway against allow-listed services. See How plugins interact with core data. - Read
/etc/eiou/config/.master.key,userconfig.json, or any other wallet secret. The plugin pool’sopen_basedirand Unix UID block filesystem access;disable_functionsblocks shell-out andeval. - Crash the node during discovery, registration, or boot — failures
are caught per-plugin, logged, and the plugin is marked as
failed; core keeps running. - Persist state outside their own directory or their own MySQL tables.
- Take effect on event subscriptions / filter / render hooks without a node restart — the wallet pool’s IPC forwarder binds those at boot. Enable / disable for sandboxed plugins applies immediately though (the supervisor brings their FPM pool up or down on the toggle).
Disabled by default
A freshly-dropped plugin folder will not run until the operator enables it through the GUI and restarts the node. This opt-in posture exists so a buggy or untrusted plugin cannot crash the node on its first boot — you get a chance to read the manifest, inspect the code, and then deliberately turn it on.
Directory Layout
Each plugin lives in its own subdirectory under /etc/eiou/plugins/. The
canonical layout:
/etc/eiou/plugins/
└── hello-eiou/
├── plugin.json # required — manifest
├── CHANGELOG.md # optional — bundled, rendered in the GUI
├── README.md # optional — for humans reading the directory
└── src/
└── HelloEiouPlugin.php # entry class, loaded via PSR-4
Only plugin.json and the entry class are required. Everything else is
optional. Additional src/ files are loaded lazily through the declared PSR-4
autoload map — you can structure the plugin however you like.
On-disk state
The persisted enabled/disabled flag for every plugin lives in a shared file:
/etc/eiou/config/plugins.json
Schema: { "<plugin-name>": { "enabled": true|false }, ... }. This file is
written by PluginLoader::setEnabled() when the operator toggles a plugin in
the GUI. Manual edits take effect on the next restart.
Bundled plugins
Plugins shipped inside the Docker image (currently just hello-eiou) live at
/app/plugins/ in the image and are seeded into /etc/eiou/plugins/ on first
boot via cp -rn — -n means “no clobber”, so if an operator has removed or
modified a bundled plugin, the change persists across container rebuilds.
When the operator pulls a new image whose bundled plugin version is newer
than the copy already on the plugins volume, cp -rn correctly leaves the
old directory alone (operator state lives in MySQL + credentials JSON, and a
raw overwrite would skip the migration ceremony that preserves it). The
actual version bump happens inside Application::boot via
PluginUpgradeService::upgradeFromBundle — see Upgrading
Plugins for the full backup → onUpgrade → grant
reconcile → pool reload chain. So:
| Situation | What happens |
|---|---|
| Bundled plugin not yet on volume | cp -rn seeds it. Disabled by default per the safety stance. |
| Bundled version == installed | No-op. |
| Bundled version > installed | Auto-upgrade on next boot via the upgrade service (preserves operator state). |
| Operator removed the bundled plugin | cp -rn skips (dir absent on the volume? — actually no: removal happens by deleting the dir, so subsequent boots will re-seed. To suppress re-seeding, operators uninstall via the GUI/CLI, which records the uninstall in the plugins-state file). |
Trust gate: beginning with eIOU v0.1.20-alpha, first-party Ed25519 public
keys are baked into /app/eiou/plugins/trusted-keys/, and signed plugin
packages are checked by PluginSignatureVerifier. The released MCP
Passthrough v0.29.3 package includes a format-2 signature that verifies
against the trust bundle shipped with eIOU v0.1.21-alpha. Set
PLUGIN_SIGNATURE_MODE=require to refuse
unsigned, untrusted, malformed, or byte-modified packages.
Volume persistence
/etc/eiou/plugins/ is mounted on a named Docker volume ({node}-plugins,
declared in docker-compose.yml and in the Dockerfile VOLUME directive).
The volume is what makes the cp -rn behaviour above actually hold: on a
container rebuild (docker compose down && docker compose up --build) the
volume persists, so operator-installed plugins, operator-removed bundled
plugins, and any plugin-owned on-disk state all survive unchanged. Without
the volume an image rebuild would re-seed every bundled plugin from scratch
and silently drop everything the operator added. See
/docs/reference/docker-configuration for the full volume list
and backup-priority guidance.
Manifest Schema
plugin.json is a JSON object. Minimum viable manifest:
{
"name": "my-plugin",
"version": "1.0.0",
"entryClass": "Eiou\\Plugins\\MyPlugin\\MyPlugin",
"autoload": {
"psr-4": {
"Eiou\\Plugins\\MyPlugin\\": "src/"
}
},
"sandboxed": true
}
"sandboxed": true is required — the loader refuses to load plugins
without it (see Sandboxing is mandatory).
The four other required fields (name, version, entryClass,
autoload) shape the same way they always did.
Full manifest with all the surfaces a sandboxed plugin can declare,
plus the optional metadata and the database block for plugins that
want their own MySQL user (see Database
Isolation):
{
"name": "my-plugin",
"version": "1.0.0",
"description": "Short one-liner shown in the Plugins table.",
"entryClass": "Eiou\\Plugins\\MyPlugin\\MyPlugin",
"autoload": {
"psr-4": {
"Eiou\\Plugins\\MyPlugin\\": "src/"
}
},
"sandboxed": true,
"author": {
"name": "Acme Co.",
"url": "https://acme.example"
},
"homepage": "https://acme.example/plugins/my-plugin",
"changelog": "https://acme.example/plugins/my-plugin/CHANGELOG.md",
"license": "MIT",
"min_upgradable_from": "1.0.0",
"core_services": ["Logger.info", "TransactionLookupService.getByTxid"],
"subscribes_to": ["transaction.received", "sync.completed"],
"filter_hooks": ["gui.dashboard.widgets"],
"render_hooks": ["gui.dashboard.after"],
"plugin_tab_panel": {"label": "My Plugin", "icon": "fas fa-puzzle-piece"},
"gui_actions": [{"name": "myPluginAction", "tier": "csrf"}],
"gui_assets": [{"type": "css", "path": "assets/styles.css"}],
"api_routes": [{"method": "GET", "action": "fortune"}],
"cli_commands": [{"name": "my-plugin"}],
"public_routes": [
{
"method": "POST",
"action": "chat",
"auth": "bearer",
"rate_per_minute": 60,
"max_body_bytes": 65536,
"cors_allowed_origins": ["https://example.com"]
}
],
"payback_method_types": [
{
"id": "paypal",
"catalog": {
"id": "paypal",
"label": "PayPal",
"group": "p2p",
"icon": "fab fa-paypal",
"currencies": ["USD", "EUR"],
"fields": [
{"name": "handle", "label": "PayPal.me handle", "type": "text", "required": true}
]
}
}
],
"database": {
"user": true,
"owned_tables": [
"plugin_my_plugin_subscriptions",
"plugin_my_plugin_notifications"
],
"db_limits": {
"max_queries_per_hour": 20000,
"max_user_connections": 20
}
}
}
The declarative surface fields (subscribes_to, filter_hooks,
render_hooks, tabs, gui_actions, gui_assets, api_routes,
cli_commands, public_routes) replace the in-process registry
calls plugins used to make in boot(). Core’s IPC forwarder reads
each list at boot and bridges the surface into your __dispatch.php
when an event fires, a hook resolves, a request hits, etc. See
Sandboxed Plugin Authoring for the
contract and The __dispatch.php
contract for the wire shape.
Field reference
| Field | Required | Type | Notes |
|---|---|---|---|
name |
yes | string (kebab-case) | Used as the key in plugins.json and in all API responses. Should match the subdirectory name (loader doesn’t enforce, but mismatches make the GUI/CLI surfaces confusing). |
version |
yes | string (semver) | Displayed in the Plugins table. Used for log correlation and version_compare() in the upgrade flow. |
entryClass |
yes | string (FQCN) | Must implement Eiou\Contracts\PluginInterface. Optionally also UninstallablePlugin for cleanup hooks or UpgradablePlugin for cross-version migration hooks. |
autoload |
yes | object | PSR-4 map: { "psr-4": { "Namespace\\": "src/" } }. Relative to the plugin directory. |
sandboxed |
yes | boolean (true) |
Must be true. Plugins missing the flag are refused at install, refused at enable, and skipped at discover. See Sandboxing is mandatory. |
description |
no | string | One-line summary, shown in the table and detail modal. |
author |
no | string or object | "Acme Co." or {"name": "Acme Co.", "url": "https://..."}. URL is validated as http(s). |
homepage |
no | absolute http(s) URL | Rendered as an external link in the detail modal. |
changelog |
no | absolute http(s) URL | Fallback when no bundled CHANGELOG.md is present. Bundled file wins when both exist. |
license |
no | string (≤ 64 chars) | SPDX identifier preferred (MIT, Apache-2.0, etc.). Shown next to version. |
database |
no | object | Enables per-plugin MySQL user isolation. See Database Isolation. |
min_upgradable_from |
no | string (semver) | Lowest version this manifest can be upgraded from. PluginUpgradeService refuses upgrades whose installed version is below this floor. See Upgrading Plugins → min_upgradable_from. |
core_services |
no | list<string> | Allow-list of <Service>.<method> entries the plugin will call via core_call(). Methods not in this list are 403’d by the gateway even if they carry #[PluginCallable]. See Plugin-callable surface — policy. |
permissions |
no | list<string> | Louder-consent grants required by a subset of #[PluginCallable] methods (e.g. bulk address-book enumeration). Each entry is a snake_case key catalogued by the host in PluginPermissionCatalog. Keys not in the catalog are rejected at install time. See Permissions — louder consent than core_services. |
subscribes_to |
no | list<string> | Event names this plugin handles. Core’s IPC forwarder POSTs each fired event into your __dispatch.php with type: "event". See Events a Plugin Can Subscribe To. |
filter_hooks |
no | list<string> | Filter hook names this plugin transforms. IPC forwarder routes each applyFilter call into the dispatcher with type: "filter". See Extending the GUI. |
render_hooks |
no | list<string> | Render hook names this plugin contributes HTML to. IPC forwarder routes each doRender call into the dispatcher with type: "render". |
tabs |
no | list<object> | Deprecated for plugins. Was used to declare top-level GUI tabs; the host now owns a single Plugins tab and each plugin registers a sub-panel via plugin_tab_panel. Manifests carrying tabs log a deprecation warning and the entries are not registered. Use plugin_tab_panel instead. |
plugin_tab_panel |
no | object | Single object {label, icon?, order?} declaring this plugin’s panel inside the host-owned Plugins tab (between Activity and Settings). Operators see a dropdown of all installed plugins at the top of that tab; selecting an entry swaps the panel body. label is required and shown in the dropdown; icon defaults to fas fa-puzzle-piece; order defaults to 100 (lower = earlier in the dropdown). |
gui_actions |
no | list<object> | POST handlers this plugin exposes inside the wallet GUI. Each entry: {name, tier?, timeout_ms?} (optional timeout_ms raises the 500ms IPC budget for an action that makes a synchronous network call, clamped to 25s — see Per-entry timeout). |
gui_assets |
no | list<object> | CSS/JS files to enqueue. Each entry: {type: "css"|"js", path}. Paths are plugin-dir-relative. |
api_routes |
no | list<object> | Admin-scoped REST endpoints under /api/v1/plugins/<id>/<action>. Each entry: {method, action}. |
cli_commands |
no | list<object> | Top-level CLI subcommands. Each entry: {name, timeout_ms?} — operators invoke as eiou <name> [args...]; optional timeout_ms raises the 500ms IPC budget for a verb that does network work, clamped to 25s (see Per-entry timeout). |
public_routes |
no | list<object> | Non-admin HTTP endpoints under /p/<plugin-id>/<action>. The node permits these by default, but each plugin’s stay off until the operator turns them on. See Public routes for the entry shape. |
payback_method_types |
no | list<object> | Plugin-provided payback-method rail types. Each entry: {id, catalog}. Core ships 29 reserved ids — plugins cannot shadow any of them. See Reserved rail ids for the breakdown and Registering Payback-Method Rail Types for authoring. |
cron |
no | list<object> | Host-driven scheduled tasks. Each entry: {interval_minutes, action, timeout_ms?} (interval_minutes bounded [1, 1440], action kebab-case, optional timeout_ms overrides the default 5s cron dispatch budget, clamped to 25s — see Per-entry timeout). See Scheduled Tasks (cron). |
pool |
no | object | FPM pool resource limits for the plugin’s sandbox: {max_children?, request_timeout_seconds?}. Defaults are max_children: 4, request_timeout_seconds: 30 — fine for a slow-path observer. An inference or other request-serving plugin can raise them; values are clamped to max_children ≤ 16 and request_timeout_seconds in [5, 600], so a manifest can ask but never exceed a safe ceiling. A longer timeout lets a long streaming completion finish; more children allow concurrent customer requests. The clamped request_timeout_seconds is also what nginx’s fastcgi_read_timeout is set to for the plugin’s IPC and public-route locations, so nginx never cuts a request short of the FPM budget (a long call still needs the upstream to produce output, or stream, within that window — a stalled request holds a worker for up to the timeout). See Public routes for the matching request-size limit. |
Validation
PluginLoader::listAllPlugins() normalizes every optional field before
emitting it to the GUI:
- URLs must be absolute
http://orhttps://. Anything else (javascript:,data:, relative paths) is silently dropped, so a hostile manifest cannot slip arbitrary schemes into the clickable<a href>tags the GUI renders. authoraccepts a plain string (wrapped to{"name": ...}) or an object; the object’surlgoes through the same URL validation.licenseis capped at 64 characters.
Invalid values are dropped, not rejected — a manifest with one bad field still loads the plugin with the rest of its metadata intact.
The database block is the one exception. A malformed database block
aborts the plugin load and the plugin surfaces in the list as
status: failed with an explanatory error — silently dropping a
half-broken DB declaration would leave the plugin running with no grants
and no tables, producing obscure “Unknown table” errors at runtime instead
of an honest “your manifest is broken” surface. See
Database Isolation.
Bundled CHANGELOG.md
If a plugin ships a CHANGELOG.md file next to its plugin.json, the GUI’s
detail modal surfaces a View bundled CHANGELOG.md button instead of a
clickable external link. Clicking it opens a nested modal that renders the
file’s markdown server-side through
UpdateCheckService::markdownToHtml() (the same parser that powers the
What’s New modal, so the same
htmlspecialchars-first escaping applies to plugin content).
Advantages over an external changelog URL:
- Works offline — Tor-only nodes, air-gapped deployments, and operators without browser access to the open internet still see release notes
- Keeps the operator inside the wallet UI instead of punching out to a browser
- Ships with the plugin — no version skew between the code and its release notes
The file is capped at 256 KB and read by name through
PluginLoader::readChangelog(), which cross-checks the plugin name against
the on-disk listing before touching the filesystem to prevent
../etc/passwd-style traversal.
Database Isolation
Plugins that need to store data get their own MySQL user and their own table
namespace. Core tables (contacts, transactions, api_keys, etc.) are
completely unreachable from a plugin’s PDO handle — the isolation is enforced
at the MySQL privilege level, not at the application layer.
Opting in
Declare a database block in your plugin.json:
"database": {
"user": true,
"owned_tables": [
"plugin_my_plugin_subscriptions",
"plugin_my_plugin_notifications"
],
"db_limits": {
"max_queries_per_hour": 20000,
"max_updates_per_hour": 5000,
"max_connections_per_hour": 500,
"max_user_connections": 10
}
}
user: trueis a required explicit acknowledgement — a typo on a truthy value (1,"yes") is rejected. If you don’t want a DB user, omit the block entirely.owned_tableslists every table this plugin will create. Each entry must match/^plugin_[a-z0-9_]+$/and start withplugin_<snake_case(plugin_name)>_(e.g. pluginmy-pluginowns tables starting withplugin_my_plugin_). Listing them explicitly means the uninstall flow knows exactly what to drop — no prefix-scanning heuristic that could accidentally catch a neighbour’s table.db_limitsis optional; core defaults are10000 / 5000 / 500 / 10. Invalid values fall back to defaults silently so a single-limit typo doesn’t brick the whole plugin.
Plugin names are kebab-case; table prefixes snake-case the plugin name
(my-plugin → plugin_my_plugin_). Plugin names are capped at 24 chars
for the table-name budget so the full prefix + suffix fits MySQL’s 64-char
identifier limit.
What a plugin user can do
Each plugin user gets exactly these privileges, issued per-table for every
entry in the manifest’s owned_tables:
CREATE, ALTER, DROP, INDEX, SELECT, INSERT, UPDATE, DELETE ON eiou.<owned_table>
(One GRANT statement per entry. MySQL/MariaDB only treats %/_ as LIKE
wildcards in the database portion of db.tbl; the table portion is
stored as a literal name in mysql.tables_priv, which is why the grants
have to enumerate each table individually.)
Not included:
REFERENCES— plugins cannot create foreign keys pointing at core tables. They can FK between their own tables freely.GRANT OPTION— plugins cannot sub-grant privileges to other users.- Any privilege on
eiou.*— there is no database-level grant. Core tables (contacts,transactions,api_keys,payback_methods, …) are invisible and inaccessible, and the plugin user cannot evenSHOW TABLES. - Any privilege on other plugins’ tables — Plugin A cannot read, modify, or even enumerate Plugin B’s tables.
- Any privilege on tables not in this plugin’s
owned_tables— adding a new table at runtime requires updating the manifest and re-enabling (or re-running boot-time reconciliation) so the per-table grant is issued. A plugin that tries toCREATE TABLE plugin_<id>_<unlisted>at runtime will be denied at the privilege check.
The user is bound to 'plugin_<snake_id>'@'localhost' — never '%'. A
network-layer compromise cannot reach it via remote MySQL auth.
How plugins interact with core data
The MySQL grants above describe what the plugin’s own database connection can touch. They are not the whole picture — plugins also interact with core data, but the two paths run through different mechanisms:
Route 1 — Plugin-owned tables via direct PDO
Each enabled plugin gets a credentials file at
/etc/eiou/credentials/plugin-<id>.json (mode 0640, owned by
root:eiou-pc-<hash>). The supervisor adds the plugin’s pool user
(eiou-p-<same-hash>) to that group on every apply, and the FPM pool’s
open_basedir is extended to admit the file’s exact path. The plugin
reads the file from inside __dispatch.php and constructs its own PDO,
authenticated as plugin_<snake_id> — the same user whose MySQL grants
are scoped to owned_tables. No gateway round-trip, no allow-list
entry, no JSON-encoded query body. The master key never enters the pool
process; the plaintext password does, but that password is bound to
@'localhost' and useless without already being inside the pool
(MySQL’s grants are what gate the surface, not the password’s secrecy).
What it sees: only the tables listed in this plugin’s owned_tables.
Core tables (contacts, transactions, api_keys, payback_methods,
balances, …) and other plugins’ tables are not just hidden — they
are denied at the MySQL privilege check. SELECT * FROM contacts from
this PDO returns MySQL error 1142.
This is the route you use for anything the plugin owns: storing its own state, building its own indexes, running its own analytics on its own rows. See Running queries below for the dispatch-side code.
Route 2 — Host-curated services via core_call($service, $method, …)
What it sees: whatever each host service chooses to expose through methods
marked #[PluginCallable]. Those methods run inside the wallet process
with full app-user database privileges and return whatever shape they
normally return. The plugin’s manifest must allow-list each
<Service>.<method> it intends to call in core_services.
This is the route you use for anything the plugin needs to read or
react to in core data. A notifications plugin doesn’t query
transactions directly — it subscribes via subscribes_to and/or calls
TransactionLookupService::getRecent(). A custom payback-method type
doesn’t query payback_methods directly — it registers via the
manifest’s payback_method_types and gets invoked with the rows already
loaded.
Why the split: the host services act as a typed, business-rule-aware
boundary. They redact what shouldn’t leave the core, gate sensitive
operations behind sensitive-access, and stay stable across schema
changes. A direct SELECT * FROM api_keys from a plugin would be a
disaster on multiple axes (schema coupling, no redaction, no
authorization, no audit) — ApiKeyService::list() returns hashed
identifiers and never plaintext, regardless of caller.
What this means in practice:
| Goal | Right path |
|---|---|
| Read recent transactions | core_call('TransactionLookupService', 'getReceivedUserTransactions', …) |
| Look up a contact by pubkey hash | core_call('ContactLookupService', 'getByPubkeyHash', …) |
| Look up a contact by name | core_call('ContactLookupService', 'getByName', …) |
| Check whether a contact is online | core_call('ContactLookupService', 'getOnlineStatus', …) |
| List accepted contacts (paginated) | core_call('ContactLookupService', 'listAccepted', …) (also requires permissions: ["contact_address_book_enumerate"]) |
| List pending contact requests | core_call('ContactLookupService', 'listPending', …) (also requires permissions: ["contact_pending_enumerate"]) |
| Read a contact’s available credit | core_call('ContactCreditLookupService', 'getCreditState', …) (also requires permissions: ["contact_credit_read"]) |
| Look up a transaction by memo | core_call('TransactionLookupService', 'getByMemo', …) |
| Read sent transactions | core_call('TransactionLookupService', 'getSentUserTransactions', …) (also requires permissions: ["transaction_history_enumerate"]) |
| Read transactions between two pubkeys | core_call('TransactionLookupService', 'getTransactionsBetweenPubkeys', …) (also requires permissions: ["transaction_history_enumerate"]) |
| Read aggregate stats for a period | core_call('TransactionStatisticsLookupService', 'getStatsForPeriod', …) (also requires permissions: ["transaction_history_aggregate"]) |
| Read the wallet balance | core_call('BalanceLookupService', 'getUserBalance', …) (also requires permissions: ["wallet_balance_read"]) |
| Read a contact’s balance | core_call('BalanceLookupService', 'getUserBalanceContact', …) (also requires permissions: ["wallet_balance_read"]) |
| Look up a payment request by id | core_call('PaymentRequestLookupService', 'getByRequestId', …) |
| List pending incoming payment requests | core_call('PaymentRequestLookupService', 'listPendingIncoming', …) (also requires permissions: ["payment_request_enumerate"]) |
| List outgoing payment requests | core_call('PaymentRequestLookupService', 'listOutgoing', …) (also requires permissions: ["payment_request_enumerate"]) |
| Read your own configured payback methods | core_call('PaybackMethodLookupService', 'getMyConfiguredMethods', …) (also requires permissions: ["payback_method_read_own"]) |
| Read a contact’s payback preferences | core_call('PaybackMethodLookupService', 'getContactPaybackPreference', …) (also requires permissions: ["payback_method_read_contact"]) |
| Read your own permissions / manifest | core_call('PluginLookupService', 'getOwnPermissions', …) / 'getOwnManifest' |
| List other enabled plugins | core_call('PluginLookupService', 'listEnabledPluginIds', …) (also requires permissions: ["plugin_inventory_read"]) |
| Send EIOU on behalf of the wallet | core_call('WalletOutboundService', 'send', …) (also requires permissions: ["wallet_outbound_send"]) |
| Return a received payment to its sender | core_call('WalletRefundService', 'refundReceivedTransaction', …) (also requires permissions: ["wallet_refund_return_to_sender"]) |
| Bill a contact (mint a payment request) | core_call('PaymentRequestService', 'create', …) |
| Stop your sidecar container on disable | core_call('ContainerLifecycleService', 'stopSidecar', …) |
| React to a sync event | declare subscribes_to: ["sync.completed"] in the manifest |
| Store the plugin’s own state | direct PDO against an owned_tables entry (see Running queries) |
Common asks that are deliberately unreachable:
- Reading all wallet keys — no service exposes plaintext private keys; the per-plugin user can’t read the wallet table either.
- Reading API key plaintext —
ApiKeyServicereturns only hashed identifiers; the per-plugin user can’t readapi_keyseither. SELECT * FROM contacts— privilege denied at MySQL.- Modifying another plugin’s tables — privilege denied at MySQL.
If a host service doesn’t yet expose the data your plugin needs, the
right move is to add or extend a #[PluginCallable] method — not to
widen MySQL grants.
Resource limits
The four db_limits keys map directly to MySQL’s per-user resource caps:
| Manifest key | MySQL equivalent | Default |
|---|---|---|
max_queries_per_hour |
MAX_QUERIES_PER_HOUR |
10000 |
max_updates_per_hour |
MAX_UPDATES_PER_HOUR |
5000 |
max_connections_per_hour |
MAX_CONNECTIONS_PER_HOUR |
500 |
max_user_connections |
MAX_USER_CONNECTIONS |
10 |
Defaults cap a runaway loop at roughly 3 queries per second sustained — non-restrictive for honest plugins, visible enough to halt a bug.
Running queries
The plugin reads its credentials file from inside __dispatch.php and
opens a PDO directly. Schema setup happens lazily the first time the
plugin sees a relevant envelope (or once on type: "cli" from an
install-time command) rather than at boot — there is no boot()
callback running inside the sandbox.
// Inside __dispatch.php (runs as eiou-p-<hash> in the plugin's pool):
function pluginPdo(PluginLog $log): ?PDO {
static $pdo = null;
if ($pdo !== null) return $pdo;
$pluginId = basename(__DIR__);
$credsPath = '/etc/eiou/credentials/plugin-' . $pluginId . '.json';
$raw = @file_get_contents($credsPath);
if ($raw === false) {
$log->error('credentials file unreadable', ['path' => $credsPath]);
return null;
}
$cfg = json_decode($raw, true);
if (!is_array($cfg) || !isset($cfg['host'], $cfg['database'], $cfg['username'], $cfg['password'])) {
$log->error('credentials file malformed');
return null;
}
try {
$dsn = "mysql:host={$cfg['host']};dbname={$cfg['database']};charset=utf8mb4";
$pdo = new PDO($dsn, $cfg['username'], $cfg['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
$log->error('plugin PDO connect failed', ['error' => $e->getMessage()]);
return null;
}
return $pdo;
}
function ensureSchema(PluginLog $log): bool {
static $applied = false;
if ($applied) return true;
$pdo = pluginPdo($log);
if ($pdo === null) return false;
$pdo->exec(<<<SQL
CREATE TABLE IF NOT EXISTS plugin_my_plugin_subscriptions (
id INT AUTO_INCREMENT PRIMARY KEY,
topic VARCHAR(64) NOT NULL,
created_at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6),
UNIQUE KEY ux_topic (topic)
) ENGINE=InnoDB
SQL);
$applied = true;
return true;
}
// SELECT
$stmt = pluginPdo($log)->prepare(
'SELECT id, topic FROM plugin_my_plugin_subscriptions WHERE topic = ?'
);
$stmt->execute([$topic]);
$rows = $stmt->fetchAll();
// INSERT / UPDATE / DELETE
$stmt = pluginPdo($log)->prepare(
'INSERT IGNORE INTO plugin_my_plugin_subscriptions (topic) VALUES (?)'
);
$stmt->execute([$topic]);
$insertedId = $pdo->lastInsertId();
$affected = $stmt->rowCount();
The PDO is local to the FPM worker, so transactions, prepared-statement
caching, and lastInsertId() work the way they normally do —
BEGIN/COMMIT around a multi-step write is fine when the plugin
needs cross-statement atomicity.
No core_services allow-list entry is needed for owned-table access —
the path is filesystem + MySQL privileges. The credentials file is
readable only to the plugin’s own pool user (via eiou-pc-<hash> group
membership) and only at one exact path under /etc/eiou/credentials/
(the rest of the directory is outside the pool’s open_basedir).
Privileges are enforced at the MySQL layer — the per-plugin user can
only touch the manifest’s owned_tables, so SELECT * FROM contacts
(or any other core table) is denied with MySQL error 1142 even though
the PDO connection itself is unrestricted.
Credential storage
Each plugin’s MySQL password is 32 bytes of random_bytes base64-encoded,
wrapped via KeyEncryption with the plugin id baked into the AAD, and
stored in the plugin_credentials core table. If the operator has set
EIOU_VOLUME_KEY for passphrase protection, plugin credentials inherit
that protection automatically — the master key is encrypted at rest,
which transitively protects every wrapped blob.
The password is generated on first enable and persists across restarts.
It is never shown to the operator or logged; rotation replaces the
ciphertext in-place and runs ALTER USER ... IDENTIFIED BY NEW in the
same transaction.
Sibling-container credentials
Some plugins are easier to ship as the eIOU plugin plus a sibling Docker container the operator deploys separately — e.g. a heavy provider-client library that doesn’t fit cleanly into a per-request PHP-FPM pool, or a long-running daemon that needs to live somewhere the FPM lifecycle doesn’t reach. To let the sibling share state with the plugin’s MySQL user, the host writes a credentials JSON file at plugin-enable time that the operator can mount read-only into the sibling.
Path and contents:
/etc/eiou/credentials/plugin-<plugin-id>.json
{
"host": "127.0.0.1",
"port": 3306,
"database": "eiou",
"username": "plugin_my_plugin",
"password": "<plaintext>",
"issued_at": "2026-05-13T11:56:00Z"
}
host is what the wallet sees inside its own container (typically
127.0.0.1). The operator’s sibling will almost certainly need to
override this in its own configuration to point at the eIOU
container’s address on the docker network — the wallet has no way
to know the operator’s network topology, so it writes what’s
locally true and leaves the override to the deployer.
File permissions and per-plugin group:
The file is mode 0640 owned by root:eiou-pc-<8hex> where the
hex is sha256(pluginId)[0:8] — the same shape used elsewhere
in plugin sandboxing (the per-plugin system user is
eiou-p-<same-8hex>, so operators can recognize the user/group
pair belongs to the same plugin). The supervisor creates the
group at first apply (groupadd -rf, system-range GID,
idempotent) and removes it on plugin disable / uninstall
(groupdel, refused while members exist so a sibling deployment
that’s still attached doesn’t get its group pulled out from
under it).
The credentials directory itself is mode 0711 root:root:
traverse-only, no list. Unprivileged host users can’t enumerate
which plugins exist; the per-file group on each plugin-<id>.json
governs actual reads.
Operator workflow on the sibling-container side:
Pick a host-side uid for the sibling container’s process and add it to the specific plugin’s group. The plugin id whose credentials the sibling needs determines which group:
# On the docker host, AFTER the plugin has been enabled in the wallet
# (the group only exists once apply-credentials has run).
sudo usermod -a -G eiou-pc-$(printf '%s' my-plugin | sha256sum | cut -c1-8) <sibling-uid-name>
Then bind-mount the specific credentials file into the sibling:
# docker-compose.yml for the sibling container
services:
my-plugin-companion:
image: vendor/my-plugin-companion:1.0.0
user: "1500:1500" # uid added to eiou-pc-<hex> above
volumes:
- /etc/eiou/credentials/plugin-my-plugin.json:/run/credentials.json:ro
Inside the sibling, read /run/credentials.json, override host
to the eIOU container’s network address, and use the values to
open a MariaDB connection.
Trust model:
- Each plugin’s credentials file lives in its own group; no cross-plugin read access on the host. A sibling configured for plugin A can’t drift into reading plugin B’s credentials by mistake (it doesn’t have the group).
- The wallet pool (
www-data) is not in any of these groups. It doesn’t need to read the credential files — it has the master key and decrypts credentials in memory whenever it needs to act on them. So a wallet pool compromise doesn’t grant access to the on-disk credential files beyond what it already has via the DB directly. - Plugin FPM pools run as
eiou-p-<hex>users. Each pool user is added to its owneiou-pc-<same-hex>group on apply, and the pool’sopen_basediradmits its own credentials file as an exact path (no trailing slash, so the rest of/etc/eiou/credentials/is outside basedir). A plugin can read its own credentials file; reading a sibling plugin’s is denied at both the group layer and the basedir layer. - The file holds plaintext, not encrypted — sibling containers can’t decrypt the wrapped form (they don’t have the master key, by design) so the protection is purely filesystem-level. Operators with full host root can read every credentials file; this is unchanged from any other secret on disk and is intentional.
The file is rewritten on every plugin enable and on every boot
reconcile (so a /etc/eiou volume recreation or manual file
deletion self-heals on the next boot). It is removed on plugin
disable and on uninstall.
Operator obligation when retiring a plugin with a sibling:
groupdel refuses to remove a group with live members. If the
operator added a sibling-container uid to eiou-pc-<hex> and then
uninstalls the plugin without first removing that uid from the
group, the supervisor’s groupdel is rejected, the group persists,
and the sibling uid retains its group membership. If the same
plugin id is later reinstalled, the group already exists with the
sibling still attached — meaning the sibling would inherit access
to the new plugin’s credentials without the operator opting in
again. Two ways to avoid this:
-
Recommended. Detach the sibling before uninstall: stop the sibling container, then
gpasswd -d <sibling-uid-name> eiou-pc-<hex>to remove it from the group, theneiou plugin uninstall <name>. The supervisor’sgroupdelsucceeds and the group is fully torn down. -
Defensive. If you only operate one set of plugins on a host, reusing a plugin id with a new author / new database schema is uncommon — but if you do reuse ids, also run
getent group eiou-pc-<hex>after uninstall to spot any lingering members and clean them up before reinstalling.
Boot-time reconciliation
On every node boot, after the master key is loaded and before plugins’
register() runs, the loader runs an idempotent reconcile pass:
- For each enabled plugin with
database.user: true:CREATE USER IF NOT EXISTS+ALTER USER+ oneGRANTperowned_tablesentry. Self-heals after amysql-datavolume recreation, a manualDROP USER, an operatordb_limitsedit, or a master-key rotation. A manifest that adds a new table is picked up automatically on the next reconcile. - For each disabled plugin that still has a credential row:
REVOKE ALL PRIVILEGES, GRANT OPTION FROM <plugin_user>(the no-ONform, which drops every grant the user holds without needing to know which tables were granted). Self-heals cases wheresetEnabled(false)flipped the flag but didn’t revoke (shouldn’t happen in current code, but the reconciler is defensive).
Per-plugin reconcile errors are logged and surfaced as error:<msg> in
the plugin list’s status field. They do not block node boot — the
operator investigates via the GUI/CLI list.
App DB user privileges
The plugin isolation feature requires the application’s own MySQL user
(eiou_user_<hex>, generated by DatabaseSetup::freshInstall) to hold
two upstream privileges so it can in turn create plugin users and grant
them their per-table access:
GRANT ALL ON `eiou`.* TO 'eiou_user_<hex>'@'localhost' WITH GRANT OPTION;
GRANT CREATE USER ON *.* TO 'eiou_user_<hex>'@'localhost';
Fresh installs receive these directly during database provisioning. On
container boot a small root-credentialed helper (files/scripts/grant-app-user-plugin-privileges.php,
invoked from startup.sh after MariaDB is up) re-applies them
idempotently — no-op when the master key isn’t loadable yet, and no-op
when the user already holds them. This exists so installs that pre-date
the plugin isolation feature pick up the required grants on first boot
under a new image, and so a manual REVOKE against the app user
self-heals on the next restart.
These privileges are scoped to user creation and eiou.*-grant
delegation only — the app user cannot read mysql.*, cannot FILE,
PROCESS, or SHUTDOWN, and cannot escalate to MariaDB root. The
threat-model trade-off is that an attacker who already controls the app
user (which has full read/write on eiou.*, including wallet keys) can
now also create persistent backdoor users — but they could already
self-grant equivalent access via WITH GRANT OPTION, so this is a
timing change, not a privilege-surface widening.
Uninstall
Uninstall is a separate, destructive action from disable. The plugin must be disabled first — the service refuses to uninstall an enabled plugin regardless of whether the request arrives via CLI, REST, or GUI.
Uninstall runs these steps, in order, on a best-effort basis (a failure in any single step does not abort the rest — the response reports per-step status so the operator can investigate):
onUninstall()hook — if the plugin implements the optionalUninstallablePlugininterface, itsonUninstall()method runs while the plugin still has MySQL grants so it can clean up its own data. Exceptions are logged but do not block the remaining steps.REVOKE ALL PRIVILEGES, GRANT OPTION FROM <plugin_user>— the no-ONform drops every privilege the user holds in one statement, regardless of which tables had grants. Locks out the plugin user before the table drops so a hostile plugin can’t race the next steps.DROP TABLE IF EXISTSfor every table inowned_tables. Each name is revalidated against the/^plugin_[a-z0-9_]+$/shape so a manifest edited between install and uninstall cannot injectcontactsorapi_keysinto the drop list.DROP USER IF EXISTSfor the plugin user.- Delete the
plugin_credentialsrow and purge the in-memory PDO cache so any lingering connection doesn’t outlive the user it was authenticated as. rm -rf /etc/eiou/plugins/<name>/— the plugin’s files.- Remove the plugin’s entry from
plugins.json.
Each step emits ok, skipped, or error:<msg>. Uninstall fires
PLUGIN_UNINSTALLING before step 1 and PLUGIN_UNINSTALLED after step 7
with the full step-status map in the payload.
Uninstallable plugins
Plugins that need a cleanup hook implement the optional
Eiou\Contracts\UninstallablePlugin interface (an extension of
PluginInterface with one additional method):
use Eiou\Contracts\UninstallablePlugin;
use Eiou\Services\ServiceContainer;
class MyPlugin implements UninstallablePlugin
{
// ... normal getName / getVersion / register / boot ...
public function onUninstall(ServiceContainer $container): void
{
// Runs BEFORE MySQL revoke — full grants still available, so
// the plugin's own PDO (constructed from
// `/etc/eiou/credentials/plugin-<id>.json` inside the pool,
// see *Running queries*) still works against `owned_tables`.
// Sandboxed plugins reach this hook through an
// `__dispatch.php` envelope with `type: "uninstall"`; the
// `$container` argument is retained on the interface for
// signature compatibility but the in-pool ServiceContainer
// cannot reach the master key or `dbconfig.json`.
//
// Typical uses: ping an external service to revoke a
// subscription, purge a remote cache, write a final audit row.
//
// Implementations MUST be idempotent — uninstall may retry
// after a partial failure.
}
}
Plugins that don’t need cleanup simply don’t implement the interface and step 1 is skipped. Most plugins won’t need it — table removal is handled automatically from the manifest.
Threat model notes
This design isolates the database layer, not PHP execution. A malicious plugin still runs arbitrary PHP in the node process and can do anything the filesystem permits. The DB isolation closes the most valuable target (wallet data), but doesn’t turn plugins into a sandbox. For truly hostile plugins, the mitigations are upstream: manifest signatures, operator- vetted install sources, code review. See also What plugins cannot do (by design).
Plugins sharing a single MySQL instance with core means a pathological
query can still starve the instance (the MAX_*_PER_HOUR caps reduce but
don’t eliminate this). Separate MySQL instances would be stronger but are
an operational step-change — revisit if a real starvation incident
materializes.
Plugin Secrets Encryption
Sandboxed plugins that store secrets at rest (e.g. provider API tokens
for an LLM passthrough, a third-party signing key, customer payloads
that must round-trip in plaintext) can derive a per-plugin encryption
key in memory at boot and use libsodium to seal/open their state. The
key derivation is keyed by an ecosystem-wide master that startup.sh
resolves before php-fpm spawns; each plugin pool inherits the value
through a single FPM env[] reference.
How a plugin uses the master
Inside the sandboxed plugin’s PHP code:
// 1. Read the master key from the FPM-inherited env. Returns false
// if the operator started the container with the env var unset
// AND the host's auto-gen fallback didn't fire (e.g. /etc/eiou/
// config/ wasn't writable). Fail closed: if you cannot derive,
// refuse to operate on encrypted data rather than silently writing
// plaintext.
$masterHex = getenv('EIOU_PLUGIN_MASTER_KEY');
if ($masterHex === false || $masterHex === '') {
throw new RuntimeException(
'EIOU_PLUGIN_MASTER_KEY is not set; refusing to operate. '
. 'See docs//docs/reference/plugins → Plugin Secrets Encryption.'
);
}
// 2. Derive your plugin's key via HKDF-SHA256. The host emits the
// master as lowercase hex (64 chars for 32 bytes; PHP-FPM pool
// config rejects '=' so base64 padding is out); hex2bin restores
// the raw 32 bytes. The info string is
// "<plugin-id>/<purpose>/<version>" so different plugins, different
// purposes within a plugin, and version bumps all derive different
// keys. A leak of one derived key does not compromise others.
$master = hex2bin($masterHex);
$pluginKey = hash_hkdf('sha256', $master, 32, 'my-plugin/token-vault-v1');
// 3. Scrub the local master reference so it doesn't sit on a long-
// lived object. Pass only the derived $pluginKey downstream.
sodium_memzero($master);
unset($masterHex);
// 4. Seal / open with libsodium's authenticated symmetric box. 32-byte
// key, 24-byte random nonce per ciphertext (stored alongside).
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $pluginKey);
// On-disk envelope: versioned so a future schema change is graceful.
$envelope = [
'v' => 1,
'n' => base64_encode($nonce),
'c' => base64_encode($ciphertext),
];
// 5. Decrypt later.
$plain = sodium_crypto_secretbox_open(
base64_decode($envelope['c']),
base64_decode($envelope['n']),
$pluginKey
);
if ($plain === false) {
throw new RuntimeException('Sealed value failed authentication; key may have rotated.');
}
Info-string convention
Use <plugin-id>/<purpose>/<version> exactly. Examples:
mcp-passthrough/token-vault-v1— provider API tokensmcp-passthrough/session-cookie-v1— operator session cookies if anyacme-payments/customer-payload-v1— encrypted customer rows
Bumping the version (v1 → v2) gives that one purpose a fresh key
without touching any other plugin or any other purpose within the same
plugin. Useful for rotating a single vault without an ecosystem-wide
rekey.
What the master mode says about your threat coverage
Whether a plugin’s encrypted data survives a disk-exfil attack depends on which mode the operator chose, not on the plugin’s code. See SECURITY.md → Plugin Secrets Encryption for the Mode A vs Mode B trade-off. A plugin author can document in its README which threats their at-rest encryption defends against (it will be a subset of what the active mode covers), but the upper bound is set by the host’s mode and is the operator’s choice.
What plugin authors should ship
Three CLIs are the operator-facing surface a plugin’s vault needs:
| CLI | Purpose |
|---|---|
<plugin> vault migrate |
One-shot at first encryption rollout: walks plaintext fields, seals them, writes back. Idempotent (skips already-sealed values). |
<plugin> vault rotate |
Two-key path used during master rotation. Reads EIOU_PLUGIN_MASTER_KEY_OLD + EIOU_PLUGIN_MASTER_KEY, derives both per-plugin keys, decrypts with old, re-encrypts with new. |
<plugin> vault reset |
Wipes the plugin’s encrypted fields. Used when the operator has lost the master and has to start over from upstream tokens. Destructive; should prompt for confirmation. |
A boot check that refuses to start the plugin’s service when encrypted envelopes exist but the env var is absent (fail-closed) prevents silent plaintext leakage on a misconfigured restart.
Threat model honesty
Plugin secrets encryption defends against the threats the active master mode covers, no more. It does not defend against:
- A malicious plugin reading another plugin’s derived key. Mitigation: per-plugin info string in HKDF means even if a malicious plugin somehow learns the master, it can only derive its own purpose key, not another plugin’s.
- A live-container attacker who can read the FPM master process’s env.
This is unsolvable in software without hardware (TPM/HSM); a future
hardware-backed tier would replace
getenv()with a request to the hardware module to unwrap on demand, without changing the per-plugin HKDF derivation pattern above. - Customer key material (e.g. wallet private keys) — those have their own encryption layer (AES-256-GCM keyed by the wallet master) and are out of scope for plugin secrets.
Plugin Signatures
Plugins can ship with an Ed25519 detached signature that binds every byte of the manifest and source tree. Operators trust a set of public keys up front; unsigned plugins (or plugins signed by an untrusted key) are rejected when signature enforcement is on. This closes the “a plugin I installed yesterday was swapped for a backdoored copy today” supply-chain window at the file-on-disk level.
Signatures are complementary to, not a replacement for, Database Isolation. DB isolation limits what a running plugin can touch; signatures limit what code can run in the first place.
Trust model
Two layers of trusted-key directories, both scanned on every plugin-load pass:
| Layer | Path | Source | When to use |
|---|---|---|---|
| Baked-in | /app/eiou/plugins/trusted-keys/ |
Image (read-only) | First-party / eIOU-official keys that ship with every node |
| Operator | /etc/eiou/plugins/trusted-keys/ |
Config volume | Third-party publishers you’ve vetted and decided to trust |
Both directories accept *.pub files — plain text, one or more base64
Ed25519 public keys per file, # lines are comments. Multiple keys per
file are fine; duplicates across files are de-duplicated silently.
Adding a key is a deliberate operator action that says “I trust whoever holds the corresponding private key to publish plugins on my node.” The verifier cannot distinguish “this plugin is safe” from “this key is trusted” — trust is a human decision, signatures are the machine-enforceable bit.
Enforcement modes
Controlled by Constants::PLUGIN_SIGNATURE_MODE:
| Mode | Behaviour |
|---|---|
off |
Don’t verify. Load every plugin regardless of signature state. Intended only when an operator explicitly disables verification. |
warn (default) |
Verify, log failures, but still load the plugin. The plugin list surfaces signature.status (ok / unsigned / untrusted_key / bad_signature / malformed_sig / malformed_manifest) so operators can fix signing before flipping to require. |
require |
Verify and refuse to load any plugin whose signature is missing, malformed, bound to an untrusted key, or fails verification. Failed plugins surface in the plugin list with status: failed and an explanatory error. |
Recommended rollout: use the default warn mode to see what would fail →
sign or remove everything you want to keep → flip to require. The verifier
cost is roughly one Ed25519
verification per plugin per boot (~1ms each) so leaving it on warn or
require long-term is free.
Wire format
Every signed plugin has a plugin.sig file alongside plugin.json:
{
"algorithm": "ed25519",
"key_fingerprint": "sha256:<64-hex-chars>",
"signature": "<base64 of raw 64-byte Ed25519 signature>"
}
The signed payload is deterministic:
plugin.json bytes + 0x00 + sha256-hex-of-src-tree
Where sha256-hex-of-src-tree is:
SHA-256 ( concat, in sorted path order, for every file under src/:
relpath + 0x00 + SHA-256(file contents) + 0x00
)
Any byte change in the manifest or any source file invalidates the signature on the next verification pass — the attack window between sign time and install time doesn’t extend past boot.
Key format
*.pub files — one or more keys per file, plain text:
# eIOU official release signing key
# fingerprint: sha256:abc123...
# issued: 2026-04-24
Ab3+k/...base64 of raw 32-byte Ed25519 public key...==
Private keys use the same format (base64 of the raw 64-byte Ed25519
secret key, with a leading comment). File mode should be 0600 — the
signing helper chmods it for you on generation.
Signing your own plugins
The runtime image bundles plugin-sign.php at
/app/eiou/scripts/plugin-sign.php. Three subcommands:
1. Generate a keypair (one-time):
docker exec -it <node> sh -c 'cd /tmp && php /app/eiou/scripts/plugin-sign.php generate-key'
# Writes <fingerprint>.pub and <fingerprint>.key into /tmp.
# Copy them out:
docker cp <node>:/tmp/<fingerprint>.key ./my-plugins.key
docker cp <node>:/tmp/<fingerprint>.pub ./my-plugins.pub
Keep .key secret — it’s the signing authority for anything that says
“I’m this publisher.” Treat it like an SSH private key: chmod 600, keep
out of backups/CI images, use a password manager or hardware token if
available. The .pub is safe to share.
2. Install the public key into the operator trust store:
docker cp ./my-plugins.pub <node>:/etc/eiou/plugins/trusted-keys/my-plugins.pub
From this moment, any plugin signed with the corresponding private key loads on this node.
3. Sign a plugin:
docker cp ./my-plugins.key <node>:/tmp/my-plugins.key
docker exec <node> php /app/eiou/scripts/plugin-sign.php sign \
--key=/tmp/my-plugins.key \
--plugin=/etc/eiou/plugins/my-plugin
# Writes /etc/eiou/plugins/my-plugin/plugin.sig
docker exec <node> rm /tmp/my-plugins.key # remove the private key ASAP
Re-sign whenever you change plugin.json or any source file — the
deterministic hash means even a whitespace edit invalidates the previous
signature.
4. Verify (useful in CI before publishing):
docker exec <node> php /app/eiou/scripts/plugin-sign.php verify \
--plugin=/etc/eiou/plugins/my-plugin
# Exit 0 on valid, 1 on any failure.
What you can and can’t do with a stolen private key
- An attacker with your private key can publish plugins that load on any node that trusts your public key.
- An attacker with your private key cannot reach into nodes where your public key isn’t in the trust directory.
- If you suspect a key is compromised: remove the
.pubfrom every node’s/etc/eiou/plugins/trusted-keys/immediately. On next boot every plugin signed by that key becomesuntrusted_key. Generate a new keypair, distribute the new public key, re-sign your plugins.
Threat model honesty
Signatures close the “installed file was tampered with / swapped after install” attack surface — that’s the most common real-world supply-chain vector. They don’t close:
- Compromised publisher — if the private-key holder is itself malicious (or their key was stolen and is being used to sign a backdoored plugin by the attacker), verification succeeds and the plugin loads. Mitigations there are human: review the code, pin plugin versions, publish reproducible builds.
- Malicious plugin that was always malicious — signatures don’t attest to behaviour, only to origin. A well-known attacker with a trusted key can still ship a well-signed malicious plugin.
- PHP execution sandbox — plugins still run in the node process. See Safety Model and Limitations.
Lifecycle
Two distinct lifecycles run in parallel because the wallet and the plugin live in different PHP-FPM pools.
Wallet-side lifecycle — IPC forwarder registration
PluginLoader runs three phases inside Application::__construct,
all in the wallet’s PHP-FPM worker (not the plugin’s):
-
discover()— Scans/etc/eiou/plugins/for subdirectories with aplugin.json. Parses each manifest. Records the plugin’s declarative surfaces (subscribes_to,filter_hooks,render_hooks,tabs,gui_actions,gui_assets,api_routes,cli_commands,public_routes). Plugins missing"sandboxed": trueare recorded with statuslegacy_unsupportedand skipped. -
reconcileIsolation()+reconcileSandbox()— Boot-time self-heal. Idempotent: re-applies the per-plugin MySQL user + grants from the manifest; re-applies the per-plugin FPM pool config; prunes upgrade backups older than 30 days. Failures here are logged but don’t abort the wallet boot. -
PluginIpcForwarder::registerAll()— Walks every enabled sandboxed plugin’s manifest surfaces and registers in-process bridges:- For each
subscribes_toentry, subscribes theEventDispatcherso that when an in-process event fires, the forwarder HTTPS-POSTs the event payload into the plugin’s__dispatch.phpwithtype: "event". - For each
filter_hooks/render_hooksentry, registers anonFilter/onRenderlistener that does the same. - For each
gui_actions,api_routes,cli_commandsentry, registers a handler in the matching in-process registry (GuiActionRegistry,PluginApiRegistry,PluginCliRegistry) that forwards the invocation as an IPC call.
- For each
The wallet pool does not load the plugin’s PHP code — sandboxed
plugins live in their own FPM pool and only that pool sees their
classes. PluginInterface::register() and PluginInterface::boot()
are still on the interface for compatibility but they run inside the
plugin’s pool on each __dispatch.php request, not in the wallet
pool’s boot path.
Plugin-side lifecycle — per-request init in the plugin pool
Each call into the plugin’s pool — whether from the IPC forwarder
(type: "event" / "filter" / "render" / "action" / "rest" /
"cli") or from a customer (type: "public") — runs the dispatcher
in a fresh-or-recycled FPM worker. The dispatcher:
- Reads the request envelope
- Loads the plugin’s autoload + entry class
- Runs the plugin’s per-type handler (which the plugin author writes
in
__dispatch.php) - Buffers any log lines into
_log, returns the response
Workers cycle on pm.max_requests / pm.process_idle_timeout —
opcache picks up new code via mtime, so the upgrade flow’s directory
swap plus FPM SIGUSR2 reload is what causes workers to pick up the
new on-disk version.
One-shot on_enable hook
When PluginLoader::setEnabled($id, true) succeeds, the host
dispatches a single lifecycle envelope at the plugin’s
__dispatch.php before returning to the caller:
{
"type": "lifecycle",
"name": "on_enable",
"context": {"fired_at": 1715635200}
}
This is the sandbox-model replacement for the pre-sandbox boot()
method. Plugins that need to wire sidecars (call
ContainerLifecycleService.startSidecar), prime caches, verify a
provider, or do any other one-shot setup do it here. The dispatch
budget is 5 seconds (same as cron); failures are logged but do
not roll back the enable — the plugin’s state is already
committed by the time the hook fires. Disable does NOT mirror this:
by the time we’d dispatch the plugin’s pool has already been
dropped, and the call would never reach a live worker. Plugins that
need cleanup work should register it inside the on_enable handler
(e.g. via a manifest-declared cron that drains a wind-down queue).
Plugin-side handling looks like every other envelope:
if ($type === 'lifecycle' && $name === 'on_enable') {
// one-shot setup work — verify, prime, register sidecars …
return ['ok' => true];
}
Your dispatcher must accept the lifecycle type for the hook to land.
This is the PLUGIN_DISPATCH_VERSION 6 contract: re-sync your
__dispatch.php from the template so lifecycle is in the dispatcher’s
allowed-type whitelist (a dispatcher that rejects unknown types will
otherwise answer the hook with 400 bad_envelope). The bundled template
ships a default lifecycle arm that acknowledges with ok:true and does
nothing, so a plugin with no setup work needs no extra code beyond the
re-sync. The host fires the hook best-effort and retries it on transient
transport failures while the pool warms up, so an on_enable handler that
does real work must be fast and idempotent.
Why GUI / event subscriptions need a wallet restart
Toggling a plugin on or off in /etc/eiou/config/plugins.json is
immediate, and the supervisor brings the plugin’s pool up or down on
the toggle — so plugin endpoints work immediately. But the
wallet pool’s in-process IPC forwarder binds its event / hook
listeners at boot (registerAll’s pass). Those bindings are frozen
in the running wallet workers; a wallet restart recycles them
through registerAll again so the new on-disk subscriptions list
takes effect.
The GUI’s Restart node button (shown inside the yellow “changes
saved” banner when the wallet’s on-disk plugin state diverges from
what’s bound in the running workers) triggers this via the
request-marker pattern documented in /docs/reference/architecture
— see RestartRequestService and NodeRestartService.
Installing Plugins
There are three ways to land a plugin’s files in /etc/eiou/plugins/<name>/:
-
Drop the directory in by hand — log in to the container, copy the plugin’s files into
/etc/eiou/plugins/, then enable it in the GUI / CLI. This is the path operators with shell access have always had. The plugin is disabled by default (Disabled by default). -
Bundled — plugins shipped inside the Docker image are seeded into the plugins volume on first boot via
cp -rn(Bundled plugins). -
Upload a
.zipthrough the GUI — the operator picks a.zipfrom their machine and the node stages it disabled, after a layered validation pass. The rest of this section documents that path.
Why a separate code path
A plugin installs at PHP-FPM privilege — once it runs, it can do anything PHP can do (see No sandboxing). The upload path’s job is not to make every plugin safe — it can’t — but to make the install decision deliberate and to ensure that a malformed, oversized, or hostile zip cannot exploit the node before the operator has even decided whether to enable the plugin. Trust comes from the operator (and optionally the signature trust chain — see Plugin Signatures). Mechanical safety comes from the gates below.
Service
Eiou\Services\Plugins\PluginInstallService owns the validation and extraction
pipeline. The GUI’s pluginsUpload action and the CLI’s eiou plugin install <zip-path> both delegate to it — same validation gates, same staging /
atomic-publish ceremony, same PLUGIN_INSTALLED event. The CLI form is the
scripted-rollout entry point (docker cp the zip into the container, then
run the install command via docker exec); operators with shell access can
still drop a plugin directory in by hand, but the install service is what
the rest of the docs assume going forward.
Validation pipeline
Validations run in the order listed. The pipeline short-circuits on the first failure, and any bytes already written to the staging directory are removed before the error returns.
| Gate | Limit / Rule | Why |
|---|---|---|
| File readable, non-empty | PluginInstallService::MAX_ZIP_BYTES (25 MiB) |
First-line DoS guard; rejects before the zip is even opened. |
| Magic bytes | First 4 bytes must be PK\x03\x04 |
Client-supplied MIME / extension aren’t trusted; the magic header is. |
| Path traversal | No .., no leading /, no \, no ./ |
Zip-slip — prevents extraction from escaping /etc/eiou/plugins/. |
| Single top-level directory | All entries share one root, matching ^[a-z0-9][a-z0-9_-]{0,63}$ |
A plugin is one named directory; multiple roots or capital-letter names break the loader’s contract. |
| Per-file uncompressed size | MAX_FILE_BYTES (15 MiB) |
No single file can dominate the install; sized to forgive heavy single assets (large splash images, bundled fonts) without raising the aggregate cap. |
| Total uncompressed size | MAX_UNCOMPRESSED_BYTES (50 MiB) |
Disk-space cap. |
| File count | MAX_FILE_COUNT (500) |
Inode-pressure cap. |
| Compression ratio | MAX_COMPRESSION_RATIO (100:1) |
Zip-bomb sentinel; legitimate plugin zips sit well under 20:1. |
| File extension allow-list | php, json, md, txt, css, js, map, html, htm, svg, png, jpg, jpeg, gif, webp, ico, woff, woff2, ttf, otf, eot |
Refuses .phar, .so, .htaccess, hidden dotfiles, anything outside the plugin asset contract. |
| Symlink rejection | No is_link() paths in the extracted tree |
Belt-and-suspenders: even if entry walk approved a name, an actual symlink on disk is anomalous. |
| Extraction stays inside root | realpath() of every extracted path is under the staging dir |
Cross-checks the entry walk against the post-extraction filesystem state. |
| Manifest validation | plugin.json parses, name matches directory, version and entryClass present |
Catches corrupt manifests before the loader has to. |
| No overwrite | Target dir must not already exist | Install is not update; the GUI then offers a confirm modal that re-routes the same zip through the upgrade flow, and the CLI surfaces eiou plugin upgrade <zip-path> as the replacement command. |
| Signature (mode dependent) | In require mode, the zip must include a valid plugin.sig against a trusted key |
See Enforcement modes. warn and off modes accept the upload but report the verifier’s verdict in the response. |
The numeric limits and the allow-list are returned to the GUI by the
pluginsUploadLimits action so the upload form can echo them without
duplicating constants.
Staging and atomic publish
Uploaded bytes are not written under their final name. The pipeline:
- The upload tmp file (provided by PHP under
$_FILES['plugin_zip']['tmp_name']) is opened read-only viaZipArchive. - Every entry is walked with
statIndex()— no extraction yet — and the gates above run against the metadata. - If the walk passes, extraction targets a sibling directory:
/etc/eiou/plugins/.staging-<16-hex-chars>/. A failure here leaves the staging directory in place, which the service immediately recursive-deletes inside the catch block. - The extracted tree is walked again for symlinks / realpath escapes, and the manifest is parsed.
- If signature mode is
require, the verifier runs against the staged tree. A bad verdict aborts the install; the staging directory is cleaned up. rename()moves.staging-…/<plugin_id>/to/etc/eiou/plugins/<plugin_id>/.rename()inside the same filesystem is atomic, soPluginLoader::discover()never sees a half-written plugin.- The staging parent is removed. The
PLUGIN_INSTALLEDevent fires.
If anything between steps 3 and 6 fails, the catch block recursive-deletes the staging directory before re-throwing, so failed uploads leave no artefacts on the volume.
What the upload does not do
- Enable. Installed plugins land disabled. No
register()orboot()runs. The operator must toggle the plugin on and restart the node, exactly like a manually-dropped-in plugin. This matches the Disabled by default stance. - Replace an existing plugin. Re-uploading a plugin whose
nameis already installed returns409 already_installedwith a message pointing at the upgrade flow. To replace the plugin while preserving its data, the GUI offers a confirm modal that re-routes the same zip throughpluginsUploadAsUpgrade. See Upgrading Plugins for the full story. (Operators who do want a destructive replace — losing the plugin’s DB tables, credentials, and gateway token — uninstall first and then upload as a fresh install.) - Bypass signature enforcement. If
PLUGIN_SIGNATURE_MODEis set torequire, the upload path applies the same verifier as the loader.
Response shape
{
"success": true,
"plugin_id": "my-plugin",
"version": "1.2.3",
"signature": {
"status": "ok",
"key_fingerprint": "sha256:…",
"enforced": true
},
"enabled": false,
"restart_required": false,
"message": "Plugin uploaded and staged as disabled. Enable it and restart the node to activate."
}
signature.status mirrors PluginSignatureVerifier::verify(): ok,
unsigned, untrusted_key, bad_signature, malformed_sig,
malformed_manifest, or not_checked when no verifier is wired.
Errors
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_upload |
No file, partial upload, PHP UPLOAD_ERR_*, or forged tmp_name |
| 400 | invalid_zip |
Magic-byte / zip-slip / oversize / bad-extension / manifest failures |
| 409 | already_installed |
Target directory already exists |
| 500 | install_unavailable |
Service not wired (early-boot / no-wallet state) |
| 500 | install_failed |
Filesystem failure, signature required but verification failed, etc. |
Trust boundary
Every check above is mechanical. None of them can answer “should I trust this plugin’s code?” — that is a human decision, and a plugin you trust enough to enable runs at PHP-FPM privilege (No sandboxing). Before enabling an uploaded plugin:
- Read the manifest.
- Read the changelog (the detail modal renders it).
- Note the signature status —
okproves only that the plugin was signed by a key intrusted-keys/, not that the code is safe. - If you’re running with
database.userdeclarations, review them — enabling triggersCREATE USER/GRANT.
Upgrading Plugins
Replacing a plugin’s on-disk code with a newer version preserves the operator’s state — MySQL tables, plugin user, credentials, the gateway bearer token. Doing this through uninstall-then-install would lose all of that (DROP TABLE for every owned table, DROP USER, delete credential row), which is why upgrade is a separate flow with its own service and its own end-to-end test coverage.
Four paths feed into the same engine:
| Path | When it fires |
|---|---|
GUI zip upload (pluginsUploadAsUpgrade) |
Operator uploads a newer-version .zip of an already-installed plugin and confirms the replace in the “Replace v{current} with v{new}?” modal. |
CLI zip upgrade (eiou plugin upgrade <zip-path>) |
Operator runs the CLI with a path-shaped argument (anything that doesn’t match the kebab-case plugin-name regex). Routes through PluginUpgradeService::upgradeFromZip. Same engine as the GUI zip upload, no confirmation modal — the verb itself is the confirmation. |
Bundled (eiou plugin upgrade <name> / pluginsUpgrade) |
Operator explicitly triggers an upgrade to the image-baked version via CLI (with a name argument) or the GUI “Upgrade available” badge. |
Auto on boot (Application::autoUpgradeBundledPlugins) |
The image (/app/plugins/<name>/) ships a newer version than the operator’s plugins volume holds; runs before discover() so the rest of boot operates on the post-upgrade manifest. No operator action required — image swap → container restart → upgrade. |
All four routes call the same PluginUpgradeService and produce the same
step-status envelope.
What the upgrade flow does
- Validate the new bundle. Zip path runs the same magic-bytes /
size cap / entry walk / manifest / signature checks that
pluginsUploadruns. Bundled path validates the on-disk/app/plugins/<name>/plugin.jsonshape but skips zip ceremony. - Read the old manifest. Refuses if the plugin isn’t installed (this is upgrade, not install).
- Version compare via
version_compare(). Refuses:- Equal versions — nothing to do.
- Downgrades — the operator would have to uninstall + install explicitly to acknowledge the destructive intent.
- Honour
min_upgradable_fromin the new manifest. If the installed version is below that floor, the operator gets a clear error telling them to install an intermediate version first (or to uninstall + reinstall and accept the data loss). - Snapshot old → backup. The current plugin dir is renamed to
<pluginDir>/<name>.backup-<oldver>-<YYYYMMDD-HHMMSS>/next to the live plugin. Kept for 30 days by default (BACKUP_RETENTION_DAYS); the boot reconcile prunes anything older. - Swap new in. Staged dir renamed into the canonical location. On rename failure, step 5’s snapshot is restored — the operator’s plugin doesn’t disappear.
onUpgrade()hook. If the new entry class implementsEiou\Contracts\UpgradablePlugin, itsonUpgrade(ServiceContainer, $oldVersion, $newVersion)runs with the old MySQL grants still active (so the plugin can read/transform its existing data via the unchanged plugin user) and the new code loaded ($thisresolves to the new entry class). A thrown exception triggers full rollback to the backup snapshot; the failed bundle is preserved at<name>.failed-<ts>/for post-mortem.- Reconcile MySQL grants. REVOKE ALL clears the old set in one
statement; GRANT per-table from the new manifest’s
owned_tablesrebuilds the new set. Handles both growth and shrinkage. The plugin’s MySQL user itself is unchanged. - Re-export the sibling-mountable credentials file if the plugin is currently enabled. Picks up any manifest-driven changes to the file shape across versions.
- Reload the FPM pool if the plugin is currently enabled — triggers the supervisor’s SIGUSR2 to FPM so workers pick up the new on-disk code rather than continuing to hold stale class definitions. Disabled plugins skip this step; their next enable runs through the normal enable path which loads the new code.
- Fire
PluginEvents::PLUGIN_UPGRADEDwith{name, old_version, new_version, source}. Only fires on full success; subscribers never observe a “half upgraded” state because partial failures throw before reaching the dispatch site. Delivery is unordered and reaches only this process — seePluginEventsfor what it does and does not guarantee.
UpgradablePlugin hook
Plugins that need cross-version data migration implement
Eiou\Contracts\UpgradablePlugin.
Your hook MUST be idempotent. It is not exactly-once, and it is not atomic with the rest of the upgrade.
The hook runs at step 6, before grant reconciliation (7), credential re-export (8) and pool reload (9). If any of those later steps fails, the upgrade rolls back — but rollback restores only the directory and the MySQL grants. Nothing undoes what your hook did to your own tables or to any external system. The plugin is then back on the old version with data the new version migrated.
The operator’s natural next move is to retry the upgrade, which runs your hook a second time over data it has already transformed.
Two rules follow, and neither is optional:
- Make every migration re-runnable. Guard on the current shape (
IF NOT EXISTS, check before backfilling,UPDATE … WHERE not_yet_migrated) rather than assuming the pre-upgrade state.- Make it safe to be rolled back over. Prefer additive changes the old version tolerates — add a column, don’t rename one out from under it — so a rollback leaves a database the old code can still read.
If you need a true transaction, open one inside your hook around your own statements. The host does not and cannot provide one that spans your tables and its filesystem swap.
use Eiou\Contracts\UpgradablePlugin;
use Eiou\Services\ServiceContainer;
class MyPlugin implements UpgradablePlugin
{
public function getName(): string { return 'my-plugin'; }
public function getVersion(): string { return '1.1.0'; }
public function register(ServiceContainer $c): void { /* ... */ }
public function boot(ServiceContainer $c): void { /* ... */ }
public function onUpgrade(
ServiceContainer $container,
string $oldVersion,
string $newVersion
): void {
// Sandboxed plugins receive this hook through an
// `__dispatch.php` envelope with `type: "upgrade"`. Run schema
// migrations against the plugin's own PDO (constructed inside
// the pool from `/etc/eiou/credentials/plugin-<id>.json`, see
// *Running queries*); the `$container` argument is retained
// on the interface for signature compatibility but the in-pool
// ServiceContainer cannot reach the master key or
// `dbconfig.json`.
if (version_compare($oldVersion, '1.1.0', '<')) {
// From __dispatch.php on a "type: upgrade" envelope:
//
// pluginPdo($log)->exec(
// 'ALTER TABLE plugin_my_plugin_keys '
// . 'ADD COLUMN expires_at INT NULL'
// );
}
}
}
Plugins that don’t need migration simply don’t implement the interface — the upgrade flow handles the directory swap, grant reconcile, and pool reload automatically.
min_upgradable_from manifest field
An optional declaration in the new manifest that refuses upgrades from a version below the declared floor:
{
"name": "my-plugin",
"version": "2.0.0",
"min_upgradable_from": "1.0.0",
...
}
When set, the upgrade service refuses transitions whose
$installedVersion < $minUpgradableFrom per version_compare(). Use
this when v2.0 ships a schema migration that assumes the v1.0 schema
shape — operators on v0.x must install v1.x first (so v1’s
onUpgrade runs the intermediate migration) before stepping to v2.0.
Backup retention
Upgrade backups live at <pluginDir>/<name>.backup-<oldver>-<ts>/.
The boot reconcile prunes anything older than
PluginUpgradeService::BACKUP_RETENTION_DAYS (30 days). Operators who
want to preserve a specific backup past the window can rename it out
of the .backup-<ver>-<ts> shape (the prune regex is anchored on
exactly that pattern).
To roll back manually within the retention window:
# Stop the plugin pool so it doesn't see the swap mid-request.
eiou plugin disable my-plugin
sudo rm -rf /etc/eiou/plugins/my-plugin
sudo mv /etc/eiou/plugins/my-plugin.backup-1.0.0-20260513-140000 \
/etc/eiou/plugins/my-plugin
eiou plugin enable my-plugin
(onUpgrade was already idempotent per the contract, so the rollback
side doesn’t need a reverse hook — the old code expects its own
schema shape and operates against it.)
What’s NOT preserved across upgrade
The upgrade flow preserves the plugin user, its credentials, its owned tables, and its gateway token. It does not preserve:
- The plugin’s PSR-4-autoloaded class instances inside the FPM workers. The supervisor reload recycles workers so the new code takes effect; any in-memory state in the old workers is lost.
- The plugin’s on-disk scratch space if the new manifest’s
open_basedir paths differ from the old (rare; scratch is keyed on
eiou-p-<system_user>which is plugin-id-derived, not version- derived). - Custom additions a plugin made to its own directory at runtime that weren’t in the new bundle. The swap is wholesale — anything in the old dir that isn’t in the new bundle ends up only in the backup snapshot.
If your plugin writes runtime state into its own directory (rare;
DB-backed state is the supported pattern), include a stub in the
new bundle that triggers regeneration on first request, or stage
the migration via onUpgrade.
Surface summary
| Surface | Drives | Notes |
|---|---|---|
| GUI: “Upgrade available” badge | pluginsUpgrade → upgradeFromBundle() |
Row on a plugin appears with badge when image’s bundled version > installed. |
| GUI: Zip upload of installed plugin | pluginsUploadAsUpgrade → upgradeFromZip() |
After pluginsUpload returns 409 already_installed, operator confirms the replace. |
CLI: eiou plugin upgrade <name> |
upgradeFromBundle() |
Same logic as the GUI badge, accessible without the wallet’s web pool. |
Direct PHP: PluginUpgradeService |
both methods | For test-harness and bundled scripts that don’t go through CLI/GUI. |
Managing Plugins in the GUI
The Plugins section lives under Settings → Plugins.
Plugins table
Each discovered plugin appears as a row:
| Column | Shown on | Contents |
|---|---|---|
| status dot | always | Green = enabled and running · grey = disabled · red = failed to load (under the Western status-colour scheme; the Neutral scheme in Settings collapses green and red to grey so the dot only distinguishes disabled-vs-other states) |
| name | always | Matches the manifest name |
| version | always | Matches the manifest version |
| description | desktop only | Manifest description, truncated with ellipsis (full text on hover or in modal) |
| Enabled | desktop only | Toggle switch — flips the persisted enabled flag |
On mobile (viewport ≤ 600px), description and the toggle column are hidden — tap the row to open the detail modal, which carries the toggle inside.
Detail modal
Clicking any row opens a detail modal showing:
- Version (with
· <license>suffix if license is set) - Status badge
- Author (linked if a URL was provided in the manifest)
- Website (if
homepageset) - Changelog (bundled file button, or external URL, see above)
- Description (full, not truncated)
- Enabled toggle
- Error block (red alert) if the plugin failed to load
- Uninstall button — shown only when the plugin is disabled. Clicking it opens a second red-accented modal that requires typing the plugin’s name verbatim to confirm. On submit the modal shows the per-step uninstall result (✓ ok, − skipped, ✕ error) inline. The UI forces the two-step “disable → confirm uninstall” flow; the service refuses to uninstall an enabled plugin regardless of how the request arrives. See Database Isolation → Uninstall.
Uploading a .zip
The section header carries an Upload .zip button next to Refresh.
Clicking it opens the OS file picker; selecting a .zip POSTs it to the
pluginsUpload action.
Three outcomes:
-
Fresh install. The plugin’s
nameisn’t already on disk. The new plugin appears in the table as disabled (grey dot); the toast carries the signature status (ok,unsigned, etc.). To activate, toggle the plugin on and use the restart banner. -
Plugin already installed. The server returns
409 already_installed. The GUI catches the 409 and pops a confirm modal showing the installed version and asking whether to upgrade. On confirm, the same zip is re-POSTed topluginsUploadAsUpgrade, which drives the upgrade flow (atomic swap,onUpgradehook, grant reconcile, pool reload) — the plugin’s DB tables, credentials, and gateway token are preserved. See Upgrading Plugins. -
Bundled-version newer than installed. When
/app/plugins/<id>/ships a higherversionthan the operator’s/etc/eiou/plugins/<id>/, the row in the table surfaces an Upgrade available affordance. Clicking it POSTs topluginsUpgrade(no zip — drives the bundled upgrade path), same engine aspluginsUploadAsUpgrade.
The full validation contract and threat model for fresh installs is documented in Installing Plugins; the upgrade flow lives in Upgrading Plugins.
Restart banner
A yellow banner appears above the table when the desired enabled state diverges from the runtime state for any plugin. “Divergence” is computed per plugin as:
enabled !== (status === 'booted')
So toggling a plugin on and then back off again — landing on the same state as before — clears the banner: no restart is actually needed, and the UI reflects that. The success toast copy mirrors the same logic: “restart the node for the change to take effect” when divergent, “matches the current runtime, no restart needed” when not.
Managing Plugins from the CLI
Five subcommands. Toggle / uninstall don’t restart on their own; the
operator follows up with eiou restart once they’re done. The
upgrade path does recycle the plugin’s FPM pool (so the new code
takes effect immediately for enabled plugins) but a wallet restart is
still required for the new code’s event subscriptions and other
manifest-declared surfaces to bind in the wallet pool’s IPC forwarder.
eiou plugin list
Prints a compact table of every installed plugin: name, version, enabled
flag, runtime status, license. --json emits the full
listAllPlugins() payload so scripts see author, homepage, changelog, and
description without a schema split.
eiou plugin
eiou plugin list --json
eiou plugin enable <name> / eiou plugin disable <name>
Persists the enabled flag in /etc/eiou/config/plugins.json. Rejects
unknown plugin names (scoped against the on-disk listing) and names that
don’t match the kebab-case regex — no ../ traversal, no arbitrary state
keys. On success emits a reminder to eiou restart.
For plugins that declare database.user: true, enable triggers
CREATE USER + GRANT in MySQL; disable triggers REVOKE. Credentials
are generated on first enable and persisted encrypted. See
Database Isolation.
eiou plugin reconcile
Force a sandbox reconcile pass immediately: ensure each enabled plugin’s
FPM pool socket exists and its nginx routes match the desired state, namely
the admin /gui/plugin/<id>/ route plus the public /p/<id>/ route when
public routes are permitted (EIOU_PUBLIC_PLUGIN_ROUTES is on, or allow
with the plugin’s own toggle on). Prints how many plugins were applied,
skipped (already up to date), or dropped.
eiou plugin reconcile
Idempotent and flock-guarded, so it is safe to run at any time and safe to run alongside the node’s own boot reconcile. The node runs this automatically near the end of startup (after the routing pollers are up, before it reports ready) so plugin sockets and routes exist before any traffic; operators rarely need it by hand, but it is useful to re-apply routes after a manual config edit or to confirm the sandbox state.
eiou plugin public-routes <name> on|off
Turn a plugin’s public HTTP routes (/p/<id>/<action>) on or off. This is the
CLI equivalent of the Public routes toggle on the plugin’s row in
Settings → Plugins and of POST /api/v1/plugins/<name>/public-routes. It only
has a live effect when the node’s EIOU_PUBLIC_PLUGIN_ROUTES ceiling is
allow (under on every plugin is already live; under off none are). The
change re-renders the nginx config and reloads, so it takes effect immediately.
Turning the toggle on under an off ceiling still records the preference, but
it does not arm the plugin to go live when the ceiling is later raised — see
Raising the ceiling to allow never publishes a
route.
eiou plugin public-routes my-plugin on
eiou plugin public-routes my-plugin off
If the node ceiling is off, the command saves the preference but reports that
nothing is live yet, and that lifting a node-level lock means changing the
container environment (or asking the host on a hosted node). See
Public routes.
eiou plugin install <zip-path>
Install a plugin from a .zip file on the container’s filesystem. The
CLI runs inside the wallet container, so operators usually docker cp
the zip into /tmp/ first:
docker cp my-plugin.zip alice:/tmp/my-plugin.zip
docker exec alice eiou plugin install /tmp/my-plugin.zip
Runs the same validation pipeline the GUI’s pluginsUpload uses —
magic bytes, entry walk, manifest, signature verification per the
configured mode — then atomic-renames the staged tree into
/etc/eiou/plugins/<name>/. The plugin lands disabled; follow up
with eiou plugin enable <name> and a node restart to activate.
Refused for:
- Missing or unreadable zip file (error includes a
docker cphint) - Plugin id already installed — error carries the on-disk version,
the would-be-installed version, and points at
eiou plugin upgrade <zip-path>for the replacement path. The CLI deliberately does NOT auto-route to upgrade on collision (the GUI’s confirmation modal is what guards that path; the CLI’s analog is making the operator type a different verb) - Anything the install service rejects — malformed zip, manifest
validation, signature failure in
requiremode
eiou plugin install /tmp/my-plugin.zip
eiou plugin enable my-plugin
eiou restart
eiou plugin uninstall <name>
Runs the full uninstall flow — onUninstall() hook, REVOKE, DROP TABLE
for every owned table, DROP USER, credential deletion, plugin-directory
removal, and state-file cleanup. The plugin must be disabled first;
the CLI returns an error otherwise (match the REST 409 Conflict).
eiou plugin uninstall my-plugin
Per-step status is printed in the JSON response (ok / skipped /
error:<msg>) so operators can see exactly what succeeded. This is a
permanent action — a fresh install issues new credentials, new tables,
and a new MySQL user.
eiou plugin upgrade <name|zip-path>
Replaces the installed plugin code with a newer version. Two argument shapes feed into the same engine:
<name>(strict kebab-case): upgrades to the image-baked version under/app/plugins/<name>/viaPluginUpgradeService::upgradeFromBundle. Refused unless the bundled version is strictly newer than installed.<zip-path>(slashes, dots, anything else): upgrades to the version inside the operator-supplied zip viaPluginUpgradeService::upgradeFromZip. Runs the same validation pipeline the GUI upload uses before swapping. Operators usuallydocker cpthe zip into/tmp/first, then pass the in-container path. Detection happens automatically — anything matching^[a-z0-9][a-z0-9-_]{0,63}$is treated as a plugin name; anything else is treated as a path.
Either way the plugin’s state (MySQL tables, plugin user, credentials,
gateway token) is preserved across the upgrade — the directory swap
is atomic, the old version is snapshotted to
<name>.backup-<oldver>-<ts>/ next to the live plugin, the plugin’s
onUpgrade(...) hook (if implemented) runs against the new code with
the old grants still active, grants are reconciled against the new
owned_tables, and the FPM pool reloads so workers pick up the new
code.
Refused for:
- Same version (
error: Plugin '<name>' is already at version X.Y.Z) - Downgrades (
error: Refusing downgrade of '<name>' from A to B …) min_upgradable_fromviolations — the new manifest declares a floor and the installed version is below it- Bundled path: no bundled version on disk
- Zip path: missing / unreadable file (error includes a
docker cphint)
# Bundled (image-baked) upgrade
eiou plugin upgrade hello-eiou
# Zip-based upgrade (operator-supplied)
docker cp hello-eiou-1.6.zip alice:/tmp/hello-eiou-1.6.zip
docker exec alice eiou plugin upgrade /tmp/hello-eiou-1.6.zip
Backups are pruned automatically after 30 days by the boot reconcile.
See Upgrading Plugins for the full flow, the
UpgradablePlugin hook contract, and the manual rollback recipe.
eiou plugin enable hello-eiou
eiou plugin disable hello-eiou
eiou restart # once you're done toggling
Managing Plugins over the REST API
Endpoints under the admin scope. Same semantics as the CLI: toggles
persist but do not restart. Pair with POST /api/v1/system/restart (same
scope) when you’re ready to apply.
| Method | Path | Action |
|---|---|---|
| GET | /api/v1/plugins |
List installed plugins |
| POST | /api/v1/plugins/{name}/enable |
Set enabled = true |
| POST | /api/v1/plugins/{name}/disable |
Set enabled = false |
| DELETE | /api/v1/plugins/{name} |
Uninstall (must be disabled) |
DELETE /api/v1/plugins/{name} returns 409 Conflict if the plugin is
still enabled, 404 Not Found if unknown, 200 with success: true on a
fully-clean uninstall, and 200 with success: false + the per-step map
if any step reported an error. See
Database Isolation → Uninstall for the full step sequence.
Install and zip-upgrade are GUI- and CLI-only today; the REST API does
not expose them. The GUI uses pluginsUpload, pluginsUploadAsUpgrade,
and pluginsUpgrade AJAX actions against the admin-authenticated session
(see Managing Plugins in the GUI). The CLI
offers eiou plugin install <zip-path> for fresh installs and
eiou plugin upgrade <name|zip-path> for both bundled and zip upgrades
(see eiou plugin install and
eiou plugin upgrade). REST API
install / upgrade is deferred pending the auth-scope decision —
a compromised admin-scope key today can toggle existing plugins
(matching what the CLI/GUI allow) but cannot introduce new plugin code,
and widening the blast radius to “API can install arbitrary code”
deserves its own scope (e.g. plugin:install) rather than slipping into
the existing admin umbrella.
Example
# List
curl -s -H "X-API-Key: $KEY" https://localhost/api/v1/plugins
# Enable + restart
curl -s -X POST -H "X-API-Key: $KEY" https://localhost/api/v1/plugins/hello-eiou/enable
curl -s -X POST -H "X-API-Key: $KEY" https://localhost/api/v1/system/restart
Response shape
GET /api/v1/plugins returns the same per-plugin objects as the GUI’s
pluginsList action — including the optional metadata fields:
{
"success": true,
"data": {
"plugins": [
{
"name": "hello-eiou",
"version": "1.0.0",
"description": "...",
"enabled": true,
"status": "booted",
"author": {"name": "EIOU", "url": "https://eiou.org"},
"homepage": "https://github.com/...",
"license": "Apache-2.0",
"has_changelog": true
}
]
}
}
POST /api/v1/plugins/{name}/{enable,disable} returns:
{
"success": true,
"data": {
"plugin": "hello-eiou",
"enabled": true,
"restart_required": true,
"message": "Plugin state persisted. POST /api/v1/system/restart to apply."
}
}
Errors: 400 invalid_name (regex mismatch), 404 unknown_plugin
(not on disk), 500 persist_failed (state file unwritable),
500 plugin_loader_unavailable (the plugin system didn’t initialize
during boot — usually means the node is in an incomplete state).
Sandboxed Plugin Authoring
Plugins can opt into running in an isolated PHP-FPM pool as their own
Unix user, with no access to /etc/eiou/config/ (and therefore no
access to the master key, the seed phrase, the encrypted private key,
or any other wallet secret). Set "sandboxed": true in the manifest.
The full architecture lives in this document; the subsections below walk through what you need to know as a plugin author.
Sandboxing is mandatory
All plugins must declare "sandboxed": true in their manifest. The
in-process plugin model has been removed — non-sandboxed plugins
could read the master key and decrypt the seed phrase, so the loader
refuses to load them. Operator-facing behaviour:
| Manifest | At install | At enable | At boot |
|---|---|---|---|
"sandboxed": true |
Accepted | Accepted | Pool spawned, IPC plumbed |
Missing flag or "sandboxed": false |
Rejected with sandboxed_required |
Rejected — setEnabled returns false |
legacy_unsupported status, auto-flipped to disabled in plugins.json |
Plugins run in their own per-plugin PHP-FPM pool as their own Unix
user (eiou-p-<hash>), with open_basedir restricted to the
plugin’s dir + scratch, disable_functions blocking shell-out and
eval, and zero filesystem access to wallet secrets.
Manifest surfaces a sandboxed plugin declares
Everything a plugin would have wired in boot() becomes manifest
metadata:
{
"name": "my-plugin",
"version": "1.0.0",
"entryClass": "Vendor\\MyPlugin\\Entry",
"autoload": { "psr-4": { "Vendor\\MyPlugin\\": "src/" } },
"sandboxed": true,
"subscribes_to": ["sync.completed", "contact.created"],
"render_hooks": ["gui.dashboard.after"],
"filter_hooks": ["gui.dashboard.widgets"],
"plugin_tab_panel": {"label": "My Plugin", "icon": "fas fa-puzzle-piece"},
"gui_actions": [{"name": "myPluginAction", "tier": "csrf"}],
"gui_assets": [{"type": "css", "path": "assets/styles.css"}],
"api_routes": [{"method": "GET", "action": "fortune"}],
"cli_commands": [{"name": "my-plugin"}],
"core_services": ["Logger.info", "Logger.warning"]
}
Core reads each list at boot and registers IPC forwarders that route
each surface into the plugin’s __dispatch.php. The plugin’s own
PHP code never executes in-process.
The __dispatch.php contract
The plugin ships one PHP file as its sandboxed entry point:
<plugin-dir>/__dispatch.php. nginx is configured to route
/gui/plugin/<plugin-id>/* to the plugin’s FPM socket with
SCRIPT_FILENAME pinned to that file — the plugin never picks its
own entry.
Wire shape (request):
POST /gui/plugin/<id>/__dispatch HTTP/1.1
Content-Type: application/json
{
"type": "event" | "filter" | "render" | "action" | "rest" | "cli" | "lifecycle",
"name": "<event_name | hook_name | action_name | route_action | command | on_enable>",
"context": <type-specific payload>
}
Response:
{
"ok": true,
"result": <type-specific>,
"_log": [
{"level": "info", "message": "...", "context": {...}},
...
]
}
The _log array is copied into the wallet’s central log under the
plugin’s name when the dispatch returns — so log lines emitted by
the plugin appear next to wallet log lines for the same operation,
even though the plugin couldn’t write /var/log/ directly.
ok is a transport-level flag, not your command’s success. The
host relays your result to the caller only when the dispatch
response is HTTP 200 and "ok": true. A non-2xx response, or one
carrying "ok": false, is read as a failed dispatch (a plugin
malfunction): the host logs it, the caller gets a generic 502 / “did
not respond”, and your result is discarded. So a handled outcome
that happens to be a failure (an unreachable upstream, a rejected
key, a validation error) must still return "ok": true (HTTP 200)
and carry the failure inside result:
- For
clicommands: setresult.exit_codeto a non-zero value and put the message inresult.stderr; the CLI bridge prints the stderr and exits non-zero. (result.stdoutwithexit_code: 0is the success shape.) - For
gui_actions: returnresult: {"success": false, "message": "..."}; the wallet renders the message as an error toast. ({"success": true, "message": "..."}is the success shape.)
Reserve "ok": false (or a non-2xx HTTP status) for cases where the
dispatcher genuinely could not handle the envelope. For filter and
render hooks specifically, "ok": false means “no usable result”
and the host falls back (the filter value passes through unchanged,
the render slot stays empty).
Use the bundled template at
files/src/templates/plugin-dispatch-template.php as the starting
point for your dispatcher. The template carries a
PLUGIN_DISPATCH_VERSION constant — bump it in lockstep with
upstream so the wallet can warn operators if your plugin’s
dispatcher is older than the current contract. The current contract is
version 6, which added the lifecycle envelope type (see the
on_enable hook); a dispatcher that omits
lifecycle from its allowed-type whitelist answers the hook with 400 bad_envelope.
Public routes — non-admin HTTP under /p/<plugin-id>/<action>
The IPC contract above only carries internal core→plugin traffic
(events, filters, REST routes from authenticated admin callers). A
plugin that wants to serve non-admin customers — anything where
the caller holds a per-customer bearer token rather than the wallet’s
admin session — declares public_routes in its manifest. Each
declared route is routed by nginx directly to the plugin’s FPM pool
under a separate URL prefix /p/<plugin-id>/<action> that lives
outside the admin gate.
The feature is gated in two places: a node-level ceiling in the container
environment, and a per-plugin toggle the operator flips. No public route is
served until both permit it, and on a node nobody has configured, the
per-plugin toggle is what is holding it back. EIOU_PUBLIC_PLUGIN_ROUTES is
the ceiling and takes three values:
| Value | Meaning |
|---|---|
unset / allow |
Permitted (default). Public routes are allowed, but each plugin’s routes stay dark until the operator turns that plugin’s own toggle on. A fresh node therefore serves no public routes; what the default grants is the ability to turn one on without recreating the container. |
off |
Locked off. No public routes, and the node’s own admin cannot enable any. This is a hosting control: whoever controls the container env (someone running the node on a tenant’s behalf) can refuse to let that tenant expose public HTTP under their address. Set it deliberately, since the tenant cannot lift it. |
on / true |
Force-all. Every enabled plugin’s public_routes are live (the legacy behaviour). The per-plugin toggle is locked in this mode: the GUI renders a read-only “on (node-forced)” badge instead of a switch, and the REST response reports forced_by_node: true (with live: true) so a client knows the per-plugin preference is recorded but currently overridden. |
An unrecognised explicit value (disabled, none, a typo) resolves to off,
not to the default: a mistyped lock has to fail closed. Unset and blank are not
attempts to configure anything, so they take the default.
Under allow, the operator flips a plugin’s routes on or off from any of:
- the Public routes toggle on the plugin’s row in Settings → Plugins,
eiou plugin public-routes <plugin-id> on|off(CLI),POST /api/v1/plugins/<plugin-id>/public-routeswith{"enabled": true|false}(REST).
The per-plugin preference is persisted, so it survives restarts. With the ceiling locked off, the nginx renderer skips public-route blocks (the manifest field still validates, but no requests reach the plugin); a GUI-only operator on a hosted node is told the lock is node-level and that only their host can lift it, since they can’t edit the container env themselves.
Raising the ceiling to allow never publishes a route
The API and CLI persist the per-plugin preference under any ceiling, including
off. Under allow, that preference alone is not what puts a route in the
nginx config: a plugin’s routes go live only if the toggle was turned on while
the node permitted public routes, and only while the plugin still declares
the same validated route surface. The node records both the permitting ceiling
and a stable route-manifest fingerprint alongside the preference.
Without that, moving the ceiling to allow would publish every
already-persisted preference in the same instant — a host lifting an off lock
would hand their tenant live public endpoints they never re-approved, and a node
upgrading onto the allow default would start serving /p/<plugin>/<action>
with no operator action at all. The operator who last touched such a toggle
flipped it on a node where doing so was inert, and so never made a decision
about a node that serves it.
onis the exception, by definition. This section is aboutallow. Setting the ceiling toonis the force-all switch: it serves every enabled plugin’s declaredpublic_routesand does not consult the per-plugin toggle or anything recorded alongside it. Anoff→onorallow→onchange therefore does publish routes immediately, which is the whole meaning of that value. If you want per-plugin control, useallow.
Two consequences to know about:
- Turning the toggle on under an
offlock is recorded but inert, and stays inert when the lock is lifted. Flip it once more on the now-permitting node and it sticks from then on. - Upgrading a node that had public routes on keeps them dark until they are
turned on again. Preferences persisted before the node recorded this have no
record of the ceiling they were given under, so they are not treated as
consent. Re-enabling is one click on the plugin’s row in Settings → Plugins
(or one
eiou plugin public-routes <plugin-id> on), and the toggle behaves normally afterwards.
For the same reason, a plugin installed into an id that a previous plugin
had turned on does not inherit that decision: install clears the preference, so
new code always starts dark. Turning the toggle on for a plugin that declares no
public_routes records nothing either — a plugin cannot collect an approval
today and ship a version that declares public routes tomorrow. An upgrade keeps
routes on only when the validated public_routes surface is unchanged. Adding
or changing a route, authentication field, rate limit, body limit, or CORS
allow-list invalidates the old approval and keeps the plugin dark until the
operator turns it on again. Formatting, declaration order, and CORS-origin
order do not invalidate approval because they do not change the effective
surface.
The ceiling is resolved once at boot and the resolved literal is exported into
the container environment before the FPM pool’s environment passthrough is
written. No later context has to apply the default itself, which matters most
for the off lock: the www pool runs with clear_env = yes, and an off that
failed to reach the pool would otherwise have an FPM-context reconcile read the
variable as unset, take the default, and render a locked node’s routes back on.
The flag is read consistently in every context (CLI, message processors,
and the wallet’s GUI/API FPM workers): startup passes the node’s
environment-derived settings through to the www FPM pool, so a reconcile
that happens to run inside an FPM worker renders the public route the same
way a CLI reconcile does. Combined with the synchronous reconcile at the
end of boot, the /p/<id>/ route is live before traffic and is not dropped
by a later eiou restart. If a plugin’s expected public route ever goes
missing from the live config, the next reconcile detects the drift and
re-applies it.
Manifest shape:
{
"sandboxed": true,
"public_routes": [
{
"method": "POST",
"action": "chat",
"auth": "bearer",
"rate_per_minute": 60,
"max_body_bytes": 65536,
"cors_allowed_origins": [
"https://example.com",
"https://app.example.com"
]
}
]
}
Per-entry fields:
| Field | Required | Default | Notes |
|---|---|---|---|
method |
yes | — | One of GET / POST / PUT / PATCH / DELETE. Pinned — wrong verb returns 405 without invoking the plugin. |
action |
yes | — | Kebab-case, ^[a-z][a-z0-9-]{0,63}$. Becomes the final URL segment. |
auth |
no | "bearer" |
Only "bearer" is supported today. Host validates the Authorization header shape; the plugin does the real auth check. |
rate_per_minute |
no | 60 |
Per-bearer-per-route rate cap. Bounded [1, 6000]. Enforced server-side via nginx limit_req_zone. |
max_body_bytes |
no | 65536 |
Request body size cap, in bytes; bounded [1, 8388608] (8 MiB). A larger or non-positive value falls back to the 65536 default. Raised from 1 MiB so inference routes can accept large prompts / base64 image inputs. When several methods share one action (e.g. POST + DELETE on mcp), the smallest cap among them applies to the whole location, so give a body-less companion method (like DELETE) the same cap rather than a tiny one. |
cors_allowed_origins |
no | — | List of explicit origin strings. No wildcard *. Max 10 entries. When present, the host emits CORS headers and short-circuits OPTIONS preflight. |
What the host does, before any plugin code runs:
- Method gate — wrong verb → 405.
- Bearer shape preflight —
Authorization: Bearer [A-Za-z0-9._~+/=-]{8,256}is the only shape accepted. Anything else → 401. The plugin still does the real credential check against its own state; this just refuses values that obviously can’t be a token. - Rate limit — one
limit_req_zoneper route at http{} scope, keyed on$http_authorization(so the window is per-bearer-per-route, not per-IP — a single misbehaving customer can’t starve well-behaved ones sharing the same plugin). Excess requests → 429. - Body size cap —
client_max_body_sizeper route. Excess → 413. - Cookie strip — the host blanks
HTTP_COOKIEbefore the request reaches your pool. Public routes authenticate by bearer, never by cookie; and because the wallet’s own session cookie isPath=/, a browser already logged into the operator GUI would otherwise send (and leak) that cookie to your pool on a same-origin/p/request. So$_COOKIE/$_SERVER['HTTP_COOKIE']are always empty on a public route — do not design cookie-session auth here; put per-customer auth in theAuthorization: Bearerheader. - CORS (only when
cors_allowed_originsis declared) —OPTIONSpreflight returns204with the appropriateAccess-Control-*headers without invoking the plugin; cross-origin requests from non-allow-listed origins get an emptyAccess-Control-Allow-Originvalue, which the browser’s same-origin check then refuses to surface to the calling page. The host alsofastcgi_hide_header’sAccess-Control-*andVaryheaders from the plugin’s response — a plugin handler that emitted its ownAccess-Control-Allow-Origin: *can’t fight the allow-list (browsers reject duplicate ACAO anyway, but stripping the upstream value keeps the host’s intent authoritative). Don’t bother setting CORS headers from your handler whencors_allowed_originsis configured; they’ll be dropped. - Lock-down Content-Security-Policy — the host adds a CSP to every public-route response (with
always, so it covers the 401/405/429 early returns too) that pins every fetch directive to'self':default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self', plusX-Content-Type-Options: nosniffandReferrer-Policy: no-referrer. If your public route returns an HTML page, it cannot pull an external font, image, script, or stylesheet — each such request is an outbound beacon that would leak the visitor’s presence and timing and, over Tor, help deanonymize them. Bundle any assets and serve them from your own same-origin routes; inline<script>/<style>within your own page is still allowed (the restriction is on external origins, not your own inline code).
What the plugin sees in __dispatch.php:
The dispatcher template detects public-route invocations via the
EIOU_PLUGIN_PUBLIC_ROUTE=1 fastcgi param and synthesizes an
envelope from the raw HTTP request. From the handler’s point of
view, the wire shape is the same uniform envelope as IPC:
{
"type": "public",
"name": "<action>",
"context": {
"method": "POST",
"bearer": "<the raw bearer token, prefix stripped>",
"body": "<raw request body>",
"remote_ip": "1.2.3.4"
}
}
The same respond($status, $body, $log) helper from the template
sends the response back. Returning JSON works as you’d expect; the
plugin is free to set its own content type via PHP’s header() if
it needs to.
A minimal public-route handler:
// inside the dispatch switch
case 'public':
if ($name !== 'chat') {
respond(404, ['ok' => false, 'error' => ['code' => 'unknown_action']], $log);
}
$bearer = $context['bearer'] ?? '';
$customer = $myKeysTable->lookupByBearer($bearer); // your own state
if ($customer === null) {
respond(401, ['ok' => false, 'error' => ['code' => 'invalid_token']], $log);
}
$body = json_decode($context['body'] ?? '', true);
// … do the work, charge the customer …
respond(200, ['ok' => true, 'result' => $result], $log);
break;
Authoring constraints to keep in mind:
- Bearer storage. The host doesn’t know what a valid bearer is — that’s plugin state. Store bearer hashes (bcrypt / argon2id) in your plugin DB, not plaintext. Match incoming tokens by constant-time compare against the hash.
- Rate limit semantics.
burst=rate_per_minutelets one minute of capacity absorb a small retry storm without 429’ing every retry;nodelaymeans excess requests are rejected immediately rather than queued (so a misbehaving client sees 429 fast instead of latency-bombed responses). - The per-bearer limiter is a fairness control, not a DoS control. The
limit_req_zoneis keyed on theAuthorizationvalue, and the edge only checks the bearer shape beforefastcgi_pass, so an attacker rotating distinct shape-valid bearers gets a fresh bucket per token — and each admitted request buffers up to the route’smax_body_bytes(now up to 8 MiB) before the plugin rejects the fake token. The real DoS backstops are the server-levellimit_conn(per source address) and thegenerallimit_reqzone in the node’s nginx config, plus the fact that no route is exposed until an operator turns that plugin’s public routes on. Note that last backstop is the per-plugin toggle, not the node ceiling: the ceiling permits public routes by default, so it is the toggle that keeps an unconfigured node’s/p/surface empty. Over Tor all traffic shares one address, so the connection cap is the effective limit. If you expect untrusted public exposure, add an IP/connection-scoped limit on the/p/locations rather than relying on the per-bearer cap. - No cookies. The customer-bearer auth flow is intentionally stateless — sessions belong to the admin GUI, not customer-facing services. If your plugin needs per-customer state across requests, store it keyed on the bearer (or a customer id derived from it) in your plugin DB.
- CORS is allow-list only. Don’t try to accept arbitrary origins via wildcard — the manifest validator refuses
*and the renderer’s defence-in-depth filter would drop it anyway. Add explicit origin strings; if your plugin’s web frontend moves to a new origin, update the manifest and re-enable. - Companion-container deployment. Some plugins are easier to ship as the eIOU plugin + a sibling Docker container the operator deploys separately (e.g. a heavy provider-client library that doesn’t belong in a PHP-FPM pool). The sibling can authenticate to the plugin’s MySQL user via the on-disk credentials file — see Sibling-container credentials under Database Isolation.
Calling core services from your handler — core_call()
The dispatcher template includes a core_call($service, $method, $args, $log)
helper. It POSTs an authenticated request to the wallet’s gateway
endpoint, which validates:
- The plugin’s bearer token (loaded from
.gateway-tokenin the plugin’s dir, written there by core at enable time). - The plugin’s manifest declares
"<Service>.<method>"incore_services(this manifest gate is operator-visible — operators see which APIs the plugin wants to use before they enable it). - The target method on the core service carries the
#[\Eiou\Contracts\PluginCallable]attribute. This is a per- method allow-list in the core codebase, reviewable ingit grep PluginCallable.
Example:
core_call('Logger', 'info', [
"Sync completed for {$contactPubkey}",
['plugin' => 'my-plugin', 'contact_pubkey' => $contactPubkey],
], $log);
Returns the method’s return value on success, null on failure
(transport error / validation rejection / handler exception).
Failure cases log to $log with structured context.
Plugin-callable surface — policy
The set of methods reachable through core_call() is intentionally
small. As of this writing, the callable surface is:
| Service | Methods |
|---|---|
Logger |
debug, info, warning, error |
TransactionLookupService |
getByTxid, getStatusByTxid, existingTxid, isCompletedByTxid, getByMemo, getStatusByMemo, getReceivedUserTransactions (requires transaction_history_enumerate), getSentUserTransactions (requires transaction_history_enumerate), getTransactionsBetweenPubkeys (requires transaction_history_enumerate) |
TransactionStatisticsLookupService |
getStatsForPeriod (requires transaction_history_aggregate) |
ContactLookupService |
getByPubkeyHash, getByName, getOnlineStatus, listAccepted (requires contact_address_book_enumerate), listPending (requires contact_pending_enumerate) |
ContactCreditLookupService |
getCreditState (requires contact_credit_read) |
BalanceLookupService |
getUserBalance (requires wallet_balance_read), getUserBalanceContact (requires wallet_balance_read) |
PaymentRequestLookupService |
getByRequestId, listPendingIncoming (requires payment_request_enumerate), listOutgoing (requires payment_request_enumerate) |
PaybackMethodLookupService |
getMyConfiguredMethods (requires payback_method_read_own), getContactPaybackPreference (requires payback_method_read_contact) — both strictly scoped to the plugin’s declared payback_method_types |
PluginLookupService |
getOwnPermissions, getOwnManifest (self-introspection — no permission key, scope is the calling plugin’s own row only), listEnabledPluginIds (requires plugin_inventory_read) |
IdentityLookupService |
getPublicKey, getPublicKeyHash, getName |
NodeInfoLookupService |
getAppEnv, isDebug, getHttpsAddress, getTorAddress |
PluginEventPublisher |
publish |
WalletOutboundService |
send (requires wallet_outbound_send) |
WalletRefundService |
refundReceivedTransaction (requires wallet_refund_return_to_sender) |
PaymentRequestService |
create |
ContainerLifecycleService |
startSidecar, stopSidecar |
That’s it. The list grows on demand, when a concrete plugin
needs something — not speculatively. The reasoning is straightforward:
every #[PluginCallable] method is operator-visible attack surface.
Operators reading a plugin’s manifest see the list of methods that
plugin will call; the wider the underlying surface, the harder that
review becomes. A small, deliberately-curated surface keeps the
review meaningful.
Two principles guide what gets added:
-
Default-deny, expand for a concrete need. A repository method is exposed only when a real plugin (in this repo’s plugin directory, or a downstream plugin whose author has filed an issue) has a use case that can’t be satisfied any other way. We don’t preemptively expose “this looks useful” — useful is measured by an actual handler that would call it.
-
Prefer narrow single-row lookups over bulk listings. When a plugin needs to enrich one event payload’s identifier, a
lookupBy<X>(id): ?arraymethod is strictly additive to what the event already told the plugin. AgetAllX(): arraymethod is a different shape of trust — it’s a one-call exfiltration primitive for the underlying table. The first kind is easy to justify case-by-case; the second kind needs a concrete export plugin (or similar) and a deliberate decision.
If a plugin needs a method that isn’t exposed, the path is: file an
issue describing the use case, propose the method signature, and
include the smallest read-only repository method that satisfies it.
The maintainer adds it as a thin Lookup/ service if the use case
holds up. Decoration on the repository itself is not the path —
those classes stay pure data-access.
Permissions — louder consent than core_services
A subset of #[PluginCallable] methods carry a permission: key on
their attribute. Those methods require the calling plugin to declare
the key in a top-level permissions: [...] manifest field, in
addition to the usual core_services entry. The gateway enforces
this as a third gate (after the attribute presence and the
core_services allow-list).
Why the second tier exists: core_services is a per-method
allow-list. That works well for narrow methods where one entry means
roughly what its name suggests — Logger.info, a per-hash lookup, a
send-payment call. It works less well for methods whose trust shape
goes beyond what a single line conveys to a casual reader. The first
example is ContactLookupService.listAccepted, which enumerates
every accepted contact on the wallet (operator-chosen labels + all
transport addresses including .onion) — a different shape of
disclosure than the per-hash getByPubkeyHash on the same service,
but a manifest reader skimming core_services sees two similarly-
shaped lines. Routing the bulk-enumerate method through a separate
permissions entry gives the operator a distinct line item to read
and consent to.
Catalogued keys (single source of truth: PluginPermissionCatalog):
| Key | Granted when set | Required by |
|---|---|---|
contact_address_book_enumerate |
Plugin may list every accepted contact (operator-chosen labels + all transport addresses including .onion). Distinct from per-hash lookups, which only reveal contacts the plugin has already seen via events. |
ContactLookupService.listAccepted |
contact_pending_enumerate |
Plugin may walk the wallet’s pending contact requests (people who have asked to connect but the operator has not accepted or blocked yet). Distinct from the address-book enumerate — pending requests reveal who wants to talk to the operator, not who they already do. | ContactLookupService.listPending |
contact_credit_read |
Plugin may read per-contact credit policy (available credit for a given contact and currency) — operator-set financial policy normally not visible to plugins. | ContactCreditLookupService.getCreditState |
transaction_history_enumerate |
Plugin may walk the wallet’s received- and sent-transaction lists (amounts, currencies, descriptions, counterparty pubkey hashes). Distinct from per-txid lookups, which only reveal transactions the plugin has already learned about through events. | TransactionLookupService.getReceivedUserTransactions, TransactionLookupService.getSentUserTransactions, TransactionLookupService.getTransactionsBetweenPubkeys |
transaction_history_aggregate |
Plugin may read aggregated totals across the wallet’s transaction history (count + total amount for a time window, optionally per-currency). Distinct from the row-level enumerate — aggregates leak volume but not individual counterparties or memos. Dashboard / daily-summary plugins typically need this only. | TransactionStatisticsLookupService.getStatsForPeriod |
wallet_balance_read |
Plugin may read the wallet’s current balance totals (overall and per-currency) and per-contact balances. Discloses the operator’s net financial position. | BalanceLookupService.getUserBalance, BalanceLookupService.getUserBalanceContact |
wallet_outbound_send |
Plugin may spend funds from the wallet (same path as the eiou send CLI). The most consequential permission in the catalog — every call is rate-capped and logged, but within the cap the plugin can move money. |
WalletOutboundService.send |
wallet_refund_return_to_sender |
Plugin may return a payment the wallet has received back to its original sender, in full or in parts. The plugin names the received txid and may optionally name a partial amount; the wallet fixes the recipient (the original sender) and currency, and caps the amount at the un-refunded remainder (the plugin cannot redirect funds or re-price). The total returned for a given payment is capped at the amount received, so it can never refund more than was taken in; every refund is logged. Narrower than wallet_outbound_send: grant it for refund-only plugins without granting free spend. |
WalletRefundService.refundReceivedTransaction |
payment_request_enumerate |
Plugin may walk the wallet’s pending-incoming and outgoing payment-request lists. Per-id lookups by request_id are not gated by this permission — a plugin that minted the request via PaymentRequestService.create already knows its id. |
PaymentRequestLookupService.listPendingIncoming, PaymentRequestLookupService.listOutgoing |
payback_method_read_own |
Plugin may read capability metadata for the operator’s configured payback methods of rail types this plugin itself declared in payback_method_types. Encrypted account identifiers are NOT exposed by this surface. |
PaybackMethodLookupService.getMyConfiguredMethods |
payback_method_read_contact |
Plugin may read capability metadata for a contact’s chosen payback methods of rail types this plugin itself declared. Decrypted account identifiers are NOT exposed by this surface. | PaybackMethodLookupService.getContactPaybackPreference |
plugin_inventory_read |
Plugin may enumerate which other plugins are enabled on this node (name + version). Orchestration plugins use this to branch on whether a companion is installed. | PluginLookupService.listEnabledPluginIds |
Adding a permission key is a deliberate act in both directions:
- In code: annotate the attribute (
#[PluginCallable(permission: "<key>")]) and add the catalog entry (PluginPermissionCatalog::ENTRIES) — the install validator rejects manifests requesting un-catalogued keys, and the gateway returns 503 if an attribute references a key the catalog doesn’t know about. - In a manifest: include the key in
permissions: [...]. Missing the key when the method requires it returns 403 withpermission_not_declaredand a message naming the missing key.
GUI surface: the plugin’s modal in Settings → Plugins renders a
“Permissions requested” panel (before enable) / “Permissions granted”
panel (after enable) listing each key with its catalog label and
description. Operators reviewing a plugin before flipping the toggle
read the panel as a separate line of consent from the routine
core_services list.
Consent gate on enable — declared and approved. The manifest
declaring a permission is necessary but not sufficient. The gateway
requires the calling plugin’s approved_permissions (operator-recorded
consent in plugins.json) to also include the key — 403
permission_not_approved if not. This separation matters because a
plugin upgrade can quietly grow the manifest’s permissions list;
without a record of operator consent, the new surface would be
auto-allowed on the next call. The approved set is the load-bearing
authority.
Two paths to record consent:
-
GUI. Sliding the Enabled toggle on for a plugin that declares any
permissionsopens a confirmation modal listing every requested permission with its full description. Grant & enable records the list toapproved_permissionsand proceeds; Cancel reverts the slider without firing the enable POST. The gate fires from both call sites — the Settings → Plugins row toggle AND the slider inside the plugin’s detail modal. -
CLI.
eiou plugin enable <name>for a permission-requesting plugin takes one of three forms:--grant-all— approve every permission the manifest declares--grant <key,key,…>— approve a subset (must be a subset of the manifest; unknown keys are refused)- no flag on an interactive TTY — operator is prompted (
y/N) - no flag on a piped / non-TTY context — refused with a message listing the permissions and the flag to add. Automation has to declare its intent.
Disable always passes through without a prompt (reducing access doesn’t need confirmation) and preserves the approved set, so a routine off/on cycle doesn’t re-prompt when the manifest hasn’t changed. Plugins that request no permissions bypass the consent flow entirely.
Drift detection auto-disables. On boot, PluginLoader::discover()
walks every enabled plugin and computes the manifest-minus-approved
difference. Non-empty difference (the plugin author added a permission
since the operator last consented, or the operator never consented at
all) flips the plugin’s enabled flag to false and emits
plugin_permission_drift_auto_disabled in the log. The operator must
re-enable through the consent flow — the existing approvals stay on
file so the GUI / CLI can show the diff between what was previously
granted and what’s newly requested.
State-file shape (/etc/eiou/config/plugins.json):
{
"<plugin-name>": {
"enabled": true,
"approved_permissions": ["contact_address_book_enumerate", "wallet_balance_read"],
"approved_at": "2026-05-14T22:45:00Z"
}
}
approved_permissions is absent on entries for plugins that declare
no manifest permissions; approved_at is the UTC ISO8601 timestamp
of the most recent grant.
Where plugin-owned state and code lives
Sandboxed plugins are full PHP applications inside their own FPM pool. Anything beyond the dispatcher is plugin-owned and plugin-managed — core doesn’t load it, doesn’t audit it, doesn’t migrate it. A typical layout for a non-trivial plugin:
/etc/eiou/plugins/payback-btc/
plugin.json ← manifest (declares autoload, surfaces, etc.)
__dispatch.php ← entry; routes envelope types
src/
PaybackBtcPlugin.php ← entry class (PluginInterface)
CustomerRepository.php ← plugin-owned, queries the plugin's own MySQL schema
RefundPolicyService.php ← plugin-owned, enforces the plugin's own caps
OutboundAuditRepository.php ← plugin-owned, writes to the plugin's own audit table
...
Your __dispatch.php is responsible for loading these classes
itself. The manifest’s autoload.psr-4 field is metadata that
tooling (and the pre-sandbox in-process load path) read, but the
sandboxed FPM pool does not register an autoloader on your behalf
— the dispatcher template ships without one. Two practical paths:
require_once from the top of __dispatch.php:
require_once __DIR__ . '/src/CustomerRepository.php';
require_once __DIR__ . '/src/RefundPolicyService.php';
require_once __DIR__ . '/src/OutboundAuditRepository.php';
Simple and explicit. Fine for small plugins. The plugin’s
open_basedir includes its own dir, so the includes succeed; the
disabled-functions list does not block require_once.
Register a PSR-4 autoloader from the top of __dispatch.php:
spl_autoload_register(function (string $class): void {
$prefix = 'Eiou\\Plugins\\PaybackBtc\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) return;
$relative = substr($class, strlen($prefix));
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($file)) require_once $file;
});
Cleaner for plugins with many classes — instantiate by FQCN and the file gets loaded on demand. Mirrors the autoloader core used to run on the in-process load path; nothing stops you from porting that exact closure into your dispatcher.
Your handler then instantiates the classes as plain PHP, and they talk to your per-plugin MySQL user via the connection your handler opens itself.
Core doesn’t have a “factory that auto-loads plugin services” — and deliberately so:
- It would defeat the sandbox. Loading plugin code into the
wallet’s FPM pool would give that code the wallet’s master key,
full DB access, and no
open_basedirrestriction. That’s the in-process model the mandatory-sandboxing change deliberately removed. - The plugin’s pool is the right home anyway. Plugin state
belongs in the plugin’s own DB schema (isolated MySQL user, can’t
reach wallet tables); plugin business logic runs as the plugin’s
Unix user under restricted
open_basedir. There’s no reason for the wallet pool to know about either.
What core DOES provide for plugin-owned tables: per-plugin database
isolation (see Database Isolation). Your
plugin gets a MySQL user, a credential entry, optional
sibling-container credential export, and grants on tables your
manifest declares via owned_tables (or unrestricted DDL if you
prefer to CREATE TABLE IF NOT EXISTS from your own boot path).
What core does NOT provide is a class loader for your services —
load them yourself from __dispatch.php using one of the two
patterns above.
The trade-off this design accepts: plugin-managed state is opaque to
the operator. If a plugin maintains its own audit log of, say,
outbound spending, the operator’s view of that log is whatever the
plugin chooses to show. The honest framing is that this matches the
trust model — if you trust a plugin enough to allow-list a
mutating core service like WalletOutboundService.send, you trust
it enough to keep its own honest audit. If you don’t, the right
path is the event-publish + operator-approval flow, where the
operator is the one clicking through the existing wallet send GUI
and the canonical record is the wallet’s own transactions table.
Where plugin runtime files (caches, locks, state files) live
Not your own plugin folder. /etc/eiou/plugins/<your-id>/ is
owned by www-data (the wallet pool extracted the zip and chowned
it that way during install). Your pool runs as eiou-p-<hash>, a
different uid that has read access to those files (via the
filesystem’s other-permission bits) but no write access. A naïve
file_put_contents(__DIR__ . '/cache/foo.json', …) from your
dispatcher returns false with EACCES.
Use the scratch dir instead. Each sandboxed pool gets a
private writable directory at
/var/lib/eiou/plugin-scratch/<your-system-user>/, created by the
supervisor on every apply-pool and chowned <your-system-user>:<your-system-user>
with mode 0700. It’s the only path under /var/ your open_basedir
admits — perfect home for SQLite databases, decoded caches, lock
files, last-tick timestamps, anything the pool itself produces.
Derive the directory from the plugin id without trusting any
external input:
$systemUser = 'eiou-p-' . substr(hash('sha256', basename(__DIR__)), 0, 8);
$scratch = '/var/lib/eiou/plugin-scratch/' . $systemUser;
The scratch dir survives container restarts and recreates (it’s on the
{node}-plugin-scratch named volume mounted at /var/lib/eiou/plugin-scratch)
and survives plugin upgrades, but is torn down on uninstall. If you run a
custom compose, mount that volume or the scratch dir is ephemeral and a
container recreate wipes every plugin’s state.
If you need durable, queryable state — schedule rows, customer records, an audit log the operator can inspect via MySQL CLI — use your per-plugin MySQL user (see Database Isolation) rather than scratch. SQLite-in-scratch is the right tool for plugin-private caches that don’t need to outlive the pool’s idle recycle; MySQL is the right tool for everything else.
What you CAN do as a sandboxed plugin
- Subscribe to events; emit log lines via the
_logenvelope. - Render dashboard widgets, register tabs, contribute filter values.
- Handle GUI actions (CSRF + auth tier validated by core before dispatch).
- Serve REST endpoints at
/api/v1/plugins/<your-id>/<action>. - Register CLI subcommands; the
eioubinary dispatches to your plugin via IPC. - Read your own files and the scratch dir at
/var/lib/eiou/plugin-scratch/<your-system-user>/. - Open MySQL connections to your own per-plugin database user (your
manifest’s
database.user: trueflag still works the same way it did pre-sandboxing). - Ship your own PHP classes (repositories, services, value objects,
whatever) —
require_oncethem or register your own autoloader from__dispatch.php. Core doesn’t load them. - Call whitelisted core services via
core_call.
What you CANNOT do as a sandboxed plugin
- Read
/etc/eiou/config/.master.keyor any wallet secret. EACCES at the kernel level. This is the whole point. - Decorate / wrap a core service. Plugin code runs in a different process from core; there’s no shared address space to override. Use events to observe core actions instead.
- Call into another plugin directly. Plugins talk to core only; cross-plugin coordination happens via shared events or database tables that core mediates.
- Execute shell-out functions (
exec,shell_exec,passthru,proc_open,popen,system,pcntl_exec,eval,assert). - Open arbitrary file URLs (
allow_url_fopen=0,allow_url_include=0).
Constraints to design around
- Handler timeout: 500ms per IPC call. Long-running work needs to be queued in the plugin’s own DB tables and processed asynchronously by the plugin’s own processor (if you have one).
- Event-mid-transaction: if core fires an event while inside an uncommitted DB transaction, your handler reads from a separate DB connection and won’t see the in-flight rows. (Core’s own dispatch call sites commit before firing for this reason.)
- Per-render IPC latency: each render-hook fire adds ~1-5ms. Negligible for a dashboard widget, expensive if your plugin subscribes to a render hook that fires on every page render.
Migration checklist for an existing in-process plugin
- Take inventory of every
boot()/register()action: events subscribed, hooks registered, registries called, services added. - For every service registration via
$container->setService(...)— stop. This pattern doesn’t work sandboxed. Replace with event-based observation or document the constraint. - Move every declarative surface (event names, hook names, tab metadata, asset paths, route paths, CLI names) into the manifest fields above.
- Write
__dispatch.phpfrom the bundled template. Add acasein the switch for eachtypeyour manifest declared. - Declare every core service method your handler calls in
core_services(e.g."Logger.info"). - Set
"sandboxed": true. - Disable, then re-enable the plugin (rotates the gateway token, triggers a fresh FPM pool reload).
Security note for plugin authors
Even with sandboxing, a plugin that’s installed and enabled has real reach inside the operator’s wallet — it can:
- Read every contact (via
ContactServicecalls if its manifest declares them). - Read every transaction (via
TransactionServicecalls if declared). - Sign messages on the wallet’s behalf (if
MessageDeliveryServiceis whitelisted in the manifest and the core method is#[PluginCallable]).
Sandboxing prevents seed-phrase exfiltration. It doesn’t make a
plugin trustworthy. Operators reading your manifest’s
core_services list see the surface you’re asking for — don’t ask
for more than you need.
Events a Plugin Can Subscribe To
Events are dispatched through Eiou\Events\EventDispatcher::getInstance().
Subscribe in boot():
EventDispatcher::getInstance()->subscribe(SyncEvents::SYNC_COMPLETED, function(array $data): void {
// $data carries event-specific context; see each event class for the shape.
});
The dispatched event set covers sync, delivery, chain-drop, transaction, contact, P2P, and plugin-lifecycle events. Every constant documented below has a live emit point.
SyncEvents
| Constant | When it fires |
|---|---|
SYNC_STARTED |
A sync round is about to begin (companion to SYNC_COMPLETED); listeners may throw to abort |
SYNC_COMPLETED |
A sync round with a contact completed successfully |
SYNC_FAILED |
A sync round failed |
CHAIN_GAP_DETECTED |
A missing chain link was detected during sync |
CONTACT_SYNCED |
A contact’s metadata was synchronized |
BALANCE_SYNCED |
A balance was reconciled |
CHAIN_CONFLICT_RESOLVED |
A fork in the chain was resolved |
BIDIRECTIONAL_SYNC_STARTED |
A bidirectional sync round started |
BIDIRECTIONAL_SYNC_COMPLETED |
A bidirectional sync round completed |
ALL_CONTACTS_SYNCED |
All contacts have been synced (end of broad sync) |
ALL_TRANSACTIONS_SYNCED |
All transactions have been synced |
ALL_BALANCES_SYNCED |
All balances have been synced |
ChainDropEvents
| Constant | When it fires |
|---|---|
CHAIN_DROP_PROPOSED |
A chain-drop was proposed |
CHAIN_DROP_ACCEPTED |
A chain-drop was accepted |
CHAIN_DROP_REJECTED |
A chain-drop was rejected |
CHAIN_DROP_EXECUTED |
A chain-drop completed |
TRANSACTION_RECOVERED_FROM_BACKUP |
A transaction was recovered from backup as part of a chain-drop |
DeliveryEvents
| Constant | When it fires |
|---|---|
RETRY_DELIVERY_COMPLETED |
A DLQ-backed retry finished (success or final failure) |
The exact shape of each event’s $data payload is documented in each event
class’s docblock. Read the class, don’t trust docs alone — the payload is the
contract.
TransactionEvents
| Constant | When it fires |
|---|---|
PRE_VALIDATE |
Before a transaction is validated; listeners may throw to veto and abort |
TRANSACTION_CREATED |
A pending outbound tx was inserted in SendOperationService (direct or P2P) |
TRANSACTION_SENT |
After successful direct delivery (handleAcceptedTransaction) |
TRANSACTION_RECEIVED |
An inbound direct transaction was persisted (processStandardIncoming) |
TRANSACTION_COMPLETED |
An INBOUND transaction reached STATUS_COMPLETED on the receiver — fires once per tx after the commit (processIncomingDirect for direct, processIncomingP2p end-recipient for P2P). Pair with TRANSACTION_RECEIVED for “first observed” notifications and TRANSACTION_COMPLETED for “now refundable / final” — WalletRefundService.refundReceivedTransaction only accepts completed txs, so a plugin retrying refunds on transaction.received should subscribe here instead. |
TRANSACTION_OUTGOING_COMPLETED |
An OUTBOUND transaction reached STATUS_COMPLETED on the sender — fires once per tx after the sender’s accepted → completed transition triggered by a receiver completion-response (direct) or downstream completion-relay (P2P). Three fire sites in MessageService: direct sender, P2P original sender after end-recipient inquiry, P2P relay node. Pair with TRANSACTION_SENT (peer ack’d, accepted) to fully cover the outbound state machine. Payload carries route ('direct' or 'p2p') so one subscriber can branch on routing without re-querying. |
TRANSACTION_FAILED |
Delivery attempts exhausted and DLQ-cancelled (processOutgoingDirect) |
ContactEvents
| Constant | When it fires |
|---|---|
CONTACT_ADDED |
After insertContact() / addPendingContact() — includes outgoing, incoming, and wallet-restore paths |
CONTACT_ACCEPTED |
After ContactManagementService::acceptContact() commits |
CONTACT_REJECTED |
After an incoming contact request is auto-rejected for an unsupported currency |
CONTACT_BLOCKED |
After ContactManagementService::blockContact() commits |
P2pEvents
| Constant | When it fires |
|---|---|
P2P_RECEIVED |
An inbound P2P leg was persisted (both relay and end-recipient legs) |
P2P_APPROVED |
After the operator approves a P2P transaction (via CLI, REST, or GUI — all route through P2pApprovalService::approve()) |
P2P_REJECTED |
After the operator rejects a P2P transaction (same shared service commit point) |
P2P_COMPLETED |
A P2P transaction reached its final destination (end-recipient leg) |
PluginEvents
Plugin-lifecycle events dispatched by PluginLoader and the plugin
management services. Use them to observe other plugins’ lifecycle
transitions or to record audit trails.
| Constant | When it fires | Payload |
|---|---|---|
PLUGIN_REGISTERED |
After a plugin’s register() completes |
{name, version} |
PLUGIN_BOOTED |
After a plugin’s boot() completes |
{name, version} |
PLUGIN_FAILED |
A plugin threw during register() or boot() |
{name, version, phase, error} |
PLUGIN_INSTALLED |
After a successful .zip install via pluginsUpload |
{name, version, source: "zip_upload"} |
PLUGIN_UNINSTALLING |
Just before uninstall begins, while grants are still in place | {name} |
PLUGIN_UNINSTALLED |
After every uninstall step has run | {name, success, steps} — per-step status map (ok / skipped / error:<msg>) |
PLUGIN_UPGRADED |
After a successful upgrade (atomic swap + hook + reload) | {name, old_version, new_version, source: "zip_upload" | "bundled"} |
What a lifecycle event is — and is not
Read this before writing anything that reacts to PLUGIN_UPGRADED or
PLUGIN_UNINSTALLED. The delivery contract is narrower than it looks, and
building on the wrong assumption produces bugs that only show up under
concurrency or crash.
A lifecycle event is a best-effort, in-process, at-most-one-process notification.
- One process, not all of them.
EventDispatcheris an in-process singleton. The event is dispatched only in the process that committed the operation. A node runs several PHP-FPM workers plus the CLI poller; the others never see it. This is not a gap to be closed — it is what an in-process dispatcher means. - Not durable. The payload lives only in that process. If it dies between committing and publishing, the notification is gone. (The audit line survives — see below.)
- Unordered. Two operations on the same plugin can be announced in either order, regardless of which committed first. See below.
So: never make correctness depend on receiving one. The overwhelmingly common case is not “the process crashed” but “you were one of the four workers that was never going to be told.”
What to use instead
| You want to… | Use |
|---|---|
| Run migration work when your plugin is upgraded | UpgradablePlugin::onUpgrade() — runs under the upgrade lock, with your old grants still live, and a throw rolls the directory swap back. It is not exactly-once and not atomic — your hook must be idempotent. See UpgradablePlugin hook and the warning below. |
| Know what version a plugin is at | Read it. Every process reads each manifest fresh at boot (discover() / listAllPlugins()), so the current version is already durable and already visible to every process — no event needed. |
| Converge after a missed transition | Reconcile at boot from on-disk state, the way every core reconciler does (reconcileIsolation(), reconcileSandbox()). State is the source of truth; events are a convenience on top. |
| Observe lifecycle activity for logging/metrics | The event is fine. Missing one occasionally is acceptable for this use. |
Delivery is unordered
Publication happens after the upgrade lock is released — it has to, so a subscriber is free to run its own lifecycle operation without being blocked by the call that notified it. In that window a concurrent commit can announce first, so two operations on the same plugin may arrive in either order.
The host does not try to fix this, and deliberately gives you nothing to order by. It has tried twice: first by serialising publication behind a per-plugin turn-taking protocol, then by stamping a commit number on each event. Neither could make a consumer correct, because only the process that performed the operation is notified at all — a subscriber that cannot survive a missed event cannot survive being one of the other workers either. Both attempts produced a stream of concurrency and contract defects, so both are gone.
Treat a lifecycle event as a wakeup, not as a fact. On receiving one, read the current state — the installed version from the manifest on disk, the presence or absence of the plugin directory — and act on that. Then order does not matter, because you are never acting on the event’s contents.
Being idempotent is not sufficient on its own, and it is worth being precise about why: idempotency makes it safe to process the same event twice. It does not make it safe to process different events in reverse order. A listener that handles “upgraded to 2.0” and then a delayed “upgraded to 1.1” will happily regress whatever it derived, no matter how idempotent each step is. Reconciling from current state is what removes the hazard; being order-independent or monotonic is the only alternative.
The audit trail is written earlier than the event, not guaranteed
plugin_upgraded and plugin_uninstalled are logged at commit time,
under the upgrade lock — not on the publication path. So a committed
upgrade or uninstall leaves its record even if the process dies before it
announces anything, and the records are written in commit order — which is
the only place commit order is observable, since event delivery is not
ordered.
That placement is the whole of the guarantee. The record is not durable in any stronger sense, and operators should not treat its absence as proof that nothing happened:
- The write is best-effort.
SecureLoggerappends with@file_put_contents(...), so a full disk or a permissions problem is suppressed rather than surfaced. - The application log rotates and prunes, so an old record ages out like any other line.
- If the logger itself throws, the audit call swallows it — deliberately, so a logging outage cannot make an already-committed upgrade report failure — which means the operation succeeds with no record of it.
A real forensic guarantee would need a dedicated append-only audit journal with explicit write confirmation, its own retention policy independent of the app log, and an operator-visible failure state when a record cannot be written. That does not exist today. Until it does, the on-disk state is the authority for what happened (the installed version, the absent plugin directory), and the log is corroboration.
If a consumer ever needs guaranteed delivery
Do not reach for a message broker or a durable payload queue with a
recovery worker. A recovery worker publishing another process’s event
delivers it to the wrong subscriber set (subscriber registrations are
per-process, built at boot from listAllPlugins()), and it still cannot
reach the workers that were never going to be told.
The design that fits this codebase is a durable transition marker, consistent with how everything else here works:
- Write the transition (plugin, old version, new version, sequence) to a
state file under the upgrade lock, atomically (tmp + rename, the
same pattern
plugins.jsonand the cron state file already use). It is then durable and readable by every process. - Each consumer converges on it by comparing against its own last-seen position, rather than being pushed to.
The hard part is that cursor, not the marker: PHP-FPM workers have no stable identity and no persistent per-worker state, so “which transitions has this consumer already seen?” has to live somewhere. For a sandboxed plugin the natural answer is that the plugin owns its cursor — it already knows its own last-seen version — which makes this a plugin-facing contract rather than host bookkeeping. Settle that before writing code.
This is deliberately not built today: nothing in core subscribes to these
events, and the two mechanisms above (onUpgrade() and boot reconcile)
already cover every case that needs a guarantee.
Subscriptions made in register() won’t observe other plugins’
PLUGIN_REGISTERED events that ran ahead of you in iteration order —
subscribe in boot() (or via the manifest’s subscribes_to for
sandboxed plugins, which always sees all of them) to catch the full
registration pass.
Plugin-published events
Beyond subscribing to core events, a sandboxed plugin can emit its own events for other plugins or in-process subscribers to react to. The host namespaces every plugin-emitted event so subscribers can identify the origin and one plugin cannot spoof another:
plugin.<source-plugin-id>.<event-name>
Where <source-plugin-id> is the trusted plugin id resolved by the
plugin gateway from the bearer token (not a value the plugin passes —
attempts to do so are ignored), and <event-name> is the local event
name supplied by the publisher, constrained to ^[a-z][a-z0-9_-]{0,63}$.
A plugin opts into the publish surface by allow-listing
PluginEventPublisher.publish in its manifest core_services list and
calling it through core_call():
$envelope = [
'service' => 'PluginEventPublisher',
'method' => 'publish',
'args' => ['refund-issued', ['txid' => $txid, 'amount' => $amount]],
];
core_call($envelope);
The host dispatches plugin.<your-plugin-id>.refund-issued with the
payload, augmented with _source_plugin for trace-ability. Subscribers
declare the full namespaced name in their manifest subscribes_to list
(the existing regex already admits the dotted form):
"subscribes_to": ["plugin.payback-btc.refund-issued"]
Constraints applied by the host:
- Event name pattern:
^[a-z][a-z0-9_-]{0,63}$. - JSON-encoded payload size cap: 16 KiB (after
_source_pluginis added). - Per-plugin rate cap: 600 publishes per minute (via the
#[PluginCallable]ratePerMinute). - The publishing plugin’s id is host-injected via
PluginCallerAwareon the gateway path; plugins cannot publish under another plugin’s id by passing it as an argument.
Subscribers see plugin-emitted events through the same EventDispatcher
that fans out core events — in-process subscribers receive them
synchronously; sandboxed plugins receive them as event-typed envelopes
via PluginIpcForwarder, same as for core events. The dispatch path is
asymmetric (publish is plugin → wallet via gateway; receive is wallet →
plugin via IPC) because sandboxed plugins run in their own FPM pool and
cannot share an EventDispatcher instance with the wallet pool.
Scheduled Tasks (cron)
Sandboxed plugins can declare manifest-driven scheduled tasks that the host fires on a regular interval. Use cases: batched usage flushes, key-TTL sweeps, daily summary rollups, periodic cache refreshes — anything the plugin would otherwise need to ship a separate cron container for.
Manifest field
"cron": [
{"interval_minutes": 60, "action": "flush-usage"},
{"interval_minutes": 1440, "action": "daily-summary"}
]
interval_minutes is bounded [1, 1440] (one minute to one day).
action matches the same kebab-case shape as public_routes
(^[a-z][a-z0-9-]{0,63}$) and is the discriminator the plugin’s
__dispatch.php switches on to route the work.
Why interval_minutes instead of a full cron expression
The host scheduler ticks once per minute (driven by startup.sh’s
plugin_cron_poller), so sub-minute precision is impossible regardless
of expression syntax. Cron expressions also bring timezone and DST
traps that don’t pay off for the use cases this is designed for. A
plugin that needs “daily at 03:00 UTC” can fold the hour-of-day check
into its handler — the dispatch envelope carries scheduled_at (Unix
timestamp), so the handler knows the exact moment the host invoked it.
Dispatch envelope
When an entry’s interval has elapsed since its last fire, the host
POSTs to the plugin’s __dispatch.php with:
{
"type": "cron",
"name": "flush-usage",
"context": {
"scheduled_at": 1715635200,
"interval_minutes": 60
}
}
The plugin handles it in the same dispatch switch as other envelope types:
if ($type === 'cron') {
if ($name === 'flush-usage') {
// ... do the work, return ok ...
}
}
State and locking
The host persists last-fire timestamps in
/var/lib/eiou/plugin-cron-state.json (mode 0640 root:www-data). Cron
ticks are root-only CLI operations: root writes this file, while the wallet
pool has read-only access. Each
(plugin-id, action) pair has its own entry. State entries for actions
that are no longer declared (plugin uninstalled or entry removed from
manifest) are pruned automatically on each tick, so the file doesn’t
grow without bound.
Each (plugin-id, action) also takes a non-blocking flock on a
private root-owned lockfile under /run/eiou-root/plugin-cron before
dispatch. If a previous tick’s
invocation is still running — a slow plugin handler, a stalled IPC
connection — the new tick skips with reason=lock_held and relies on
the next minute’s tick to retry once the lock is free. This prevents
pile-up under a hanging handler.
The lockfile name is derived from sha256(plugin-id|action), so it is
computable by anyone who can read a manifest. Its parent is therefore
root:root 0755, not /tmp or the www-data-writable /run/eiou.
Lock descriptor/path identity is validated before use. If the hardened
directory is missing or unsafe, the scheduler fails closed.
Additionally, the tick holds each plugin’s upgrade lock (non-blocking)
while it reads that plugin’s manifest and dispatches its entries. A
plugin that is mid-upgrade is skipped with reason=upgrade_in_progress:
firing between the directory swap and the commit would run a handler
against tables the onUpgrade hook is still migrating, and the schedule
must not be pruned against a manifest that may yet roll back. The skip
is free — the entry keeps its last-fire timestamp and is retried on the
next tick.
What the tick refuses to dispatch
Everything the tick decides on is re-read under that lock, and it is the same verdict the Plugins table shows the operator — there is no separate, looser view for the scheduler:
- A plugin whose row reads
failedis fully inert for cron. That covers a signature rejection underPLUGIN_SIGNATURE_MODE=require, an invaliddatabaseblock, and an isolation quarantine — a plugin whose credentials, grants or FPM pool could not be reconciled (eiou plugin reconcilereports it, and the row carriesfailure_stage=isolation). Cron IPCs straight into the plugin’s own pool, so a quarantined plugin must not receive scheduled work: its pool may be torn down, or a stale worker may still be serving with grants that no longer match the code on disk. Clear the quarantine and the next tick resumes on schedule — a skipped entry never advances its last-fire timestamp. - A
cronentry that fails manifest validation is dropped, not dispatched. The rules are exactly those in the manifest table above (interval_minutesan integer in[1, 1440],actionkebab-case), and they are applied identically wherever the entry is read.
Failure handling
A transport failure (plugin pool down, FPM unreachable) records
reason=dispatch_failed and does not advance the last-fire window —
the next tick retries. A handler that throws records reason=threw:<msg>
and likewise does not advance — same retry semantics. A handler that
returns {"ok": true} advances the window normally.
CLI
eiou plugin cron-tick
Runs one tick manually. Useful for diagnostics, forced fires during
development, and --json output for scripting. The startup poller
invokes this same command — there is no separate code path for
operator-triggered vs automated ticks.
Per-entry timeout (optional)
The host caps each dispatch envelope’s wall-clock budget. Cron’s
default cap is 5 seconds (much longer than the 500ms ceiling on
user-blocking surfaces like event / filter / render — those
park a real user’s request, so a slow plugin must not extend the
wait). Plugins that need more than 5s for a single tick can declare:
"cron": [
{"interval_minutes": 60, "action": "flush-usage", "timeout_ms": 15000}
]
timeout_ms is also honoured on gui_actions and cli_commands
entries, with the same clamp. Those default to the 500ms
user-blocking cap, but an operator-facing action or CLI verb that
legitimately makes one synchronous network call (a “test connection”
button, a “fetch the upstream model list” command) can raise its own
budget:
"gui_actions": [{"name": "myPluginFetchModels", "tier": "csrf", "timeout_ms": 8000}],
"cli_commands": [{"name": "my-plugin", "timeout_ms": 8000}]
The host clamps timeout_ms to a hard ceiling below the plugin
pool’s own FPM request_terminate_timeout (currently 25s vs 30s) so
the host always times out first and logs the failure under our
control. Bigger budgets indicate the tick is doing too much in one
invocation — prefer the queue-and-drain pattern below. A raised
budget on a gui_action still parks the operator’s browser request
for the duration, so reserve it for genuinely synchronous probes;
bulk or background work belongs in cron plus a queue.
Async work pattern (cron + queue)
Most non-trivial plugin work belongs in a cron tick, NOT in the event/filter/render handlers that fire on user requests. The hard constraint: those handlers have a 500ms host-side budget and the user is parked waiting for the response. A handler that takes 2s to fetch a remote IPFS pin from a Kubo sidecar will:
- Trip the host’s 500ms timeout. The host logs
plugin_ipc_transport_failedand abandons the call. - Keep running inside the plugin’s FPM worker — the worker
doesn’t know its caller has hung up — until either (a) the
plugin’s own work finishes and the worker silently drops the
response, or (b) the FPM
request_terminate_timeout(30s) fires. - Hold an FPM worker for the duration. With
pm.max_children = 4in the default pool render, four concurrent slow handlers wedge the entire plugin.
The right shape is “enqueue in the handler, drain in cron”. The handler does only what fits in 500ms (insert a row, write a JSON file), and a cron tick walks the queue at its own pace.
Sketch — plugin-side queue table
Add a small queue table in the plugin’s MySQL schema (via
database.migrations, see Database Isolation):
CREATE TABLE my_plugin_pending_pins (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
txid VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status VARCHAR(16) NOT NULL DEFAULT 'pending',
attempts INT UNSIGNED NOT NULL DEFAULT 0,
next_run_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
KEY status_run (status, next_run_at)
);
Sketch — __dispatch.php handler enqueues, doesn’t do work
if ($type === 'event' && $name === 'transaction.received') {
// 500ms budget. Insert + return; the actual work is for cron.
$pdo->prepare('
INSERT INTO my_plugin_pending_pins (txid, payload)
VALUES (:txid, :payload)
')->execute([
':txid' => $context['data']['txid'] ?? '',
':payload' => json_encode($context['data'] ?? []),
]);
return ['ok' => true];
}
Sketch — cron tick drains the queue
if ($type === 'cron' && $name === 'drain-pins') {
// 5s budget by default; declare timeout_ms in manifest if more
// is genuinely needed for a single tick.
$batch = $pdo->query('
SELECT id, txid, payload FROM my_plugin_pending_pins
WHERE status = "pending" AND next_run_at <= NOW(6)
ORDER BY next_run_at LIMIT 5
')->fetchAll(PDO::FETCH_ASSOC);
foreach ($batch as $row) {
try {
// Do the actual work — pin to IPFS, hit a remote API,
// whatever. Each iteration is its own try/catch so one
// poisonous row doesn't abort the batch.
doTheWork($row);
$pdo->prepare('
UPDATE my_plugin_pending_pins
SET status = "done"
WHERE id = :id
')->execute([':id' => $row['id']]);
} catch (Throwable $e) {
// Backoff — exponential by attempts, capped. Mark
// 'failed' once attempts hit a manifest-defined ceiling
// so the row stops eating budget on every tick.
$pdo->prepare('
UPDATE my_plugin_pending_pins
SET attempts = attempts + 1,
next_run_at = DATE_ADD(NOW(6), INTERVAL POW(2, attempts) MINUTE)
WHERE id = :id
')->execute([':id' => $row['id']]);
}
}
return ['ok' => true, 'drained' => count($batch)];
}
Manifest declares both surfaces:
{
"subscribes_to": ["transaction.received"],
"cron": [{"interval_minutes": 1, "action": "drain-pins"}]
}
Sizing the batch
Default LIMIT 5 leaves margin under the 5s cron budget for the
slowest realistic iteration (a single IPFS pin can take ~1s on a
warm sidecar; cold-cache walks can run to several). Tune per
workload by measuring how long one iteration takes in production and
choosing batch_size * iteration_p99 < timeout_ms - 500ms_overhead.
If the queue grows faster than the cron tick can drain it, raise
interval_minutes (smaller batches more often is usually better
than huge batches less often — it bounds the per-tick blast radius).
Don’t be tempted to raise timeout_ms past ~10s; longer ticks just
push the operational pain to discovery time (a stuck handler delays
the next tick, the lockfile-skip pattern in State and
locking only protects within one
(plugin, action) pair).
When to skip the queue and just do the work synchronously
If the work genuinely fits in 500ms (a cache read, a single in-process computation, a small DB query) the enqueue-and-drain pattern is overhead. The smell test: if the worst-case latency for a single invocation, including a cold network round-trip, comfortably fits in 500ms, do it in the event handler. If you’re not sure, ship it in cron — moving from “synchronous in handler” to “queued for cron” is a refactor; moving back is not.
Writing a Plugin
The reference plugin hello-eiou is ~80 lines and demonstrates the
full sandboxed-plugin contract end-to-end. Start by copying it:
cp -r /etc/eiou/plugins/hello-eiou /etc/eiou/plugins/my-plugin
A sandboxed plugin is two things on disk: a plugin.json manifest
that declares what surfaces the plugin contributes, and an
__dispatch.php that handles invocations of those surfaces when
core calls into the plugin’s FPM pool. The entry class (your
MyPlugin class) exists for the optional lifecycle hooks
(UninstallablePlugin, UpgradablePlugin) but does not run in
the wallet pool — see Lifecycle for why.
1. Edit the manifest
{
"name": "my-plugin",
"version": "1.0.0",
"description": "What it does, in one sentence.",
"entryClass": "Eiou\\Plugins\\MyPlugin\\MyPlugin",
"autoload": {
"psr-4": {
"Eiou\\Plugins\\MyPlugin\\": "src/"
}
},
"sandboxed": true,
"subscribes_to": ["sync.completed"],
"core_services": ["Logger.info"]
}
The two surface fields say:
subscribes_to— when the host firessync.completed, route the payload into our__dispatch.phpas{type: "event", name: "sync.completed", context: <event data>}.core_services— the plugin may callLogger.infovia the service gateway. Adding a method here without the host actually exposing it via#[PluginCallable]is a no-op; the gateway 403s unknown methods. See Plugin-callable surface — policy for the full set.
Every other surface (tabs, gui_actions, gui_assets,
api_routes, cli_commands, public_routes, filter_hooks,
render_hooks, database, min_upgradable_from) is the same shape
— declare in the manifest, handle in __dispatch.php. Add them as
your plugin needs them.
2. Rename the entry class
Move src/HelloEiouPlugin.php to src/MyPlugin.php, update the
namespace and class name. The class is minimal — it exists for
identity (getName/getVersion) and optional cleanup /
migration hooks:
<?php
namespace Eiou\Plugins\MyPlugin;
use Eiou\Contracts\PluginInterface;
use Eiou\Services\ServiceContainer;
class MyPlugin implements PluginInterface
{
public function getName(): string { return 'my-plugin'; }
public function getVersion(): string { return '1.0.0'; }
public function register(ServiceContainer $container): void { /* unused in sandboxed model */ }
public function boot(ServiceContainer $container): void { /* unused in sandboxed model */ }
}
register() and boot() are on the interface for compatibility
but don’t run in the wallet pool for sandboxed plugins — the
wallet pool only reads your manifest. They do run inside the
plugin’s own FPM pool on each __dispatch.php request (the
dispatcher calls them), so authors who do need shared init can
put it there — but most plugins don’t need either.
3. Wire the dispatcher
Copy the bundled template to __dispatch.php and replace the
501 stub for each type you declared in the manifest:
cp /app/eiou/src/templates/plugin-dispatch-template.php \
/etc/eiou/plugins/my-plugin/__dispatch.php
// inside the dispatch switch — replace the stub
case 'event':
if ($name === 'sync.completed') {
$contactPubkey = $context['contact_pubkey'] ?? 'unknown';
core_call('Logger', 'info', [
"[my-plugin] sync completed for {$contactPubkey}",
['plugin' => 'my-plugin'],
], $log);
respond(200, ['ok' => true], $log);
}
respond(501, [
'ok' => false,
'error' => ['code' => 'handler_not_found', 'message' => "no handler for event {$name}"],
], $log);
The template already imports core_call($service, $method, $args, $log) which authenticates to the wallet’s service gateway with the
plugin’s per-pool bearer token (mounted at .gateway-token). The
gateway validates the bearer, the manifest allow-list, and the
#[PluginCallable] attribute before dispatching. See The
__dispatch.php contract for the
envelope shape and Calling core services from your handler —
core_call()
for the call’s full validation chain.
4. Enable and apply
eiou plugin enable my-plugin
The supervisor brings up the plugin’s FPM pool immediately. The
plugin’s endpoints respond from this moment on — there’s no boot
wait. A wallet restart is still required for the wallet pool’s IPC
forwarder to bind the new subscribes_to / filter_hooks /
render_hooks entries, so until you restart, the events / hooks
won’t fire your handler. gui_actions, api_routes,
cli_commands, public_routes, and tabs all bind on the same
restart pass.
eiou restart
The status dot next to my-plugin in the GUI plugin list should
turn green — your plugin is live.
5. Ship a CHANGELOG
Drop a CHANGELOG.md next to plugin.json. The GUI will
automatically expose a View bundled CHANGELOG.md button in the
detail modal. See hello-eiou/CHANGELOG.md for a minimal example.
6. Plan for upgrades
When you ship a 1.1.0, give operators a clean upgrade path:
- Bump
versionin the manifest. - If you changed
owned_tables(added or removed a table) or your on-table schema, implementUpgradablePlugin::onUpgrade()on the entry class to migrate data — runs against the unchanged plugin user with the old grants still active, see Upgrading Plugins. - If your
1.1.0can’t safely migrate from earlier than some version, declare a"min_upgradable_from": "0.5.0"in the manifest so operators on older versions get a clear refusal instead of a corrupted migration.
Extending the CLI and REST API
Plugins can add top-level eiou <plugin> ... CLI subcommands and
admin-scoped REST endpoints under /api/v1/plugins/<plugin>/<action>.
Both surfaces are manifest-declared and dispatcher-handled:
- The manifest’s
cli_commandsandapi_routesarrays tell core’s IPC forwarder to bind handlers in the wallet pool’s registries. - When an operator invokes
eiou <plugin> ...or hits the REST endpoint, the bound handler HTTP-POSTs an envelope into the plugin’s__dispatch.phpwithtype: "cli"ortype: "rest"respectively. - Your dispatcher’s
case 'cli':andcase 'rest':arms run the actual work and respond.
Both surfaces are admin-only: the CLI runs as the local operator, and
plugin-owned REST endpoints inherit the admin scope gate from
/api/v1/plugins. For non-admin HTTP from customers, see Public
routes.
CLI subcommand
Manifest:
{
"cli_commands": [{"name": "my-plugin"}]
}
Naming rules — kebab-case, 1–32 chars, starts with a letter, doesn’t
collide with a reserved core command (send, add, plugin,
restart, etc. — the registry has a hard-coded list and the manifest
validator drops bad entries before they reach core).
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'cli':
// $context carries the parsed argv and a writable output struct
$argv = $context['argv'] ?? [];
$sub = $argv[2] ?? 'help';
if ($sub === 'status') {
respond(200, ['result' => ['status' => 'ok']], $log);
}
respond(404, [
'ok' => false,
'error' => ['code' => 'unknown_subcommand', 'message' => "Unknown subcommand: {$sub}"],
], $log);
Operators invoke as eiou my-plugin status. Handler failures (a
thrown exception inside the dispatcher) come back as a 500 in the
response envelope; the CLI’s output manager surfaces them as an
error without crashing the CLI process.
REST endpoint
Manifest:
{
"api_routes": [{"method": "GET", "action": "status"}]
}
Rules: method is one of GET / POST / PUT / PATCH / DELETE;
action is kebab-case, 1–64 chars; the action names enable and
disable are reserved for the core plugin-management endpoints; the
same (plugin, method, action) tuple is registered once.
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'rest':
// $context carries: method, params (query/path), body (raw string)
if ($name === 'status') {
respond(200, ['result' => ['status' => 'ok', 'ts' => time()]], $log);
}
respond(404, [
'ok' => false,
'error' => ['code' => 'unknown_route', 'message' => "no handler for {$name}"],
], $log);
Callers invoke as GET /api/v1/plugins/my-plugin/status (admin
auth required — the wallet’s ApiController::handlePlugins checks
the admin scope before routing into your handler). The handler’s
result is wrapped in the standard successResponse shape by the
core controller. Path shape is single-level only — nested paths
aren’t supported in v1; encode sub-resources as query params
(?id=123) or as a compound action name.
Reference
See hello-eiou — eiou hello-eiou and GET /api/v1/plugins/hello-eiou/fortune
both return a random fortune. Both declarations live in plugin.json
(cli_commands + api_routes); both handlers live in
__dispatch.php’s switch.
Extending the GUI
Plugins extend the wallet GUI through five complementary surfaces — render hooks, filter hooks, asset enqueues, a Plugins-tab sub-panel, and POST action handlers. All five are manifest-declared, dispatcher-handled:
| Surface | Manifest field | Dispatcher type | What the handler returns |
|---|---|---|---|
| Render slot | render_hooks |
"render" |
An HTML string |
| Filter slot | filter_hooks |
"filter" |
The transformed filter value |
| Plugins-tab panel | plugin_tab_panel |
"render" (name=plugin_tab_panel) |
HTML for the plugin’s body inside the host’s Plugins tab |
| POST action | gui_actions |
"action" |
A response envelope (JSON or redirect) |
| CSS / JS asset | gui_assets |
— purely declarative; no handler runs | Asset file at the declared path |
Note: the older
tabsmanifest field (which let plugins register their own top-level tabs in the wallet nav) is deprecated for plugin use. The host now owns a single Plugins tab between Activity and Settings with a dropdown of installed plugins; each plugin gets one sub-panel viaplugin_tab_panel. Manifests still carryingtabslog a deprecation warning at boot and the entries are not registered.
The IPC forwarder reads each manifest list at wallet boot and binds
matching in-process listeners; when a slot fires / filter resolves /
action POSTs / tab renders, the forwarder HTTP-POSTs an envelope into
the plugin’s __dispatch.php and surfaces the response back to the
host caller. This section is the GUI-surface reference plugin authors
need day-to-day; the underlying IPC contract is documented in The
__dispatch.php contract.
Render slots
Render hooks let plugins inject HTML at named points in the templates. Each listener returns a string; the host concatenates them in priority order (lower runs first; default 10) and emits the result. Listener exceptions are logged and skipped.
| Hook | Where | Typical use |
|---|---|---|
gui.head.styles |
<head> |
Register <style> / <link> tags. The asset registry already drains here — most plugins enqueue rather than subscribe directly. |
gui.head.scripts |
<head> |
Head-mode <script> tags. Asset registry drains here for enqueueScript(..., ['head' => true]). |
gui.footer.scripts |
end of <body> |
Late-init <script> tags. Default destination of enqueueScript. |
gui.dashboard.before |
dashboard tab top | Hero widget above the wallet-information block. |
gui.dashboard.after |
dashboard tab bottom | Sidebar widget after the payback methods. |
gui.contacts.after |
contacts tab bottom | Bulk-action panel under the contact list. |
gui.activity.after |
activity tab bottom | Custom analytics under the transaction history. |
gui.settings.section |
settings tab bottom | Plugin-owned settings section. |
Manifest:
{
"render_hooks": ["gui.dashboard.after"]
}
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'render':
if ($name === 'gui.dashboard.after') {
// $context carries whatever the host passed at the fire site —
// every wallet template fires with `{'user': <user>}`.
$display = htmlspecialchars($context['user']['display_name'] ?? '');
respond(200, [
'result' => "<section class=\"plugin-myplugin-widget\"><h3>Hi {$display}</h3></section>",
], $log);
}
respond(200, ['result' => ''], $log); // no contribution for unknown hooks
The host concatenates render-hook contributions in priority order (plugin handlers get the default priority; core’s own listeners register their own). A handler that throws is logged and skipped — other plugins’ contributions still appear.
Filter slots
Filter hooks let plugins transform a host value before render. Each listener receives the value from the previous stage and must return the next stage. Listeners that throw fall back to the previous value (so other listeners aren’t punished for a misbehaving one).
| Hook | Value shape | Use case |
|---|---|---|
gui.tabs |
array of tab entries (id, label, icon, order, …) |
Add, hide, or reorder top-level tabs. Filters fire after TabRegistry::all() so registered plugin tabs are already present. |
gui.dashboard.widgets |
array of {id, html, order} |
Contribute ordered widget chunks; sorted by order (default 100) before render. |
gui.contact_modal.tabs |
array of {id, label, icon} |
Add an inner tab to the contact-detail modal. Pair with gui.contact_modal.body on a shared id. |
gui.contact_modal.body |
array of {id, html} |
Body HTML for the matching modal tab. Host renders it inside <div id="<id>-tab" class="modal-tab-content">. |
gui.contact.actions |
array of {label, icon, action} |
Buttons on the contact-modal Settings tab. The host wraps each entry in a CSRF-protected POST form whose hidden contact_address input is auto-populated when the modal opens. |
Manifest:
{
"filter_hooks": ["gui.contact.actions"]
}
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'filter':
if ($name === 'gui.contact.actions') {
// $context['value'] is the incoming filter value
$actions = is_array($context['value'] ?? null) ? $context['value'] : [];
$actions[] = [
'label' => 'Bookmark',
'icon' => 'fas fa-star',
'action' => 'myPluginBookmark', // must match a declared gui_action
];
respond(200, ['result' => $actions], $log);
}
// Pass-through if we don't transform this hook
respond(200, ['result' => $context['value'] ?? null], $log);
The handler’s result becomes the value the next listener in the
chain sees (or the final value the host renders). Throwing falls
back to the previous value, so a buggy plugin doesn’t punish other
contributors to the same filter.
Asset enqueue
CSS and JS files are declared purely in the manifest — there’s no
dispatcher handler for assets. Paths resolve under the plugin root
(/etc/eiou/plugins/<id>/); path traversal (.., leading slash,
backslashes) is rejected at manifest validation and re-validated at
render against realpath() so a symlinked target can’t escape the
plugin tree.
Manifest:
{
"gui_assets": [
{"type": "css", "path": "assets/styles.css"},
{"type": "js", "path": "assets/main.js"},
{"type": "js", "path": "assets/early.js", "head": true},
{"type": "css", "path": "assets/big.css", "priority": 5}
]
}
Files smaller than URL_MODE_THRESHOLD (4 KiB) inline as
<style nonce> / <script nonce> blocks; larger files get a
<link href="…?v=<hash>"> / <script src="…?v=<hash>"> tag served
by the /gui/plugin-assets/<id>/<path> route (handled by core’s
PluginAssetServer). Force a mode with "inline": true or
"inline": false.
CSP nonce stamping is automatic. Plugin authors don’t think about it.
CSS isolation is convention-only — namespace selectors with
.plugin-<id> (.plugin-myplugin .widget-title { … }) or use Web
Components / Shadow DOM. A misbehaving plugin’s body { … } will affect
the host page; treat plugin code with the same scrutiny you’d give any
unsigned CSS bundle.
Section helper (renderSection())
Every wallet section — the Plugins table, Failed Messages queue,
Payback Methods card list, API Keys table, Settings, etc. — wraps its
content in the same outer shape: a form-container fade-in-up div, a
section-header with icon + h2 + optional inline buttons, an optional
<details> “About this …” disclosure, and the body. renderSection()
in WalletTemplateHelpers.php consolidates the wrapper so core’s own
sections stay visually consistent when CSS/UX refinements happen.
Sandboxed plugins don’t call renderSection() directly — it runs
in the wallet pool when wallet.html renders, not in the plugin’s
pool. Plugin authors who want their HTML to match the host’s section
shape have two options:
- Mirror the markup yourself. Return an HTML string from your
render hook handler that uses the same
form-container fade-in-upsection-headerouter shape. The CSS will style it identically.
- Use a tab. Declare a
tabsentry in the manifest (see below). Core’s wallet.html iterates the tab registry and runs each tab’s body throughrenderSection()automatically, so tab bodies get the native chrome for free.
The helper also auto-fires two render hooks around every section it
renders, and these are available to plugins via render_hooks:
gui.section.before.<id>— fired immediately before the section opens. Plugins can inject a banner above someone else’s section without forking the template.gui.section.after.<id>— fired after the section closes. Useful for “additional details” panels that should attach to a specific core section.
The hook context is the full spec array, so listeners can adapt to
which section they’re inside (e.g. only inject for id === 'dlq').
{
"render_hooks": ["gui.section.after.dlq"]
}
// inside __dispatch.php's switch ($type), case 'render':
if ($name === 'gui.section.after.dlq') {
respond(200, ['result' => '<div class="my-plugin-dlq-followup">…</div>'], $log);
}
Every standard wallet section is rendered through renderSection():
plugins-section, payback-methods-section, dlq, api-keys-section,
transactions, payment-requests-section, debug-section,
settings, contacts, pending-contacts. The
gui.section.before.<id> / gui.section.after.<id> hooks fire for
every one of them.
Table helper (renderTable())
Every paginated table in the wallet wraps its <table> in
<div class="contacts-table-wrapper"> and adds a contacts-table {variant}-table class. renderTable() hides that boilerplate so
core tables stay consistent.
As with renderSection, sandboxed plugins don’t call renderTable()
directly — return HTML from your render hook handler that uses the
same outer shape (<div class="contacts-table-wrapper"> containing a
<table class="contacts-table <variant>-table">) and the CSS will
style it identically.
The Plugins tab — plugin_tab_panel
The host owns a single Plugins tab in the wallet nav, between Activity and Settings. Each enabled plugin can register one sub-panel that appears in a dropdown at the top of that tab; selecting a plugin swaps the panel body below the dropdown.
Why a host-owned parent rather than per-plugin top-level tabs: the wallet’s primary navigation is small and stable on purpose — five core tabs operators learn once and don’t have to relearn after every plugin install. A wallet running four plugins would otherwise grow the nav by 80 %; consolidating into one parent tab keeps the nav size constant regardless of how many plugins are installed.
Manifest:
{
"plugin_tab_panel": {
"label": "My Plugin",
"icon": "fas fa-puzzle-piece",
"order": 100
}
}
Field rules: label is plain text up to 64 chars (shown in the
dropdown); icon defaults to fas fa-puzzle-piece; order defaults
to 100 (lower = earlier in the dropdown, ties broken by plugin id).
The plugin’s name is the row’s identity — there’s no separate id
in plugin_tab_panel because each plugin gets at most one panel.
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'render':
if ($name === 'plugin_tab_panel') {
respond(200, ['result' => '<div class="plugin-myplugin">…</div>'], $log);
}
The host POSTs type: "render", name: "plugin_tab_panel" (fixed
— no per-tab suffix, since each plugin gets at most one panel) when
it needs the panel’s HTML. The plugin’s body renders inside a
host-provided container with the tab title already in place, so the
plugin’s body should drop the outer <h2> and start with its own
sub-header (typically <h3>).
No inline styles or event handlers. The panel HTML renders into the operator page, which serves a strict, nonce-based Content-Security-Policy (both
script-srcandstyle-srcare'self' 'nonce-...'with no'unsafe-inline'). Inlinestyle="..."attributes, inlineonclick=/on*=handlers, and non-nonced<style>/<script>blocks in panel markup are blocked by the browser and silently lose their effect. Style with CSS classes (you can reuse the host’s utility classes, e.g.text-muted,u-fs1, or ship your own via an enqueued stylesheet), wire behavior through the host’sdata-actiondispatch or your enqueued script, and set any dynamic styling at runtime through the CSSOM (el.style.x = y), which the policy does not govern. See SECURITY.md → GUI Response Headers.
Empty state. When zero plugins have a panel registered, the Plugins tab shows a helpful empty-state message (“No plugins installed yet. Upload a plugin from Settings → Plugins.”) instead of an empty dropdown.
Persistence. The operator’s last-viewed panel selection is
saved to localStorage; revisiting the Plugins tab restores the
last choice. A stored selection pointing at an uninstalled plugin
silently falls back to the first registered panel.
Deprecation note: the older
tabsmanifest field is no longer the registration path for plugins — the IPC forwarder logs a deprecation warning and does not register manifesttabsentries. Existing plugins should move their panel intoplugin_tab_panel(drop the outertabslist entirely) and rename the dispatcher’s render handler fromtab:<id>toplugin_tab_panel. Seefiles/plugins/hello-eiou/for the reference migration.
Sub-tabs in your panel
Each plugin gets one panel, but a plugin with several distinct
surfaces (settings, keys, usage, …) can organise them into sub-tabs
inside that panel — the same tab affordance as the wallet’s New
eIOU form. It’s a host-provided, reusable component: you emit a small
markup convention in your plugin_tab_panel HTML and the host’s global
stylesheet + script do the rest. Your plugin ships no CSS or JS for
this — the switching is wired by the host (the eiouSubtab
data-action), and the styling lives in the always-present page.css.
<div class="eiou-subtabs-wrap" data-subtabs>
<div class="eiou-subtabs" role="tablist" aria-label="My plugin sections">
<button type="button" class="eiou-subtab is-active" role="tab" aria-selected="true"
data-action="eiouSubtab" data-subtab="settings">
<i class="fas fa-sliders-h"></i> Settings
</button>
<button type="button" class="eiou-subtab" role="tab" aria-selected="false"
data-action="eiouSubtab" data-subtab="keys">
<i class="fas fa-key"></i> Keys
</button>
</div>
<div class="eiou-subtab-panel is-active" data-subtab-panel="settings" role="tabpanel">
… settings UI …
</div>
<div class="eiou-subtab-panel" data-subtab-panel="keys" role="tabpanel" hidden>
… keys UI …
</div>
</div>
Rules of the convention:
- A group is any element with
data-subtabs. You can have several groups in one panel, and groups may even nest — switching is scoped to the clicked tab’s nearestdata-subtabsancestor, so groups never interfere with each other (or with the wallet’s own tabs). - Each tab is
class="eiou-subtab"+data-action="eiouSubtab"+data-subtab="<name>". Mark the default oneis-active/aria-selected="true". - Each panel is
class="eiou-subtab-panel"+data-subtab-panel="<name>"matching a tab’sdata-subtab. Mark the default oneis-active; addhiddento the rest. (If you mark none active, the host defaults to the first tab on load.) - For a left-hand vertical menu instead of a top bar, add
eiou-subtabs--verticalto the wrap element.
Mobile is handled for you. A group with three or more tabs would wrap to multiple rows on a phone, so on narrow screens (≤600px) the host automatically builds a native dropdown from your tabs and shows that instead of the bar — the same affordance as the plugin selector above your panel. You don’t emit or manage the dropdown; it mirrors the tabs and switches the same panels. One or two short tabs stay as a bar (they fit fine). So feel free to use as many tabs as your panel needs.
Token-for-token, the only thing that changes per tab is the
data-subtab / data-subtab-panel name pairing. files/plugins/hello-eiou/
uses this component (Fortune / Plugin info) as a worked reference, and
ships zero tab JS/CSS of its own.
POST action handlers
GUI POST actions (form submits + AJAX) are declared in the manifest
and handled in the dispatcher with type: "action". The wallet’s
GuiActionRegistry registers an IPC-forwarding handler for each
declared gui_actions entry; when a request arrives with the
matching $_POST['action'], the forwarder bridges it into the
plugin’s dispatcher.
Manifest:
{
"gui_actions": [
{"name": "myPluginBookmark", "tier": "csrf"}
]
}
Field rules: name is camelCase, 1–64 chars; tier is one of:
| Tier | Gate the registry enforces before reaching your handler |
|---|---|
public |
None — anonymous callers OK. Use sparingly. |
auth |
Authenticated session required; CSRF check left to the handler. |
csrf |
Auth + valid CSRF token (non-rotating). On failure the registry emits {"success":false,"error":"csrf_error","message":"Invalid CSRF token"} with HTTP 403 — your handler never runs. Default for new plugin AJAX handlers. |
sensitive |
Auth + CSRF + recent sensitive-access grant (same gate that protects “Reveal API key”, “Delete account”, etc.). |
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'action':
if ($name === 'myPluginBookmark') {
// $context carries the POST body as 'request' and the
// contact_address (if any) automatically populated by the GUI's
// contact-action wiring.
$address = $context['request']['contact_address'] ?? '';
// … do the work …
respond(200, [
'result' => ['success' => true, 'message' => 'Bookmarked!'],
], $log);
}
respond(404, [
'ok' => false,
'error' => ['code' => 'unknown_action', 'message' => "no handler for {$name}"],
], $log);
The handler’s result becomes the response body the host emits to
the browser. For redirect-style responses (legacy form submits), set
the redirect field instead of result; the host emits a 303 to
the given URL with a flash message attached.
Last-write-wins on collisions — a plugin declaring an action
name that core or another plugin already registered overrides the
earlier handler. The order is: core’s own actions register first,
then plugins in the order bootAll() iterates them. Overriding
core actions (e.g. addContact, sendEIOU, apiKeysCreate) is
permitted but fragile — the JS client expects specific response
shapes and will misbehave if your override changes them. Do it
deliberately or not at all.
Forms rendered by the gui.contact.actions filter post to
/wallet?action=<name>; you don’t need a separate route.
Wiring gui.contact.actions to a registered handler
Each gui.contact.actions entry renders as:
<form method="POST" class="plugin-contact-action">
<input type="hidden" name="csrf_token" value="…">
<input type="hidden" name="action" value="myPluginBookmark">
<input type="hidden" name="contact_address" class="plugin-contact-action-address">
<button type="submit" class="btn btn-secondary">Bookmark</button>
</form>
The host’s openContactModal() JS populates every
.plugin-contact-action-address input with the open contact’s address,
so the handler receives $_POST['contact_address'] without any
plugin-side wiring.
Discovering hooks at runtime (PLUGIN_HOOKS_TRACE)
Set PLUGIN_HOOKS_TRACE=1 in the node environment to log every hook
fire (kind, name, listener count, errors) at INFO level. Useful for
plugin authors discovering which hooks the host actually calls without
grepping templates. Off by default — costs zero in production.
docker-compose exec alice sh -c 'PLUGIN_HOOKS_TRACE=1 php-fpm -D'
# or set in docker-compose / .env and restart
The trace is also available programmatically as Hooks::getTrace() for
test assertions.
Versioning + discoverability
Hook names + payloads form an API. Breaking changes follow the same deprecation policy as any host-side API. New hooks are added as needed — file an issue if your plugin needs an injection point that doesn’t yet exist.
The hello-eiou example plugin (see files/plugins/hello-eiou/)
exercises every surface above: a declared CSS asset, a dashboard
render hook, a Fortunes top-level tab, the helloEiouFortune POST
action at the csrf tier, the gui.dashboard.widgets filter, and
the gui.contact.actions filter. Manifest declarations live in
plugin.json; the corresponding handlers live in
__dispatch.php’s switch. It’s the smallest end-to-end reference
for a sandboxed plugin’s GUI surface.
Registering Payback-Method Rail Types
Core ships 29 payback-method rail types. Two of them — bank_wire
(with SEPA / Faster Payments / ACH / FedNow / SWIFT) and custom
(free-text instructions) — are hardcoded into
PaybackMethodTypeValidator’s dispatch switch for historical
reasons. The other 27 (btc, venmo, evm, solana, tron,
lightning, paypal, revolut, wise, cashapp, zelle, pix,
xrp, stellar, monero, utxo_alt, upi, mobile_payment,
alipay, wechat_pay, ton, cardano, algorand, interac, exchange_p2p, mercadopago, paynow) implement
PaybackMethodTypeContract
directly under Eiou\Payback\* and are wired into the registry via
registerCoreType(), called from
ServiceContainer::getPaybackMethodTypeRegistry() at first registry
access. The contract-based core types under files/src/payback/
are the canonical reference for what a plugin-provided rail looks
like end-to-end.
Niche regional networks, new chains, custom gift-card systems, etc. are plugin opportunities. The contract that plugins implement is the same one the core types use directly; the difference is the dispatch path (in-process vs IPC over the plugin sandbox).
Sandboxed plugins declare rail types in the manifest’s
payback_method_types list and handle the dynamic contract methods
(validate, mask, defaultPrecision, and the optional
buildPaymentUri) in __dispatch.php under a new
case 'payback_method': arm. The wallet pool’s
PaybackMethodTypeRegistry is populated at boot with an
IpcPaybackMethodTypeProxy per declared type — proxies hold the
static catalog row in memory and IPC into the plugin’s dispatcher for
each dynamic method call.
Manifest:
The manifest + dispatcher example below uses Bitcoin (
btc) as the reference rail because it exercises every contract method includingbuildPaymentUri. Important:btcis now reserved as a core type (Eiou\Payback\BtcType) — along with 28 other reserved ids; plugins cannot claim any of them. See the Reserved rail ids section below for the full list. For a real plugin, pick an unclaimed id (something niche or new —gnosis_safe,dash,nano,paynow_sg, a CBDC, a regional gift-card network, etc.). The in-tree core implementations underfiles/src/payback/*are the canonical examples of contract-based rails; the dispatcher example here shows the equivalent shape for the sandboxed path.
{
"name": "payback-btc",
"version": "0.1.0",
"entryClass": "Eiou\\Plugins\\PaybackBtc\\PaybackBtcPlugin",
"autoload": { "psr-4": { "Eiou\\Plugins\\PaybackBtc\\": "src/" } },
"sandboxed": true,
"payback_method_types": [
{
"id": "btc",
"catalog": {
"id": "btc",
"label": "Bitcoin",
"group": "crypto",
"icon": "fab fa-bitcoin",
"description": "Settle in BTC. Accepts a mainnet address.",
"currencies": ["BTC"],
"fields": [
{
"name": "address",
"label": "Bitcoin address",
"type": "text",
"required": true,
"placeholder": "bc1q…"
}
]
}
}
]
}
Multiple types per plugin are supported — declare each one as a
separate payback_method_types entry. id must match
^[a-z][a-z0-9_]{0,31}$ and must not be one of the 29 reserved
core ids; the manifest validator drops bad entries before they
reach the registry. See Reserved rail ids
for the full list. catalog is the static GUI row documented under
What the contract plugs into.
Dispatcher handler:
// inside __dispatch.php's switch ($type)
case 'payback_method':
$typeId = $context['type_id'] ?? '';
$currency = $context['currency'] ?? '';
$fields = $context['fields'] ?? [];
if ($typeId !== 'btc') {
respond(404, [
'ok' => false,
'error' => ['code' => 'unknown_type', 'message' => "no handler for type '{$typeId}'"],
], $log);
}
if ($name === 'validate') {
// Return a list of {field, code, message} records. [] = success.
if ($currency !== 'BTC') {
respond(200, ['result' => [[
'field' => 'currency', 'code' => 'invalid_currency_for_type',
'message' => 'Bitcoin settles in BTC',
]]], $log);
}
if (empty($fields['address'])) {
respond(200, ['result' => [[
'field' => 'address', 'code' => 'required',
'message' => 'address is required',
]]], $log);
}
respond(200, ['result' => []], $log);
}
if ($name === 'mask') {
$a = (string) ($fields['address'] ?? '');
$masked = $a === '' ? '•••' : substr($a, 0, 6) . '…' . substr($a, -4);
respond(200, ['result' => $masked], $log);
}
if ($name === 'defaultPrecision') {
// [min_unit, exponent] — satoshi precision when currency is BTC,
// null otherwise so SettlementPrecisionService falls back to the
// generic crypto / fiat default.
respond(200, [
'result' => $currency === 'BTC' ? [1, -8] : null,
], $log);
}
if ($name === 'buildPaymentUri') {
// OPTIONAL — only implement if this rail can synthesize a
// payment URI / payload from the saved fields plus a payer-
// entered amount and memo. Plugins that don't implement this
// arm can omit it: the host's IpcPaybackMethodTypeProxy maps
// a 501 handler_not_found back to null, which the host's
// PaymentUriBuilderService treats as "no URI for this rail"
// (the GUI falls back to copy-fields-and-pay-manually).
//
// Return shape (one of):
// ['kind' => 'uri', 'value' => '<full uri>', 'scheme' => '<scheme>']
// ['kind' => 'qr', 'value' => '<payload>', 'mime' => 'text/plain']
// null (no URI for these inputs)
//
// Context:
// $context['amount'] string, payer's amount in major units
// $context['memo'] string|null, payer's free-form note
$addr = (string) ($fields['address'] ?? '');
$amount = (string) ($context['amount'] ?? '');
$memo = $context['memo'] ?? null;
if ($addr === '' || $amount === '' || $currency !== 'BTC') {
respond(200, ['result' => null], $log);
}
$uri = 'bitcoin:' . $addr . '?amount=' . $amount;
if (is_string($memo) && $memo !== '') {
$uri .= '&message=' . rawurlencode($memo);
}
respond(200, ['result' => [
'kind' => 'uri',
'value' => $uri,
'scheme' => 'bitcoin',
]], $log);
}
respond(404, [
'ok' => false,
'error' => ['code' => 'unknown_method', 'message' => "no handler for {$name}"],
], $log);
The proxy’s IPC-failure behaviour is operator-friendly: a transport
failure on validate surfaces as a top-level plugin_ipc_failed
error record (the operator sees “could not check” rather than the
form silently passing); mask falls back to '•••' (list-view
shouldn’t break a row over a transient plugin blip); defaultPrecision
falls back to null (SettlementPrecisionService’s generic default
applies). Authors don’t need to worry about transport — the proxy
handles every failure mode with a sensible degradation.
In-process equivalent (core types)
Core’s own rail types register the in-process way because they ship in the wallet pool by design. Two patterns are used:
-
Hardcoded dispatch switch —
bank_wireandcustomare handled by switch arms insidePaybackMethodTypeValidatorandPaymentUriBuilderService. They never touch the registry. This pattern predates the contract and is preserved for those two types to avoid churn. -
Contract-registered core types — the 27 rails under
Eiou\Payback\*(BtcType,VenmoType,EvmType,SolanaType,TronType,LightningType,PaypalType,RevolutType,WiseType,CashappType,ZelleType,PixType,XrpType,StellarType,MoneroType,UtxoAltType,UpiType,MobilePaymentType,AlipayType,WechatPayType,TonType) all implement the samePaybackMethodTypeContracta plugin would — plusPaymentUriBuilderContractfor the rails with a portable URI scheme (most crypto rails, PayPal.me, Wise Quick Pay, UPI, etc.; the rails without one, like Zelle, Revolut personal links, Cashapp, Venmo prefilled-amount-only links, MobilePay-family, Alipay, WeChat Pay, intentionally skip the URI contract and the GUI renders the manual-tier UX). They register intoPaybackMethodTypeRegistryfromServiceContainer::getPaybackMethodTypeRegistry()at first registry access via the internalregisterCoreType()method. These 27 are the canonical reference for what a complete contract-based rail looks like — pick the one closest to your rail’s shape (multi-variant token rail → studyEvmType; regional bundle with sub-services →MobilePaymentType; simple-handle P2P →VenmoType):
final class BtcType implements PaybackMethodTypeContract, PaymentUriBuilderContract
{
public function getId(): string { return 'btc'; }
public function getCatalogEntry(): array { /* catalog block */ }
public function validate(string $currency, array $fields): array { /* … */ }
public function mask(array $fields): string { /* … */ }
public function defaultPrecision(string $currency): ?array { /* [1, -8] for BTC */ }
public function buildPaymentUri(string $currency, array $fields, string $amount, ?string $memo): ?array {
/* returns ['kind' => 'uri', 'value' => 'bitcoin:…', 'scheme' => 'bitcoin'] */
}
}
Plugin-side dispatchers don’t implement this interface directly; the
IpcPaybackMethodTypeProxy in the wallet pool does it on the plugin’s
behalf and forwards each method to the sandboxed __dispatch.php.
Method bodies translate 1:1 between the in-process class form (used
by BtcType / VenmoType) and the dispatcher switch form (used by
sandboxed plugins).
What the contract plugs into
The registry is consulted from four places in core:
| Caller | Uses |
|---|---|
PaybackMethodTypeValidator::getCatalog() |
getCatalogEntry() — merges the entry into the GUI type-picker catalog. Any new group id declared by a plugin is auto-injected into the groups list (between bank and other). |
PaybackMethodTypeValidator::validate() |
validate() — delegated for unknown type ids. Return [] on success, or a list of ['field' => string|null, 'code' => string, 'message' => string] error records. |
PaybackMethodService::maskForType() |
mask() — short redacted string for the list-row cell. Return '•••' on missing fields rather than throwing. |
SettlementPrecisionService::defaultFor() |
defaultPrecision() — return [min_unit, exponent] (e.g. [1, -8] for satoshi) or null to fall back to the generic fiat/crypto defaults. |
PaymentUriBuilderService::build() |
buildPaymentUri() (optional) — given saved fields plus a payer-entered $amount and $memo, return a clickable {kind:'uri',value,scheme} payload, a QR-only {kind:'qr',value,mime} payload, or null (rail has no machine-payable form for these inputs — GUI falls back to copy-fields). The proxy implements PaymentUriBuilderContract for every plugin-registered type and forwards to the dispatcher; a plugin without the handler returns 501 which the proxy maps back to null. |
Registration rules
idmust match^[a-z][a-z0-9_]{0,31}$- The full reserved list is 25 ids spanning every payback-method type
. Plugins cannot
shadow any of them. See the Reserved rail ids
table below for the breakdown. The manifest validator drops entries
with reserved ids before they reach the registry; the registry’s
own collision check is the defence-in-depth net. Core registers the
contract-based rails through the registry itself via the internal
registerCoreType()method, which bypasses the reservation; onlyregister()enforces it, and that’s what plugins call.
Reserved rail ids
The 29 reserved ids fall into three groups by implementation path:
| Group | Ids | Implementation |
|---|---|---|
Hardcoded dispatch switch in PaybackMethodTypeValidator and PaymentUriBuilderService |
bank_wire, custom |
Pre-contract; dispatched by switch arms |
Contract-registered core in Eiou\Payback\*, registered through the registry via registerCoreType() at ServiceContainer startup |
btc, venmo, evm, solana, tron, lightning, paypal, revolut, wise, cashapp, zelle, pix, xrp, stellar, monero, utxo_alt, upi, mobile_payment, alipay, wechat_pay, ton, cardano, algorand, interac, exchange_p2p, mercadopago, paynow |
Implements PaybackMethodTypeContract (plus PaymentUriBuilderContract when the rail has a portable URI scheme) |
The full catalog covers:
- Crypto rails (
cryptogroup):btc,evm(Ethereum and EVM-compatible L1s/L2s),solana,tron,lightning,xrp,stellar,monero,utxo_alt(Litecoin / Bitcoin Cash / Dogecoin),ton(TON / Telegram Wallet ecosystem),cardano(ADA, Shelley bech32),algorand(ALGO + ASAs) - P2P / fintech rails (
p2pgroup):venmo,paypal,revolut,wise,cashapp,zelle,pix,upi,mobile_payment(Swish / Vipps / MobilePay / TWINT / Bizum / BLIK),alipay,wechat_pay,interac(Canada e-Transfer),exchange_p2p(Coinbase / Binance Pay / Crypto.com / OKX / Bybit / KuCoin),mercadopago(Argentina / Brazil / Mexico),paynow(Singapore; SGQR EMV-QR) - Bank rails (
bankgroup):bank_wire(SEPA / Faster Payments / ACH / FedNow / SWIFT sub-rails) - Catch-all (
othergroup):custom - Each id can only be registered once across all enabled plugins; the second-arriving plugin’s entry is skipped with a logged warning and the first plugin keeps the slot.
- For sandboxed plugins, registration happens at wallet boot through
the IPC forwarder — no in-process call from the plugin’s PHP. A
wallet restart is required after enabling a plugin that declares
payback_method_types(same as any other manifest-declared surface), so the forwarder picks the new entries up.
Region tags — soft picker filter
getCatalogEntry()['regions'] is an optional array of region codes the
rail is primarily used in. The GUI renders a chip-filter row above the
type-picker tiles; clicking a chip narrows the visible rails to those
declaring that region — except rails tagged GLOBAL, which surface
under every chip’s filter so cross-region rails (PayPal, Wise, crypto,
SWIFT bank wire) stay reachable regardless of which chip is selected.
Recognised codes today: GLOBAL, AMERICAS, EUROPE, AFRICA,
ASIA, OCEANIA. The chip set is derived dynamically from the union
of every type’s regions array, so a plugin declaring a custom code
(e.g. MIDDLE_EAST, NORDICS) automatically appears as a new chip —
unrecognised codes slot in alphabetically after the recognised ones.
Title-cases happen client-side (MIDDLE_EAST → Middle east); for a
prettier label, contribute a REGION_LABELS entry to script.js.
regions is optional. Omitting it means the rail appears under “All”
but not under any specific region chip — same effective behaviour as
tagging GLOBAL from the All-default-view perspective, just less
intentional. Prefer setting it explicitly.
Field schema — what the GUI renders
getCatalogEntry()['fields'] is an array of field descriptors the
GUI’s two-step “Add method” modal iterates over. Each entry has:
| Key | Required | Notes |
|---|---|---|
name |
yes | POSTed field key. Must be unique within the type’s fields. |
label |
yes | Shown above the input. |
type |
yes | text | email | tel | number | select | textarea |
required |
no | Adds the red * (with a hover tooltip “Required field”) and sets HTML5 required. Default false. |
placeholder |
no | Shown inside empty inputs. |
help |
no | Small muted text under the input. Static. Use helpFor for variant-aware help. |
options |
no | select-only. Array of {value, label} entries. |
showWhen |
no | Conditional visibility: {field: 'otherFieldName', in: ['value1', 'value2']}. Use it to hide per-sub-rail fields (e.g. a memo that only applies for one of several options in a select above). |
helpFor |
no | Dynamic help that tracks another field’s value: {field: 'watchedFieldName', map: {'val1': 'help text 1', 'val2': 'help text 2'}}. When the watched field changes, the help text under this field swaps to the matching map entry. Falls back to the static help value when the watched value isn’t in the map. Useful when a field’s expected format depends on another picker (e.g. Lightning identifier format depends on identifier_kind; mobile_payment identifier format depends on service). |
optionsFor |
no | select-only. Filters the <select>’s options based on another field’s value: {field: 'watchedFieldName', map: {'val1': ['allowedOptionValue', ...], 'val2': [...]}}. The canonical full option list stays in options; optionsFor narrows what the operator can pick when the watched-field value matches. When the watched value isn’t a key in the map, all options stay visible (graceful fallback). When the currently-selected value is filtered out, the picker auto-switches to the first remaining option. Mirrors helpFor (text-only) and currenciesFor (Settles-in picker). |
The validator is the source of truth for validity — required in the
schema is a UX hint, not a check. Your validate() implementation is
what the server enforces.
helpFor example
A common pattern is a primary identifier whose format depends on a sub-kind picker. The Lightning catalog uses this:
[
'name' => 'identifier_kind',
'label' => 'Identifier kind',
'type' => 'select',
'required' => true,
'options' => [
['value' => 'lightning_address', 'label' => 'Lightning Address (user@domain)'],
['value' => 'lnurl', 'label' => 'LNURL-pay (LNURL1…)'],
['value' => 'bolt11', 'label' => 'BOLT11 invoice (lnbc…)'],
],
],
[
'name' => 'identifier',
'label' => 'Identifier',
'type' => 'text',
'required' => true,
'placeholder' => 'alice@strike.me',
'help' => 'Format depends on identifier kind selected above.',
'helpFor' => [
'field' => 'identifier_kind',
'map' => [
'lightning_address' => 'Lightning Address: <handle>@<domain>, just like an email. Resolves to a fresh BOLT11 invoice over HTTPS (LUD-16).',
'lnurl' => 'LNURL-pay: bech32 string starting with LNURL1… (uppercase or lowercase). Encodes a callback URL that the payer\'s wallet fetches for a fresh invoice.',
'bolt11' => 'BOLT11 invoice: a static lnbc… invoice. Note that BOLT11 invoices have an expiry and are typically single-use — only useful for one-off settlements.',
],
],
],
The watched field doesn’t have to be a select — any input the user
can change works. The GUI attaches both change and input
listeners, and runs the resolver once on initial render so the help
text is correct from the moment the form opens.
optionsFor example
optionsFor filters which entries of a <select>’s options are
selectable based on another field’s current value. Use it when a
single picker’s valid choices depend on an upstream picker. The
MercadoPago catalog uses this for its country-keyed identifier kinds:
[
'name' => 'country',
'label' => 'Country',
'type' => 'select',
'required' => true,
'options' => [
['value' => 'AR', 'label' => 'Argentina (ARS)'],
['value' => 'BR', 'label' => 'Brazil (BRL)'],
['value' => 'MX', 'label' => 'Mexico (MXN)'],
],
],
[
'name' => 'identifier_type',
'label' => 'Identifier kind',
'type' => 'select',
'required' => true,
'options' => [
['value' => 'alias', 'label' => 'Alias (AR, e.g. juanperez.mp)'],
['value' => 'cvu', 'label' => 'CVU (AR, 22 digits)'],
['value' => 'cbu', 'label' => 'CBU (AR, 22 digits)'],
['value' => 'cpf', 'label' => 'CPF (BR, 11 digits)'],
['value' => 'clabe', 'label' => 'CLABE (MX, 18 digits)'],
['value' => 'email', 'label' => 'Email'],
['value' => 'phone', 'label' => 'Phone (E.164)'],
],
'optionsFor' => [
'field' => 'country',
'map' => [
'AR' => ['alias', 'cvu', 'cbu', 'email'],
'BR' => ['email', 'phone', 'cpf'],
'MX' => ['email', 'phone', 'clabe'],
],
],
],
Notes:
optionsForis a UX hint, not a validity check. Yourvalidate()implementation is still the source of truth — server-side rejection of an invalid combination is what catches a stale form, a non-GUI caller, or a hand-crafted API payload.- The canonical full option list stays in
options;optionsForfilters the visible subset. On form open and on every watched-field change, the picker is rebuilt to the current allowed subset. - If the operator’s current selection falls outside the new allowed subset (e.g. they had picked CLABE then switched country to AR), the picker auto-switches to the first remaining option so the form never submits an invalid combination.
Long-form info (catalog info key)
In addition to description (one-liner, shown on the tile), an entry
may include info — an HTML string rendered as a collapsible About
<strong>, <em>, <code>, <br>,
<ul>, <li> are common and safe). Every core rail populates info
— the in-tree implementations under files/src/payback/* show how
to scope an info block to the friction points a given rail surfaces.
Currency binding
eIOU treats every distinct currency code as its own unit of obligation,
including token symbols. USDT is USDT. USDC is USDC. EURC is EURC.
A debt of “200 USDT” is a USDT obligation; it is settled by transferring
200 USDT, full stop. There is no implicit “USDT = USD” or “USDC = USD”
or “EURC = EUR” conversion at the ledger level. If an operator wants to
track USD debts, they pick currency=USD and a USD payback method
(ACH / Venmo / Zelle / PayPal / bank wire); if they want to track USDT
debts, they pick currency=USDT and a USDT payback method (on
Ethereum / Tron / Solana / etc.).
This means a rail’s currencies list enumerates every code the rail
supports as a first-class currency, not just the fiat the rail’s
tokens are pegged to:
currencies: ['BTC']— method can only be saved with BTCcurrencies: ['XLM', 'USDC', 'EURC', 'BRL']— Stellar’s native plus the well-known issued assets, each a separate currencycurrencies: ['ETH', 'MATIC', 'BNB', 'AVAX', 'USDC', 'USDT', 'DAI', 'WBTC', 'WETH', 'BUSD']— every EVM native and token, each its own currencycurrencies: ['EUR', 'GBP', 'USD']— fiat rails restricted to a setcurrencies: null— accept any ISO-4217 code (useful for rails like PayPal that auto-convert on receipt). The GUI falls back to the shared ISO-4217 dropdown in the catalog whencurrenciesis null
For rails that span multiple networks where the same currency code can
appear (USDC on Ethereum vs USDC on Polygon vs USDC on Solana), the
currenciesFor map filters the picker by a sibling field (typically
chain). The currency stays as the asset symbol; the chain field
disambiguates which deployment the recipient’s address is on:
'currenciesFor' => [
'field' => 'chain',
'map' => [
'eth_mainnet' => ['ETH', 'USDC', 'USDT', 'DAI', 'WBTC', 'WETH'],
'polygon' => ['MATIC', 'USDC', 'USDT', 'DAI', 'WETH'],
'bsc' => ['BNB', 'USDC', 'USDT', 'DAI', 'BUSD'],
// …
],
],
The validator enforces the binding, not the GUI. If a plugin declares
currencies: ['EUR'] but the incoming $currency is 'USD', validate()
should return an invalid_currency_for_type error record.
GUI rendering notes (relevant for plugin authors):
- The field named by
currenciesFor.field(e.g.chainfor EVM,railforbank_wire) is rendered above the “Settles in” picker, even if it appears later in thefieldsarray. The intent is causal ordering for the operator: pick the chain / variant first, then the picker shows only currencies valid for that pick. Without the reorder, “Settles in” would render with the default-variant’s currencies and force the operator to scroll down, change the variant, and scroll back up to fix the currency. Plugins don’t need to do anything to opt in — the GUI handles it whenevercurrenciesFor.fieldis set. - Currency codes in a per-rail enumerated list are rendered as bare
codes (e.g.
USDC,BRL,XLM). The catalog’s master ISO-4217 fallback list — used only by rails that declarecurrencies: null— keeps the friendly fiat-style long form (e.g.BRL — Brazilian Real). The distinction matters when a plugin’s currency code collides with an ISO-4217 fiat code (e.g.BRLis both the ISO code for fiat Brazilian Real AND a Stellar-issued tokenised BRL asset): the bare rendering prevents the picker from labelling the token “Brazilian Real” when it isn’t. - An optional top-level
currencyHelpstring on the catalog entry overrides the default hint shown under the Settles-in picker (“Only currencies valid for this method are shown.” / “Any ISO-4217 or declared asset code.”). Use it when the rail’s currency list needs an operator-facing caveat the default hint doesn’t convey — e.g. ExchangeP2p ships a curated crypto allowlist but per-exchange asset support varies, so itscurrencyHelpasks the operator to verify their chosen exchange actually supports the picked asset for P2P / Pay transfers.
Shared crypto allowlist + operator-extensible extras file
For flat-allowlist rails (rails where settlement requires no
per-asset metadata beyond the currency code itself), the
Eiou\Payback\CurrencyAllowlists helper provides a shared baseline
plus an operator-extensible config-file merge:
'currencies' => CurrencyAllowlists::getBundledCryptoMajors(),
The baseline (BUNDLED_CRYPTO_MAJORS) is a curated list of the
crypto codes commonly listed across the bundled exchange rails’
P2P / Pay surfaces (BTC, ETH, the major stablecoins, the L1s and
alts commonly bridged across exchanges, and a couple of DeFi
tokens). The helper merges in any extras from the
extraCryptoCodes user setting — a comma-separated list of
codes the operator manages from Settings → Currency → "Extra crypto codes for bundled rails" in the GUI, or via
eiou changesettings extraCryptoCodes "WSTETH,1INCH,KASPA". Same
shape rule as every other currency code in the wallet (letters /
digits, upper-cased on load for this setting, length bounded by
Constants::VALIDATION_CURRENCY_CODE_MIN_LENGTH /
MAX_LENGTH); malformed entries are silently dropped at load
time so a typo in the setting does not crash the catalog build.
The setting is a separate concern from allowedCurrencies.
allowedCurrencies is the wallet-wide obligation-acceptance gate
(your ledger only tracks debts in those codes); extraCryptoCodes
is a per-rail UI enumeration extension and does not affect what
your wallet accepts as obligations. A code the operator actually
intends to receive payment in must be added to
allowedCurrencies as well so the ledger will track the
resulting obligation.
Scope boundary. This shared helper and its extras file are
intentionally for flat-allowlist rails only. Chain-specific rails
(Stellar, EVM, Solana, TRON, TON, Algorand, Cardano, Lightning,
BTC, Monero, XRP, UTXO-alt) need rich per-asset metadata (issuer
account, contract address, mint pubkey, jetton master, ASA id, …)
in Tokens.php that a flat list of strings cannot supply.
Brand-new asset codes for those rails still require a code change
to Tokens.php; the operator-facing escape hatch for them is the
per-rail override field each rail already exposes (Stellar’s
issuer, EVM’s token_contract, Solana’s spl_token, TON’s
jetton_master, Algorand’s asset_id, etc.).
Why not fiat-pegged?
Treating USDC as USD looks tempting (it’s “the same dollar”) but two
ledger-level problems make it impractical: (1) stablecoins can depeg
(USDC briefly hit $0.87 during the SVB collapse in March 2023), so
treating them as 1:1 with their pegged fiat moves depeg risk onto the
recipient invisibly. (2) USDC on Ethereum and USDC on Polygon are
bridged-but-not-fungible at settlement time — the operator with USDC on
Ethereum can’t just send it to a Polygon address. Splitting currency
into “unit of obligation” (USD) and “settlement asset” (USDC-Ethereum,
USDC-Polygon, …) would be the alternative; that’s a substantial schema
and UX rework and isn’t shipping in core today. The simpler model
documented here is what core enforces.
Testing a payback-method type plugin
Instantiate the type directly and exercise its surface:
public function testValidateRejectsWrongCurrency(): void
{
$t = new BtcType();
$errs = $t->validate('USD', ['address' => 'bc1q...xyz']);
$this->assertSame('currency', $errs[0]['field']);
$this->assertSame('invalid_currency_for_type', $errs[0]['code']);
}
For end-to-end coverage, drive the validator directly with a real
PaybackMethodTypeRegistry:
$reg = new PaybackMethodTypeRegistry();
$reg->register(new BtcType());
$v = new PaybackMethodTypeValidator($reg);
$this->assertSame([], $v->validate('btc', 'BTC', ['address' => 'bc1q...xyz']));
The core test suite’s PaybackMethodTypeRegistryTest is a good
reference for id-shape / core-shadow / duplicate-registration /
catalog-merge / precision-override patterns.
Testing a Plugin
Plugin code is ordinary PHP and can be tested with the same toolchain as core:
cd files
./vendor/bin/phpunit tests/Unit/Plugins/MyPluginTest.php
The test helpers in tests/Unit/Services/PluginLoaderTest.php (see
writePluginWithExtras and validPluginSource) show how to build temporary
plugin directories for test fixtures without touching the shared plugin root.
When testing event subscriptions, dispatch events directly on the
EventDispatcher singleton and assert on the plugin’s observable side
effects (log calls, counters, service-container state).
Safety Model and Limitations
Failure isolation
A plugin that throws during discover, register, or boot is caught and
marked failed with the exception message attached. The failure is logged at
error level with plugin_loader context, and the GUI surfaces the error
inline in the plugin’s detail modal. Core bootstrap continues.
Idempotency
registerAll() and bootAll() skip plugins that have already completed that
phase — the lifecycle is once-per-loader regardless of how many times the
entry points are called. This is a defensive measure against some
edge cases in the PHP-FPM request lifecycle where the same worker PID would
otherwise run the lifecycle twice and double-subscribe event listeners.
Sandboxing is mandatory
Every plugin must declare "sandboxed": true in its manifest. The
in-process plugin model has been removed — a non-sandboxed plugin
could read the master key and decrypt the seed phrase, so the loader
refuses to load plugins missing the flag.
In practice:
- At install —
PluginInstallServicerejects zip uploads whose manifest lacks"sandboxed": true. The plugin’s bytes never reach the plugins directory. - At enable —
PluginLoader::setEnabled(true)refuses non- sandboxed plugins. The state file is not modified. - At boot —
discover()records non-sandboxed plugins with statuslegacy_unsupported, never registers their autoloader, never instantiates their entry class. Any plugin still marked enabled inplugins.jsonafter a manifest downgrade gets auto- flipped to disabled and a single notice is written to the wallet log so the operator knows what happened.
Plugins run in their own per-plugin PHP-FPM pool as their own Unix
user (eiou-p-<hash>), with open_basedir restricted to the
plugin’s dir + scratch, disable_functions blocking shell-out and
eval, and zero filesystem access to wallet secrets. Communication
with core happens over loopback HTTP through a per-plugin bearer
token to a whitelisted service gateway. See Sandboxed Plugin
Authoring for the contract.
The disabled-by-default rule still applies — a freshly-discovered plugin doesn’t run until the operator explicitly enables it.
URL validation for GUI rendering
homepage, changelog, and author.url are validated as absolute http(s)
URLs via filter_var(..., FILTER_VALIDATE_URL) plus an explicit
^https?:// regex. A manifest with a javascript:alert(1) URL has that
field silently dropped — the <a href> the GUI renders cannot be coerced
into executing script. Bundled-CHANGELOG rendering goes through
UpdateCheckService::markdownToHtml(), which wraps every code/text span in
htmlspecialchars before inserting structural tags.
State file
/etc/eiou/config/plugins.json is the sole source of truth for the persisted
enabled flag. It is written atomically via temp-file + rename. Corrupted or
unreadable state falls back to “all plugins disabled” — no crash, no ghost
state.
Two writers produce the file in normal operation: the wallet pool (running as
www-data, via GUI/REST plugin toggles) and the operator CLI (eiou plugin enable|disable, typically running as root inside the container via docker exec). Both must produce a file the wallet pool can read, otherwise CLI-driven
changes would be invisible to HTTP requests until a subsequent www-data-owned
write re-established readability. writeState() chmods the temp file to 0640
and chgrps it to www-data before the atomic rename, so the root-write path
ends up root:www-data 0640 and the www-data-write path ends up
www-data:www-data 0640; in both cases the wallet pool can read its own state.
Mirrors the multi-writer ownership pattern used elsewhere for plugin-gateway
tokens.
Privileged writes into the plugin directory
Rule: host code that needs to write a file under
/etc/eiou/plugins/<id>/ MUST route the write through the supervisor’s
plugin_routing_poller (via the /tmp/eiou-routing-req-*.json request-file
protocol), not write directly from the wallet pool. The wallet pool runs as
www-data and cannot reliably write into the per-plugin dir; the supervisor
runs as root and can.
Why: the directory’s owner varies by install topology. Production
zip-uploads land the dir owned by www-data (the wallet pool extracted the
zip and chowned it that way), so a direct www-data write would actually
succeed. Dev bind-mount layouts — where /etc/eiou/plugins/ is mounted from
the host filesystem — inherit the host operator’s uid. On a typical host
that uid is 1000, which inside the container collides with the
eiou-p-<8hex> plugin pool user (also derived to land low) rather than
with www-data. Direct writes from www-data into that dir then fail with
EACCES, the failure surfaces as “plugin won’t enable” with no obvious cause,
and the operator’s workaround (usermod -aG eiou-p-<hash> www-data) doesn’t
even work without a full container restart because FPM workers’ supplementary
groups are fixed at master start. Routing through the supervisor sidesteps
the whole class of perm issues — root writes anywhere.
Reference implementation: PluginGatewayTokenService::mint() generates
the token in memory (no filesystem touch), PluginPoolService::applyPool()
ships it through the apply-pool request payload as the gateway_token
field, and plugin_routing_poller in startup.sh writes the per-plugin
.gateway-token file as root with chown <system_user>:<system_user> plus
mode 0600. The request handler validates the token shape (^[a-f0-9]{64}$)
as defence in depth against a corrupted-request write primitive against
arbitrary paths.
Applies to future features too. Anything that needs the host to write into the plugin dir — config snapshots, per-plugin certs, manifest overrides, generated assets — should extend the supervisor poller protocol with a new request type rather than try to write from the wallet pool directly. Doing so keeps the operational story consistent across production and dev bind-mount, and concentrates “wrote a privileged file” auditing in one place (the supervisor’s stdout).
Troubleshooting
Plugin shows status failed with a red dot
Click the row. The detail modal shows the exception message in a red alert block. Common causes:
- Entry class not found — PSR-4 map in manifest doesn’t match the actual
namespace + file path. Check that
autoload.psr-4["Your\\Namespace\\"]matches the namespace declared at the top of the entry class, and that the directory (e.g."src/") matches where the file lives. - Entry class doesn’t implement
PluginInterface— add theimplements PluginInterfaceclause and all four required methods. - Exception thrown in
register()orboot()— the message will be the actual exception message. Check/var/log/app.logfor the full stack trace (theplugin_loadercontext field scopes the search).
Plugin doesn’t appear at all
The plugin was skipped during discovery. Possible causes:
plugin.jsonis missing, unreadable, or invalid JSON —json_decodereturned non-arraynamefield is missing or empty in the manifest- The directory lives somewhere other than
/etc/eiou/plugins/— the path is set in thePluginLoaderconstructor and isn’t exposed as an env var; changing it requires modifying howApplicationinstantiates the loader
PluginLoader swallows discovery-time errors silently to avoid one bad
plugin taking the node down. For verbose logging, bump
APP_DEBUG in the environment and re-read /var/log/app.log.
Toggle doesn’t take effect
A plugin toggle updates /etc/eiou/config/plugins.json immediately but does
not rewire the running processes. You must restart the node for event
subscriptions and service registrations to take effect. The GUI shows a
yellow restart banner whenever the desired state diverges from the runtime;
click Restart node or run eiou restart in a terminal.
Plugin boots but events don’t fire
Double-check:
- The event subscription is in
boot(), notregister()— services aren’t wired yet duringregister(), so the dispatcher may not behave as expected - You’re subscribing to the constant, not a hand-typed string — typos on
sync.completedwill subscribe to a phantom event that never fires - The process you’re testing from is actually the one dispatching the event. Remember that every PHP process has its own subscription table: a sync running in a P2P worker won’t fire listeners that were subscribed from a PHP-FPM worker
Bundled CHANGELOG.md doesn’t render
- The file must be named exactly
CHANGELOG.md(case-sensitive) and live in the plugin’s root directory, next toplugin.json - File size is capped at 256 KB — oversized files fall back to the URL (or hide the Changelog row entirely if no URL is set)
- The markdown parser is the same CommonMark-ish subset used by the What’s New modal. Complex GFM features (tables, task lists, footnotes) are not supported — keep the changelog to headings, bullets, and paragraphs
Related Documentation
- /docs/reference/architecture — overall system architecture, service container, event dispatcher
- /docs/reference/cli-reference —
eiou restartand other node management commands - /docs/reference/gui-reference — Settings section walkthrough
- /docs/reference/docker-configuration — volume mounts and
environment variables, including
/etc/eiou/pluginsand/etc/eiou/config