Feat/plugin instance actions (#2209)

* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
This commit is contained in:
jubnl
2026-09-02 23:06:03 +02:00
committed by GitHub
parent b1ce7c303d
commit b82962f95b
59 changed files with 1133 additions and 128 deletions
+26
View File
@@ -156,6 +156,32 @@ Declare settings in `trek-plugin.json`; TREK renders the form, you write no sett
Any other attribute is **silently dropped at install**. `trek-plugin validate` warns on
one (`manifest.settings-known-keys`) so a typo like `"defalt"` doesn't quietly do nothing.
### Actions
Up to 8 buttons the host renders on a settings form, declared in `trek-plugin.json`
and implemented in `definePlugin({ actions: { <key>: async (ctx) => ({ ok, message }) } })`:
```json
"actions": [
{ "key": "testConnection", "label": "Test connection", "hint": "Pings the API." },
{ "key": "purgeCache", "label": "Purge cache", "scope": "instance", "danger": true }
]
```
| Attribute | Meaning |
|---|---|
| `scope` | `user` (default) — renders on the user Settings tab and runs as the clicking user. `instance` — renders in the admin instance-settings dialog and runs as the clicking admin. The default differs from settings fields on purpose: existing manifests keep their user-tab buttons. A host older than TREK 4.2.0 (which introduced action scope) ignores this field entirely and renders the button on every user's Settings tab, running it as each user — so if you declare `scope: "instance"`, set the manifest's `trek` range floor to `>=4.2.0` or the button will run in the wrong place on an older host. |
| `danger` | Rendered destructive; the host asks for confirmation first. |
| `hint` | Shown beside the button (≤ 200 chars). |
Either way the handler gets the clicking person as acting user: `ctx.config` is the
instance config, `ctx.settings.get()` returns their own user value, and trip reads are
membership-checked against them. Return `{ ok, message }` or throw to report a failed
action (the host shows the message, emoji-stripped, ≤ 200 chars). An instance action is
disabled in the dialog until the plugin is active — there is no child process to run it.
In the mock host, pass `declaredActions: ['testConnection', { key: 'purgeCache', scope: 'instance' }]`
and drive either with `drv.action(key)`.
`ctx.config` is frozen at activation — the host re-spawns the plugin when the admin
saves. `ctx.settings.get()` is live per call and returns `undefined` in a userless
context (`onLoad`, jobs, scheduler) — fall back to `ctx.config` there.
+8 -6
View File
@@ -813,13 +813,15 @@ export interface PluginDefinition {
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
events?: PluginEventSubscription[];
/**
* Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key
* must match an entry in the manifest's `actions`.
* Buttons on the plugin's settings forms ("Test connection", "Sync now", "Purge
* cache"). The key must match an entry in the manifest's `actions`; that entry's
* `scope` decides WHERE the button renders — `'user'` (default) on the user Settings
* tab, `'instance'` in the admin instance-settings dialog.
*
* USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the
* person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are
* membership-checked against them, which is what makes a "test my credentials" button
* possible at all.
* USER-INITIATED either way, so unlike the notificationChannel hook there IS an acting
* user — the person who clicked (a user, or an admin for an instance action).
* `ctx.settings.get()` returns THEIR value, `ctx.config` is the instance config, and
* trip reads are membership-checked against them.
*/
actions?: Record<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | PluginActionResult | void>;
hooks?: {
+7
View File
@@ -60,6 +60,10 @@ export interface ManifestAction {
label?: string;
hint?: string;
danger?: boolean;
/** Which settings form renders the button: `'user'` (default) on the user Settings
* tab, run as the clicking user; `'instance'` in the admin instance-settings dialog,
* run as the clicking admin. Unlike settings fields, the default is `'user'`. */
scope?: 'user' | 'instance';
}
export interface ManifestCapabilities {
settingsUi?: boolean;
@@ -446,6 +450,9 @@ export function validateManifest(raw: unknown): ValidationResult {
if (seen.has(key)) errors.push(`duplicate action "${key}"`);
seen.add(key);
if (a.label !== undefined && typeof a.label !== 'string') errors.push(`action "${key}" label must be a string`);
if (a.scope !== undefined && a.scope !== 'user' && a.scope !== 'instance') {
errors.push(`action "${key}".scope must be "user" or "instance"`);
}
}
}
}
+6 -3
View File
@@ -72,10 +72,12 @@ export interface MockHostOptions {
* refuses it (and the SDK client swallows the rejection, so subscribers silently
* never see it). Unset = record every emit, as before. */
declaredEmits?: string[];
/** Action keys this plugin declares in its manifest `actions`. When set, driving an
/** Action keys this plugin declares in its manifest `actions`, as plain keys or
* `{ key, scope }` entries (the scope is accepted for fixture fidelity; the mock runs
* both scopes with the acting-user ctx, as the host does). When set, driving an
* undeclared key throws — production refuses it before the child is ever woken.
* Unset = any key the plugin implements can be driven. */
declaredActions?: string[];
declaredActions?: Array<string | { key: string; scope?: 'user' | 'instance' }>;
/** Hosts an ADMIN supplies at runtime to an `operatorEgress: true` plugin, which by
* definition cannot name them in its manifest. Only `trek-plugin dev` reads this (to
* widen its egress guard); the mock ctx makes no network calls of its own. It lives
@@ -1580,7 +1582,8 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost {
return impl[fn](...args, name === 'notificationChannel' ? userlessCtx : ctx) as never;
},
action: async (key) => {
if (opts.declaredActions && !opts.declaredActions.includes(key)) {
const declared = opts.declaredActions?.map((a) => (typeof a === 'string' ? a : a.key));
if (declared && !declared.includes(key)) {
throw new Error(`RESOURCE_FORBIDDEN: plugin did not declare action "${key}"`);
}
const fn = def.actions?.[key];
+2 -1
View File
@@ -9,7 +9,7 @@ describe('validateManifest normalized output', () => {
homepage: 'https://example.com', tags: ['fun'], license: 'MIT',
capabilities: { widget: { title: 'W', slot: 'hero' }, provides: ['getState'] },
settings: [{ key: 'mode', scope: 'user', options: ['a', 'b'] }],
actions: [{ key: 'ping', label: 'Ping' }],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
};
const r = validateManifest(input);
expect(r.ok).toBe(true);
@@ -17,6 +17,7 @@ describe('validateManifest normalized output', () => {
expect(m.capabilities?.widget?.slot).toBe('hero');
expect(m.settings?.[0].options).toEqual(['a', 'b']);
expect(m.actions?.[0]).toEqual({ key: 'ping', label: 'Ping' });
expect(m.actions?.[1]).toEqual({ key: 'purge', label: 'Purge', scope: 'instance', danger: true });
expect(m.icon).toBe('Bell');
expect(m.operatorEgress).toBe(false);
expect(m.nativeModules).toBe(false); // still forced
+31
View File
@@ -1091,6 +1091,37 @@ describe('mock-host inter-plugin (plugins.call + events.emit)', () => {
await expect(d.action('sync')).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('accepts scoped declaredActions entries and drives an instance action with the acting-user ctx', async () => {
const def = definePlugin({
actions: {
purge: async (ctx) => ({ ok: true, message: `url=${String(ctx.config.url)} user=${await ctx.settings.get('token')}` }),
sync: () => ({ ok: true }),
},
});
const d = createMockHost({
actingUserId: 7,
config: { url: 'https://x.example' },
userSettings: { token: 'abc' },
declaredActions: ['sync', { key: 'purge', scope: 'instance' }],
}).run(def);
// The clicking admin is the acting user: ctx.config AND their own settings resolve.
expect(await d.action('purge')).toEqual({ ok: true, message: 'url=https://x.example user=abc' });
expect(await d.action('sync')).toEqual({ ok: true, message: undefined });
await expect(d.action('other')).rejects.toThrow(/RESOURCE_FORBIDDEN/);
});
it('validates the scope of a settings action', () => {
const base = { id: 'plugin', name: 'P', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' };
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'instance' }] }).ok).toBe(true);
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'user' }] }).ok).toBe(true);
expect(validateManifest({ ...base, actions: [{ key: 'a' }] }).ok).toBe(true); // scope optional
const bad = validateManifest({ ...base, actions: [{ key: 'a', scope: 'global' }] });
expect(bad.ok).toBe(false);
expect(bad.errors).toContain('action "a".scope must be "user" or "instance"');
// Keys stay unique ACROSS scopes — one action, one form.
expect(validateManifest({ ...base, actions: [{ key: 'a', scope: 'user' }, { key: 'a', scope: 'instance' }] }).ok).toBe(false);
});
it('never resolves a settings key off Object.prototype', async () => {
// The bug this models: `__proto__`/`constructor` used to resolve to a truthy object,
// reporting a REQUIRED field as configured for a user who had configured nothing.