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
+10
View File
@@ -4226,6 +4226,16 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE plugin_settings_fields ADD COLUMN default_value TEXT');
}
},
// Settings-form actions gain a scope (#plugins): 'user' renders on the user Settings
// tab, 'instance' in the admin instance-settings dialog. Existing rows predate the
// column and were all user-tab buttons, so the default keeps them where they were.
() => {
const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>;
if (cols.length > 0 && !cols.some((c) => c.name === 'scope')) {
db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'");
}
},
];
if (currentVersion < migrations.length) {
+2 -2
View File
@@ -81,9 +81,9 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
// Refresh the settings-page action descriptors from the manifest.
db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id);
const insertAction = db.prepare(
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
);
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, i));
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, a.scope, i));
// Refresh the settings-field descriptors from the manifest.
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
+16 -2
View File
@@ -69,6 +69,11 @@ export interface ManifestAction {
hint?: string;
/** Render as destructive and ask for confirmation before running. */
danger?: boolean;
/** Which settings form renders the button. `'user'` (default): the user Settings tab,
* run as the clicking user. `'instance'`: the admin instance-settings dialog, run as
* the clicking admin. The default is `'user'` (not `'instance'` like settings fields)
* so every manifest written before scope existed keeps its user-tab buttons. */
scope: 'user' | 'instance';
}
/** A routing profile a routeProvider plugin offers — one entry in the planner's
@@ -611,15 +616,24 @@ function parseActions(raw: unknown): ManifestAction[] {
const out: ManifestAction[] = [];
for (const a of raw) {
if (!a || typeof a !== 'object') throw new ManifestError('each action must be an object');
const { key, label, hint, danger } = a as Record<string, unknown>;
const { key, label, hint, danger, scope } = a as Record<string, unknown>;
const k = assertSettingKey(String(key ?? ''));
if (!k) throw new ManifestError('action must have a "key"');
if (out.some((x) => x.key === k)) throw new ManifestError(`duplicate action "${k}"`);
if (scope !== undefined && scope !== 'user' && scope !== 'instance') {
throw new ManifestError(`action "${k}".scope must be "user" or "instance"`);
}
// `label ?? k` only defaults on null/undefined — an explicit `label: ''` would pass
// straight through and reach the client as an empty button/confirm label (which
// MConfirmSheet then refuses to render a confirm button for). Fall back to the key
// whenever the label is empty after trimming.
const rawLabel = label == null ? '' : String(label);
out.push({
key: k,
label: String(label ?? k).slice(0, 60),
label: (rawLabel.trim() === '' ? k : rawLabel).slice(0, 60),
hint: hint === undefined ? undefined : String(hint).slice(0, 200),
danger: danger === true,
scope: scope === 'instance' ? 'instance' : 'user',
});
}
return out;
@@ -35,6 +35,7 @@ import type { VersionMismatch, PluginDepRow } from './dependencies';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND, PLUGIN_API_VERSION } from './protocol/envelope';
import type { PluginActionDescriptor, PluginActionResult, PluginActionScope } from '@trek/shared';
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
@@ -1124,27 +1125,29 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
});
}
/** The settings-page action buttons a plugin declared (descriptors, from the DB). */
actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> {
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
actionsOf(id: string, scope: PluginActionScope): PluginActionDescriptor[] {
try {
return (
this.db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{
action_key: string; label: string; hint: string | null; danger: number;
}>
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 }));
this.db
.prepare('SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order')
.all(id, scope) as Array<{ action_key: string; label: string; hint: string | null; danger: number; scope: PluginActionScope }>
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1, scope: r.scope }));
} catch {
return []; // table absent (a slimmed test app)
}
}
/**
* Run a settings-page action for the user who clicked it. The acting user is bound
* host-side (never named by the plugin), so the action reads THAT user's settings and
* any trip read it makes is membership-checked against them.
* Run a settings-form action for the person who clicked it. The acting user is bound
* host-side (never named by the plugin): a user for a `scope:'user'` button, an admin
* for a `scope:'instance'` one. Either way the action reads THAT person's settings and
* any trip read it makes is membership-checked against them. The scope is the
* CALLER's route, so a user route can never fire an admin button, nor the reverse.
*/
async invokeAction(id: string, key: string, actingUserId: number): Promise<{ ok: boolean; message?: string }> {
if (!this.actionsOf(id).some((a) => a.key === key)) {
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}"`);
async invokeAction(id: string, key: string, actingUserId: number, scope: PluginActionScope): Promise<PluginActionResult> {
if (!this.actionsOf(id, scope).some((a) => a.key === key)) {
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}" in scope ${scope}`);
}
const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200);
try {
@@ -6,6 +6,7 @@ import { PluginsService, MissingRequiredSettingError } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service';
import { DatabaseService } from '../database/database.service';
import { PluginUserSettingsUpdateDto } from './plugins.dto';
import type { PluginActionDescriptor, PluginActionResult } from '@trek/shared';
/**
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
@@ -36,14 +37,14 @@ export class PluginUserSettingsController {
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
fields: unknown[];
config: Record<string, unknown>;
actions: Array<{ key: string; label: string; hint?: string; danger: boolean }>;
actions: PluginActionDescriptor[];
} {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
return {
fields: this.plugins.userSettingsFields(id),
config: this.plugins.getUserConfig(id, userId),
actions: this.runtime.actionsOf(id),
actions: this.runtime.actionsOf(id, 'user'),
};
}
@@ -59,13 +60,13 @@ export class PluginUserSettingsController {
@Param('id') id: string,
@Param('key') key: string,
@Req() req: Request & { user?: { id: number } },
): Promise<{ ok: boolean; message?: string }> {
): Promise<PluginActionResult> {
const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
return await this.runtime.invokeAction(id, key, userId);
return await this.runtime.invokeAction(id, key, userId, 'user');
} catch (e) {
// A failing action is a RESULT, not a server error — show the user why.
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
+41 -2
View File
@@ -13,7 +13,7 @@ import { pluginsEnabled } from './kill-switch';
import { devLinkEnabled } from './dev-link';
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
import type { PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
import type { PluginActionResult, PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
import { RuntimeEnvService } from '../app-config/runtime-env.service';
// Straight from sessionManager, not the src/mcp barrel: that one evaluates
// readEnv().mcp at module scope and installs the sweep interval, which a domain
@@ -141,7 +141,11 @@ export class PluginsController {
@Get(':id/config')
getConfig(@Param('id') id: string): PluginInstanceConfigResponse {
return { fields: this.plugins.instanceSettingsFields(id), config: this.plugins.getInstanceConfig(id) };
return {
fields: this.plugins.instanceSettingsFields(id),
config: this.plugins.getInstanceConfig(id),
actions: this.runtime.actionsOf(id, 'instance'),
};
}
/**
@@ -181,6 +185,41 @@ export class PluginsController {
}
}
/**
* Run one of the plugin's `scope:'instance'` actions ("Purge cache", "Test SMTP").
* ADMIN-INITIATED: the acting user is the clicking admin, bound host-side, so the
* handler sees `ctx.config` plus the admin's own settings and any trip read is checked
* against them. The scope is fixed by this route — a user-tab key is refused here.
*
* Returns the SAME 404 `{ error: 'Plugin is not active' }` body as
* PluginUserSettingsController.runAction, and a failing action is a RESULT, not a
* server error, on both — but the two routes reach 404 by checking DIFFERENT things.
* This route asks `runtime.isActive(id)`, the supervisor's live-process check: is
* there actually a forked child to run the action in. The user route instead reads
* the plugin's DB `status` column. They can disagree — a plugin can be `status =
* 'active'` in the DB with no live child (e.g. it crashed and hasn't been
* respawned yet) — so a 404 here does not always mean the row says inactive; it can
* also mean a stale-active row with nothing behind it. The client (`useInstanceSettings`)
* treats this 404 as authoritative and flips its own `active` flag to match.
*/
@Post(':id/actions/:key')
@HttpCode(200)
async runAction(
@Param('id') id: string,
@Param('key') key: string,
@Req() req: Request & { user?: { id: number } },
): Promise<PluginActionResult> {
const adminId = req.user?.id;
if (!pluginsEnabled() || adminId == null || !this.runtime.isActive(id)) {
throw new HttpException({ error: 'Plugin is not active' }, 404);
}
try {
return await this.runtime.invokeAction(id, key, adminId, 'instance');
} catch (e) {
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
}
}
/**
* Operator-supplied egress hosts. A plugin that talks to a SELF-HOSTED service can't
* name the operator's hostname in its manifest, so an admin adds it here — and the
@@ -79,6 +79,9 @@ export interface PluginListItem {
/** How many `scope:'instance'` settings fields the plugin declares — gates the admin
* settings menu item without a per-plugin fetch. */
instanceSettingsCount: number;
/** How many `scope:'instance'` actions the plugin declares — gates the admin settings
* menu item together with instanceSettingsCount (a plugin can have actions and no fields). */
instanceActionsCount: number;
/** Declared dependencies (parsed) — required addons + plugin deps. */
dependencies: PluginDependencies;
/** Whether this plugin can currently activate, and why not if it can't. */
@@ -136,6 +139,16 @@ export class PluginsService {
}
}
private instanceActionsCount(id: string): number {
try {
return (
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
).n;
} catch {
return 0; // table absent (a slimmed test app)
}
}
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
const rows = this.db
.prepare(
@@ -178,6 +191,7 @@ export class PluginsService {
operatorEgress: _oe === 1,
egressHostCount: this.egressHostCount(r.id),
instanceSettingsCount: this.instanceSettingsCount(r.id),
instanceActionsCount: this.instanceActionsCount(r.id),
dependencies: deps,
dependencyStatus,
trekRange: trek_range,
@@ -705,13 +705,15 @@ export interface PluginDefinition {
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
/**
* 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>;
events?: PluginEventSubscription[];
+14 -1
View File
@@ -30,7 +30,7 @@ beforeEach(() => {
api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT);
CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT,
required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER);
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER,
CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, scope TEXT NOT NULL DEFAULT 'user', sort_order INTEGER,
PRIMARY KEY (plugin_id, action_key));
CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
});
@@ -64,6 +64,19 @@ describe('discoverPlugins', () => {
expect(field).toMatchObject({ field_key: 'api_key', secret: 1 });
});
it('persists each action with its scope', () => {
writePlugin('acts', {
name: 'Acts', type: 'integration', permissions: [],
actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }],
});
discoverPlugins(db);
const rows = db.prepare("SELECT action_key, scope, danger FROM plugin_actions WHERE plugin_id='acts' ORDER BY sort_order").all();
expect(rows).toEqual([
{ action_key: 'ping', scope: 'user', danger: 0 },
{ action_key: 'purge', scope: 'instance', danger: 1 },
]);
});
it('keeps an existing plugin status + granted permissions on re-discovery', () => {
db.prepare("INSERT INTO plugins (id, name, type, status, granted_permissions) VALUES ('keep','Keep','page','active','[\"db:own\"]')").run();
writePlugin('keep', { name: 'Keep v2', type: 'page', version: '2.0.0' });
+17 -2
View File
@@ -330,11 +330,21 @@ describe('settings-page actions', () => {
it('parses actions with label/hint/danger', () => {
const m = parseManifest({ ...base, actions: [{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.' }, { key: 'purge', label: 'Purge', danger: true }] });
expect(m.actions).toEqual([
{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false },
{ key: 'purge', label: 'Purge', hint: undefined, danger: true },
{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false, scope: 'user' },
{ key: 'purge', label: 'Purge', hint: undefined, danger: true, scope: 'user' },
]);
});
it('defaults an action to the user scope, accepts instance, refuses anything else', () => {
const m = parseManifest({ ...base, actions: [{ key: 'ping' }, { key: 'purge', scope: 'instance' }, { key: 'me', scope: 'user' }] });
expect(m.actions.map((a) => a.scope)).toEqual(['user', 'instance', 'user']);
expect(() => parseManifest({ ...base, actions: [{ key: 'x', scope: 'global' }] })).toThrow(/scope must be "user" or "instance"/);
// one key, one form — a duplicate across scopes is still a duplicate
expect(() => parseManifest({ ...base, actions: [{ key: 'a', scope: 'user' }, { key: 'a', scope: 'instance' }] })).toThrow(/duplicate action/);
// the cap counts both scopes
expect(() => parseManifest({ ...base, actions: Array.from({ length: 9 }, (_, i) => ({ key: `a${i}`, scope: i % 2 ? 'instance' : 'user' })) })).toThrow(/at most 8/);
});
it('defaults the label to the key, and bounds label/hint', () => {
const m = parseManifest({ ...base, actions: [{ key: 'sync', label: 'L'.repeat(200), hint: 'H'.repeat(500) }] });
expect(m.actions[0].label.length).toBe(60);
@@ -342,6 +352,11 @@ describe('settings-page actions', () => {
expect(parseManifest({ ...base, actions: [{ key: 'sync' }] }).actions[0].label).toBe('sync');
});
it('falls back to the key when label is an empty (or whitespace-only) string, not just null/undefined', () => {
expect(parseManifest({ ...base, actions: [{ key: 'sync', label: '' }] }).actions[0].label).toBe('sync');
expect(parseManifest({ ...base, actions: [{ key: 'sync', label: ' ' }] }).actions[0].label).toBe('sync');
});
it('rejects a prototype-chain key, a duplicate, a non-array and too many', () => {
expect(() => parseManifest({ ...base, actions: [{ key: '__proto__' }] })).toThrow(ManifestError);
expect(() => parseManifest({ ...base, actions: [{ key: 'a' }, { key: 'a' }] })).toThrow(/duplicate action/);
@@ -127,15 +127,17 @@ describe('operator-supplied egress hosts', () => {
});
describe('settings-page actions (runtime)', () => {
function declareAction(id: string, key: string) {
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
.run(id, key, key);
function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') {
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
.run(id, key, key, scope);
}
it('ACT-001 — actionsOf returns the declared descriptors', () => {
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', () => {
install('p', false);
declareAction('p', 'testConnection');
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
declareAction('p', 'purge', 'instance');
expect(rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
expect(rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
@@ -143,13 +145,21 @@ describe('settings-page actions (runtime)', () => {
declareAction('p', 'testConnection');
// The key is caller-supplied (it comes off the URL), so the host must check it
// against the manifest rather than forwarding whatever it is handed to the child.
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', 'somethingElse', 1, 'user')).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', '__proto__', 1, 'user')).rejects.toThrow(/did not declare action/);
});
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
install('p', false);
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
await expect(rt.invokeAction('p', 'testConnection', 1, 'user')).rejects.toThrow(/did not declare action/);
});
it('ACT-004 — a key declared in the OTHER scope is refused (the user route cannot fire an admin button)', async () => {
install('p', false);
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
await expect(rt.invokeAction('p', 'purge', 1, 'user')).rejects.toThrow(/did not declare action "purge" in scope user/);
await expect(rt.invokeAction('p', 'testConnection', 1, 'instance')).rejects.toThrow(/did not declare action "testConnection" in scope instance/);
});
});
@@ -11,6 +11,7 @@ import { HttpException } from '@nestjs/common';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import type { Request } from 'express';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
@@ -120,6 +121,22 @@ describe('instance settings fields', () => {
expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 });
});
it('INS-004 — the admin list carries the instance-action count (gates the menu item even with no settings fields)', () => {
install('with-action');
install('plain');
testDb
.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
.run('with-action', 'purge', 'Purge', 'instance');
testDb
.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
.run('with-action', 'notify', 'Notify', 'user'); // user-scope actions must not count
const plugins = svc().list().plugins;
expect(plugins.find((p) => p.id === 'with-action')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 1 });
expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 0 });
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run();
});
it('INS-010 — persists a settings-field default and serves it on the fields list', () => {
installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
const fields = svc().instanceSettingsFields('fixture-id');
@@ -227,7 +244,7 @@ describe('admin config endpoints (controller)', () => {
declareField('p', 'apiUrl', 'instance');
testDb.prepare("UPDATE plugins SET config = '{\"apiUrl\":\"https://x.example\"}' WHERE id = 'p'").run();
const out = controllerWith({}).getConfig('p');
const out = controllerWith({ actionsOf: () => [] }).getConfig('p');
expect(out.config).toEqual({ apiUrl: 'https://x.example' });
expect(out.fields.map((f: Record<string, unknown>) => f.key)).toEqual(['apiUrl']);
});
@@ -328,3 +345,63 @@ describe('defaults reach the child at spawn', () => {
expect(config).toEqual({ api_url: 'https://mine.example', retries: 3 }); // note: no default, no value → absent
});
});
describe('plugin_actions.scope migration', () => {
it('MIG-ACT-001 — the column exists on a migrated DB and defaults to user', () => {
const cols = testDb.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>;
expect(cols.some((c) => c.name === 'scope')).toBe(true);
testDb.prepare("INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES ('m', 'k', 'K', NULL, 0, 0)").run();
expect((testDb.prepare("SELECT scope FROM plugin_actions WHERE plugin_id='m'").get() as { scope: string }).scope).toBe('user');
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id='m'").run();
});
});
describe('instance-scope actions (admin)', () => {
function declareAction(pluginId: string, key: string, scope: 'user' | 'instance') {
testDb.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
.run(pluginId, key, key, scope);
}
const adminReq = { user: { id: 42 } } as unknown as Request;
function controller(invoke = vi.fn(async () => ({ ok: true, message: 'pong' }))) {
const rt = createPluginRuntime(new DatabaseService(dbConn));
// isActive normally reflects the supervisor's live child map, which nothing here
// spawns — so it's stubbed to read the same DB status the test itself flips,
// mirroring what an actually-activated plugin would report.
const isActive = (id: string) => !!testDb.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
const runtime = Object.assign(rt, { invokeAction: invoke, isActive }) as unknown as PluginRuntimeService;
const c = new PluginsController(svc(), runtime, {} as unknown as PluginRegistryService, { isManaged: () => false } as unknown as RuntimeEnvService);
return { c, invoke };
}
beforeEach(() => { testDb.prepare('DELETE FROM plugin_actions').run(); });
it('ACT-ADM-001 — GET config lists the instance actions and none of the user ones', () => {
install('p');
declareAction('p', 'purge', 'instance');
declareAction('p', 'testConnection', 'user');
const { c } = controller();
expect(c.getConfig('p').actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
});
it('ACT-ADM-002 — POST runs the action as the clicking admin in the instance scope', async () => {
install('p');
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
const { c, invoke } = controller();
expect(await c.runAction('p', 'purge', adminReq)).toEqual({ ok: true, message: 'pong' });
expect(invoke).toHaveBeenCalledWith('p', 'purge', 42, 'instance');
});
it('ACT-ADM-003 — an inactive plugin answers 404 like the user route', async () => {
install('p');
const { c, invoke } = controller();
await expect(c.runAction('p', 'purge', adminReq)).rejects.toMatchObject({ status: 404, response: { error: 'Plugin is not active' } });
expect(invoke).not.toHaveBeenCalled();
});
it('ACT-ADM-004 — a refused key is a failed RESULT, not a server error', async () => {
install('p');
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
const { c } = controller(vi.fn(async () => { throw new Error('plugin p did not declare action "x" in scope instance'); }));
expect(await c.runAction('p', 'x', adminReq)).toEqual({ ok: false, message: 'plugin p did not declare action "x" in scope instance' });
});
});
@@ -27,7 +27,7 @@ function ctrl() {
updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
} as unknown as PluginsService;
// The controller now also takes the runtime (for settings-page actions).
const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService;
const runtime = { actionsOf: vi.fn(() => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
}
@@ -55,4 +55,12 @@ describe('PluginUserSettingsController', () => {
getMock.mockReturnValue(undefined as never);
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
});
it('GET lists USER-scope actions only, and POST runs one as the caller in the user scope', async () => {
const { c, runtime } = ctrl();
c.get('p', req(5));
expect(runtime.actionsOf).toHaveBeenCalledWith('p', 'user');
expect(await c.runAction('p', 'sync', req(5))).toEqual({ ok: true });
expect(runtime.invokeAction).toHaveBeenCalledWith('p', 'sync', 5, 'user');
});
});
@@ -236,9 +236,9 @@ describe('PluginsController M2 endpoints', () => {
});
it('get/update config delegate to the service (get carries the form fields, update the restart)', async () => {
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false) } as never;
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(() => []) } as never;
const c = new PluginsController(svc, rt, {} as never, envStub);
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 } });
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false });
});