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