mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user