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
+33
View File
@@ -12,6 +12,8 @@ import {
pluginEgressHostsRequestSchema,
pluginUserSettingsUpdateRequestSchema,
pluginRouteRequestSchema,
pluginActionDescriptorSchema,
pluginActionResultSchema,
} from './plugins.schema';
import { describe, expect, it } from 'vitest';
@@ -70,6 +72,7 @@ describe('instance-config wire shapes', () => {
},
],
config: { apiUrl: 'https://x.example', apiKey: '••••••••' },
actions: [],
};
expect(pluginInstanceConfigResponseSchema.parse(res).fields).toHaveLength(1);
});
@@ -139,3 +142,33 @@ describe('admin body contracts stay loose by doctrine', () => {
expect(pluginRouteRequestSchema.safeParse({ anything: [1, 2, 3] }).success).toBe(true);
});
});
describe('plugin action contracts', () => {
it('SHARED-PLUG-ACT-001: a descriptor carries key/label/danger/scope and an optional hint', () => {
expect(
pluginActionDescriptorSchema.parse({ key: 'purge', label: 'Purge cache', danger: true, scope: 'instance' }),
).toEqual({ key: 'purge', label: 'Purge cache', danger: true, scope: 'instance' });
expect(pluginActionDescriptorSchema.parse({ key: 'a', label: 'A', hint: 'h', danger: false, scope: 'user' }).hint).toBe('h');
});
it('SHARED-PLUG-ACT-002: refuses a scope outside user|instance and a missing scope', () => {
expect(pluginActionDescriptorSchema.safeParse({ key: 'a', label: 'A', danger: false, scope: 'global' }).success).toBe(false);
expect(pluginActionDescriptorSchema.safeParse({ key: 'a', label: 'A', danger: false }).success).toBe(false);
});
it('SHARED-PLUG-ACT-003: a result is ok plus an optional message', () => {
expect(pluginActionResultSchema.parse({ ok: true })).toEqual({ ok: true });
expect(pluginActionResultSchema.parse({ ok: false, message: 'nope' })).toEqual({ ok: false, message: 'nope' });
expect(pluginActionResultSchema.safeParse({ message: 'x' }).success).toBe(false);
});
it('SHARED-PLUG-ACT-004: the instance config response now lists instance actions', () => {
const r = pluginInstanceConfigResponseSchema.parse({
fields: [],
config: {},
actions: [{ key: 'ping', label: 'Ping', danger: false, scope: 'instance' }],
});
expect(r.actions).toHaveLength(1);
expect(pluginInstanceConfigResponseSchema.safeParse({ fields: [], config: {} }).success).toBe(false);
});
});
+27 -1
View File
@@ -31,11 +31,37 @@ export const pluginSettingsFieldSchema = z.object({
});
export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>;
/**
* A settings-form action button a plugin declared in its manifest (`actions`). Which
* form renders it is the `scope`, exactly like a settings field: `'user'` on the user
* Settings tab (runs as the clicking user), `'instance'` in the admin instance-settings
* dialog (runs as the clicking admin). One action lives in exactly one form.
*/
export const pluginActionScopeSchema = z.enum(['user', 'instance']);
export type PluginActionScope = z.infer<typeof pluginActionScopeSchema>;
export const pluginActionDescriptorSchema = z.object({
key: z.string(),
label: z.string(),
hint: z.string().optional(),
danger: z.boolean(),
scope: pluginActionScopeSchema,
});
export type PluginActionDescriptor = z.infer<typeof pluginActionDescriptorSchema>;
/** What an action answers, after the host bounded the plugin-supplied message. */
export const pluginActionResultSchema = z.object({
ok: z.boolean(),
message: z.string().optional(),
});
export type PluginActionResult = z.infer<typeof pluginActionResultSchema>;
/** GET /api/admin/plugins/:id/config — the declared `scope:'instance'` fields
* plus the stored values, secrets masked. */
* plus the stored values, secrets masked, plus the `scope:'instance'` actions. */
export const pluginInstanceConfigResponseSchema = z.object({
fields: z.array(pluginSettingsFieldSchema),
config: z.record(z.string(), z.unknown()),
actions: z.array(pluginActionDescriptorSchema),
});
export type PluginInstanceConfigResponse = z.infer<typeof pluginInstanceConfigResponseSchema>;