mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +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:
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user