Skip to content

Commit cc833f2

Browse files
committed
we got plugins
1 parent 9eee826 commit cc833f2

16 files changed

Lines changed: 1088 additions & 4 deletions

File tree

PLUGINS.md

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
# Mintlayer Web GUI — Plugin System
2+
3+
Plugins extend the wallet UI without being part of the main distribution. Each plugin is a Node.js ESM module uploaded as a `.tgz` archive. Plugins run server-side, in the same process as the app, with the same trust level as the app itself — **only install plugins you wrote or fully trust**.
4+
5+
---
6+
7+
## Quick start
8+
9+
```bash
10+
# 1. Build the example plugin
11+
cd examples/mlusdt-price
12+
bash pack.sh # creates examples/mlusdt-price.tgz
13+
14+
# 2. Open the GUI → Management → Plugins → Install
15+
# 3. Upload mlusdt-price.tgz
16+
# 4. Click Enable → "ML Price" appears in the nav
17+
```
18+
19+
---
20+
21+
## Archive structure
22+
23+
A plugin archive must contain these files at the root (or inside one top-level directory, npm-pack style):
24+
25+
```
26+
plugin.json ← required: manifest
27+
index.mjs ← required: handler (or whatever "entry" names)
28+
public/ ← optional: static assets
29+
style.css
30+
logo.svg
31+
```
32+
33+
Both flat and nested archives work:
34+
35+
```
36+
# flat (preferred)
37+
tar -czf myplugin.tgz plugin.json index.mjs
38+
39+
# nested (npm pack output)
40+
tar -czf myplugin.tgz myplugin/plugin.json myplugin/index.mjs
41+
```
42+
43+
---
44+
45+
## plugin.json — manifest
46+
47+
| Field | Type | Description |
48+
|---|---|---|
49+
| `id` | string | Unique identifier. **Lowercase letters, digits, and hyphens only** — used as a URL path segment and directory name. |
50+
| `name` | string | Human-readable name shown in the Plugins management page. |
51+
| `navLabel` | string | Short label shown in the top navigation bar when the plugin is enabled. |
52+
| `version` | string | Semver-style version string, shown in the Plugins list. |
53+
| `entry` | string | Filename of the ESM handler module, relative to the archive root. |
54+
55+
```json
56+
{
57+
"id": "my-plugin",
58+
"name": "My Plugin",
59+
"navLabel": "My Plugin",
60+
"version": "1.0.0",
61+
"entry": "index.mjs"
62+
}
63+
```
64+
65+
**ID rules:** must match `/^[a-z0-9][a-z0-9-]*[a-z0-9]$/`. No uppercase, no leading/trailing dash, minimum 2 characters. The ID becomes the URL path (`/plugins/{id}/`) and the directory name on disk.
66+
67+
---
68+
69+
## index.mjs — handler module
70+
71+
The entry file must export an async `handler` function:
72+
73+
```javascript
74+
export async function handler(request, context) {
75+
//
76+
}
77+
```
78+
79+
### Parameters
80+
81+
| Parameter | Type | Description |
82+
|---|---|---|
83+
| `request` | `Request` | The incoming HTTP request (standard Web API `Request`). Includes method, URL, headers, and body. |
84+
| `context` | `PluginContext` | Shared app context (see below). |
85+
86+
### Return values
87+
88+
The handler can return one of two things:
89+
90+
#### 1. A page — `{ title, html }`
91+
92+
Return a plain object with `title` (string) and `html` (HTML string). The app wraps it in the shared Layout, giving it the standard nav, header, and footer automatically.
93+
94+
```javascript
95+
export async function handler(request, context) {
96+
return {
97+
title: 'My Plugin',
98+
html: '<h1 class="text-2xl font-bold text-gray-100">Hello</h1>',
99+
};
100+
}
101+
```
102+
103+
The `html` string is injected as `innerHTML` inside the main content area, so standard Tailwind CSS classes work out of the box.
104+
105+
#### 2. A raw `Response`
106+
107+
Return a Web API `Response` for anything that isn't an HTML page: JSON APIs, redirects, SSE streams, binary downloads.
108+
109+
```javascript
110+
export async function handler(request, context) {
111+
return Response.json({ ok: true, value: 42 });
112+
}
113+
```
114+
115+
### Routing sub-paths
116+
117+
All requests to `/plugins/{id}/**` reach the same handler. Use the URL to route internally:
118+
119+
```javascript
120+
export async function handler(request, context) {
121+
const url = new URL(request.url);
122+
const path = url.pathname; // e.g. "/plugins/my-plugin/api/data"
123+
124+
if (path === `/plugins/my-plugin/api/data`) {
125+
const balance = await context.walletRpc('account_balance', {
126+
account: 0,
127+
utxo_states: ['Confirmed'],
128+
with_locked: 'Any',
129+
});
130+
return Response.json({ ok: true, balance });
131+
}
132+
133+
// Default: render the page
134+
return { title: 'My Plugin', html: '<p>Hello</p>' };
135+
}
136+
```
137+
138+
---
139+
140+
## PluginContext
141+
142+
```typescript
143+
interface PluginContext {
144+
// Call any wallet-rpc-daemon method server-side (no allowlist restriction)
145+
walletRpc(method: string, params?: Record<string, unknown>): Promise<unknown>;
146+
147+
// Per-plugin key/value storage (backed by the shared SQLite prefs DB)
148+
// Keys are namespaced automatically — no risk of collision with other plugins.
149+
getPref(key: string): unknown;
150+
setPref(key: string, value: unknown): void;
151+
152+
// Base URL of the Mintlayer indexer REST API, or null if not running.
153+
indexerBaseUrl: string | null;
154+
155+
// The original incoming Request object (same as the handler's first argument).
156+
request: Request;
157+
}
158+
```
159+
160+
### `context.walletRpc(method, params)`
161+
162+
Calls the wallet-rpc-daemon directly. See `~/projects/mintlayer/mintlayer-core/wallet/wallet-rpc-daemon/docs/RPC.md` for the full method list.
163+
164+
```javascript
165+
// Check balance
166+
const balance = await context.walletRpc('account_balance', {
167+
account: 0,
168+
utxo_states: ['Confirmed'],
169+
with_locked: 'Any',
170+
});
171+
172+
// Send coins
173+
await context.walletRpc('address_send', {
174+
account: 0,
175+
address: 'tmt1...',
176+
amount: { decimal: '1.5' },
177+
selected_utxos: [],
178+
options: {},
179+
});
180+
```
181+
182+
### `context.getPref / setPref`
183+
184+
Simple key/value store scoped to the plugin. Values are JSON-serialised.
185+
186+
```javascript
187+
context.setPref('last_run', Date.now());
188+
const lastRun = context.getPref('last_run'); // number | null
189+
```
190+
191+
### `context.indexerBaseUrl`
192+
193+
`null` when the indexer profile is not running. If set, it's the internal base URL of the Mintlayer REST API (e.g. `http://api-web-server:3000`).
194+
195+
```javascript
196+
if (context.indexerBaseUrl) {
197+
const res = await fetch(`${context.indexerBaseUrl}/api/v2/token/${tokenId}`);
198+
const info = await res.json();
199+
}
200+
```
201+
202+
---
203+
204+
## Static assets
205+
206+
Files placed in the `public/` directory of your archive are served at `/plugins/{id}/public/{filename}`.
207+
208+
```html
209+
<!-- In your HTML — reference images, CSS, JS from public/ -->
210+
<link rel="stylesheet" href="/plugins/my-plugin/public/style.css">
211+
<img src="/plugins/my-plugin/public/logo.svg" alt="logo">
212+
<script src="/plugins/my-plugin/public/app.js"></script>
213+
```
214+
215+
---
216+
217+
## Auto-refresh pattern
218+
219+
A plugin page can call its own JSON sub-route to update data without a full page reload:
220+
221+
```javascript
222+
// In the HTML returned by the handler:
223+
`<div id="price">$1.23</div>
224+
<script>
225+
setInterval(async () => {
226+
const res = await fetch('/plugins/my-plugin/api/price');
227+
const data = await res.json();
228+
if (data.ok) document.getElementById('price').textContent = '$' + data.price;
229+
}, 30_000);
230+
</script>`
231+
```
232+
233+
The JSON route in the handler:
234+
235+
```javascript
236+
if (url.pathname === '/plugins/my-plugin/api/price') {
237+
const price = await fetchCurrentPrice();
238+
return Response.json({ ok: true, price });
239+
}
240+
```
241+
242+
---
243+
244+
## Authentication
245+
246+
All plugin routes (`/plugins/**`) go through the standard session middleware. Users must be logged in — plugins do not need to implement authentication themselves.
247+
248+
---
249+
250+
## Styling guide
251+
252+
The host app uses **Tailwind CSS** with a dark theme. These classes are reliably available in plugin HTML:
253+
254+
| Purpose | Classes |
255+
|---|---|
256+
| Background | `bg-gray-950`, `bg-gray-900`, `bg-gray-800` |
257+
| Text | `text-gray-100`, `text-gray-400`, `text-gray-500` |
258+
| Accent (mint) | `text-mint-400`, `bg-mint-600`, `border-mint-800` |
259+
| Success | `text-green-400`, `bg-green-900/30` |
260+
| Error | `text-red-400`, `bg-red-900/30` |
261+
| Cards | `rounded-xl border border-gray-800 bg-gray-900/40 p-5` |
262+
| Buttons | `px-4 py-2 rounded bg-mint-600 hover:bg-mint-500 text-white text-sm font-medium` |
263+
264+
---
265+
266+
## Lifecycle
267+
268+
| Event | Behaviour |
269+
|---|---|
270+
| Install | Archive is extracted to `/app/plugins/{id}/`. Plugin starts **disabled**. |
271+
| Enable | Plugin nav item appears immediately (on next page load). Handler is imported. |
272+
| Disable | Nav item disappears. Requests to `/plugins/{id}/**` return 404. |
273+
| Uninstall | Plugin directory deleted, removed from registry. |
274+
| Reinstall | Uninstall first, then install the new archive. The module cache is invalidated automatically. |
275+
276+
---
277+
278+
## Example plugins
279+
280+
| Directory | Description |
281+
|---|---|
282+
| [`examples/mlusdt-price/`](examples/mlusdt-price/) | Displays the current ML/USDT spot price from Bitget with auto-refresh. |
283+
284+
---
285+
286+
## Security notes
287+
288+
- Plugins execute with full Node.js access (filesystem, network, child processes).
289+
- Plugin code can call any wallet RPC method, not just the browser-safe allowlist.
290+
- There is no sandboxing — treat a plugin the same as any server-side dependency.
291+
- The plugin `id` is validated against a strict regex and used as a directory name; path traversal is not possible.

app/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "mintlayer-web-gui",
33
"type": "module",
4-
"version": "0.99.5",
4+
"version": "0.99.6",
55
"scripts": {
66
"dev": "astro dev",
77
"build": "astro build",

app/src/layouts/Layout.astro

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
---
22
import '@/styles/global.css';
33
import ToastContainer from '@/components/ToastContainer';
4+
import { getEnabledPlugins } from '@/lib/plugins';
45
56
export interface Props {
67
title?: string;
7-
activeNav?: 'dashboard' | 'management' | 'balances' | 'staking' | 'token-management' | 'trading' | 'setup';
8+
activeNav?: string;
89
}
910
1011
const { title = 'Home', activeNav } = Astro.props;
@@ -13,6 +14,9 @@ const indexerEnabled = process.env.INDEXER_ENABLED === 'true';
1314
1415
import pkg from '../../package.json';
1516
const version = pkg.version;
17+
18+
let enabledPlugins: { id: string; navLabel: string }[] = [];
19+
try { enabledPlugins = getEnabledPlugins(); } catch { /* DB may not exist yet */ }
1620
---
1721

1822
<!doctype html>
@@ -63,6 +67,19 @@ const version = pkg.version;
6367
{label}
6468
</a>
6569
))}
70+
{enabledPlugins.map(({ id, navLabel }) => (
71+
<a
72+
href={`/plugins/${id}/`}
73+
class:list={[
74+
'px-3 py-1.5 rounded text-sm transition-colors',
75+
activeNav === id
76+
? 'bg-mint-600 text-white'
77+
: 'text-gray-400 hover:text-gray-100 hover:bg-gray-800',
78+
]}
79+
>
80+
{navLabel}
81+
</a>
82+
))}
6683
<form method="post" action="/api/logout" class="ml-2 pl-2 border-l border-gray-800">
6784
<button
6885
type="submit"

app/src/layouts/ManagementLayout.astro

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Layout from '@/layouts/Layout.astro';
33
44
export interface Props {
55
title: string;
6-
activeTab: 'wallet' | 'addresses' | 'transactions' | 'utxos' | 'settings';
6+
activeTab: 'wallet' | 'addresses' | 'transactions' | 'utxos' | 'settings' | 'plugins';
77
}
88
99
const { title, activeTab } = Astro.props;
@@ -14,6 +14,7 @@ const tabs = [
1414
{ href: '/management/addresses', label: 'Addresses', key: 'addresses' },
1515
{ href: '/management/transactions', label: 'Transactions', key: 'transactions' },
1616
{ href: '/management/utxos', label: 'UTXOs', key: 'utxos' },
17+
{ href: '/management/plugins', label: 'Plugins', key: 'plugins' },
1718
];
1819
---
1920

0 commit comments

Comments
 (0)