mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26: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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user