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
+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];