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
+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.