From b82962f95bcbe903b5eec7af322cce03c8d082be Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:06:03 +0200 Subject: [PATCH] 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 --- client/src/api/client.surface.test.ts | 1 + client/src/api/client.ts | 15 +- .../Admin/AdminPluginsPanel.test.tsx | 80 ++++++++- .../components/Admin/AdminPluginsPanel.tsx | 66 +++++++- .../Admin/useInstanceSettings.test.ts | 152 ++++++++++++++++++ .../components/Admin/useInstanceSettings.ts | 118 +++++++++++--- .../Settings/PluginSettingsTab.test.tsx | 10 +- .../screens/admin/MAdminPluginsPanel.tsx | 66 +++++++- .../mobile/admin/MAdminPluginsPanel.test.tsx | 56 +++++++ .../mobile/settings/MSettingsPlugins.test.tsx | 6 +- plugin-sdk/README.md | 26 +++ plugin-sdk/src/index.ts | 14 +- plugin-sdk/src/manifest.ts | 7 + plugin-sdk/src/mock-host.ts | 9 +- plugin-sdk/test/manifest-roundtrip.test.ts | 3 +- plugin-sdk/test/sdk.test.ts | 31 ++++ server/src/db/migrations.ts | 10 ++ server/src/nest/plugins/install/discovery.ts | 4 +- server/src/nest/plugins/install/manifest.ts | 18 ++- .../nest/plugins/plugin-runtime.service.ts | 27 ++-- .../plugin-user-settings.controller.ts | 9 +- server/src/nest/plugins/plugins.controller.ts | 43 ++++- server/src/nest/plugins/plugins.service.ts | 14 ++ server/src/nest/plugins/runtime/plugin-sdk.ts | 14 +- server/tests/unit/plugins/discovery.test.ts | 15 +- server/tests/unit/plugins/manifest.test.ts | 19 ++- .../unit/plugins/operator-egress.test.ts | 26 ++- .../plugins/plugin-instance-settings.test.ts | 79 ++++++++- .../plugin-user-settings.controller.test.ts | 10 +- .../unit/plugins/plugins-service.test.ts | 4 +- shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh/admin.ts | 3 + shared/src/plugins/plugins.schema.spec.ts | 33 ++++ shared/src/plugins/plugins.schema.ts | 28 +++- wiki/Plugin-Cookbook.md | 19 ++- wiki/Plugin-Development.md | 71 ++++++-- wiki/Plugin-Permissions.md | 14 +- wiki/Plugins.md | 75 +++++++-- 59 files changed, 1133 insertions(+), 128 deletions(-) create mode 100644 client/src/components/Admin/useInstanceSettings.test.ts diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index 8a238a629..be189f2e1 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -303,6 +303,7 @@ describe('client > endpoint wiring', () => { { n: 'pluginReload', r: () => adminApi.pluginReload('koffi'), e: 'POST /api/admin/plugins/koffi/reload' }, { n: 'pluginConfig', r: () => adminApi.pluginConfig('koffi'), e: 'GET /api/admin/plugins/koffi/config' }, { n: 'pluginSaveConfig', r: () => adminApi.pluginSaveConfig('koffi', { apiUrl: 'x' }), e: 'PUT /api/admin/plugins/koffi/config' }, + { n: 'runPluginAction', r: () => adminApi.runPluginAction('koffi', 'purge cache'), e: 'POST /api/admin/plugins/koffi/actions/purge%20cache' }, { n: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 9502fda6c..652003728 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -56,6 +56,8 @@ import { type PluginSettingsField, type PluginInstanceConfigResponse, type PluginInstanceConfigUpdated, + type PluginActionDescriptor, + type PluginActionResult, type PluginInstallRequest, } from '@trek/shared' import { getSocketId } from './websocket' @@ -569,6 +571,10 @@ export const adminApi = { apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data), pluginSaveConfig: (id: string, config: Record): Promise => apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data), + // Run a `scope:'instance'` action the plugin declared ("Purge cache"). Runs AS the + // clicking admin. 404 when the plugin is inactive (no child process to run it). + runPluginAction: (id: string, key: string): Promise => + apiClient.post(`/admin/plugins/${id}/actions/${encodeURIComponent(key)}`).then(r => r.data), // Operator-supplied egress hosts: a plugin talking to a SELF-HOSTED service can't name // the operator's hostname in its manifest, so the admin adds it here. Saving re-spawns // the plugin with the widened allow-list. @@ -799,10 +805,9 @@ export interface PluginAtlasLayer { * shape) — aliased so existing per-user settings consumers keep their import path. */ export type PluginUserSettingField = PluginSettingsField -/** A button a plugin contributes to its own settings page ("Test connection"). */ -export interface PluginAction { - key: string; label: string; hint?: string; danger: boolean -} +/** A button a plugin contributes to a settings form ("Test connection", "Purge cache"). + * `scope` says which form: the user tab or the admin instance-settings dialog. */ +export type PluginAction = PluginActionDescriptor export const pluginsApi = { // Active plugins the client renders (page nav entries, dashboard widgets). @@ -873,7 +878,7 @@ export const pluginsApi = { // caller, so it reads the caller's own settings. runAction: (id: string, key: string) => apiClient.post(`/plugin-settings/${id}/actions/${encodeURIComponent(key)}`) - .then(r => r.data as { ok: boolean; message?: string }), + .then(r => r.data as PluginActionResult), saveUserSettings: (id: string, config: Record) => apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record }), // Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers). diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 89a870b57..41ce1c610 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -2223,11 +2223,22 @@ describe('AdminPluginsPanel — instance settings', () => { }) it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => { - await openRowMenu(plugin({ instanceSettingsCount: 0 })) + await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 0 })) expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open expect(screen.queryByText('Instance settings')).not.toBeInTheDocument() }) + it('FE-COMP-PLUGINS-CFG-008: a plugin with instance ACTIONS but no fields still offers the menu item', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: [], config: {}, actions: [{ key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }] })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 0, instanceActionsCount: 1 })) + expect(screen.getByText('Instance settings')).toBeInTheDocument() + fireEvent.click(screen.getByText('Instance settings')) + expect(await screen.findByRole('button', { name: 'Ping server' }, { timeout: 5000 })).toBeInTheDocument() + }) + it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => { let body: Record | null = null server.use( @@ -2293,6 +2304,73 @@ describe('AdminPluginsPanel — instance settings', () => { expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' }) }) + const ACTIONS = [ + { key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }, + { key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' }, + ] + + async function openWithActions(p: Record = plugin({ instanceSettingsCount: 2 })) { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })), + ) + await openRowMenu(p) + fireEvent.click(screen.getByText('Instance settings')) + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }) + } + + it('FE-COMP-PLUGINS-ACT-010: instance actions render below the fields and run against the admin route', async () => { + let posted = '' + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key) + return HttpResponse.json({ ok: true, message: 'pong' }) + }), + ) + await openWithActions() + expect(screen.getByText('Drops every cached tile')).toBeInTheDocument() // hint + fireEvent.click(screen.getByRole('button', { name: 'Ping server' })) + expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument() + expect(posted).toBe('ping') + // The dialog stays open — the admin may run another one. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-ACT-011: a danger action asks first; confirming runs it', async () => { + let posted = '' + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key) + return HttpResponse.json({ ok: false, message: 'cache locked' }) + }), + ) + await openWithActions() + fireEvent.click(screen.getByRole('button', { name: 'Purge cache' })) + expect(await screen.findByText('Run this action?')).toBeInTheDocument() + expect(posted).toBe('') + fireEvent.click(screen.getByRole('button', { name: /^confirm$/i })) + expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument() + expect(posted).toBe('purge') + }) + + it('FE-COMP-PLUGINS-ACT-012: an inactive plugin shows the buttons disabled with the activation hint', async () => { + await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 })) + expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled() + expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-ACT-013: a dirty form is saved before the action runs', async () => { + const calls: string[] = [] + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => { calls.push('save'); return HttpResponse.json({ config: {}, restarted: false }) }), + http.post('*/api/admin/plugins/trek-gotify/actions/:key', () => { calls.push('run'); return HttpResponse.json({ ok: true }) }), + ) + await openWithActions() + fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } }) + fireEvent.click(screen.getByRole('button', { name: 'Ping server' })) + await waitFor(() => expect(calls).toEqual(['save', 'run']), { timeout: 5000 }) + }) + it('FE-COMP-PLUGINS-CFG-006: a failed config fetch is a toast, not a broken dialog', async () => { server.use( http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index 7a7488a79..16d39a0cf 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -7,6 +7,7 @@ import { SlidersHorizontal, ArrowUpDown, CircleDot, MoreHorizontal, RotateCw, ArrowRight, Database, Users, LayoutDashboard, Radio, Luggage, Globe, Image, CalendarDays, Bell, Info, History, PauseCircle, Wallet, Puzzle, MapPin, ListChecks, Pencil, Tag, FileText, Route, Navigation, Clock, LocateFixed, Palette, Bot, + Loader2, } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' @@ -53,6 +54,9 @@ interface PluginRow { /** How many `scope:'instance'` settings fields the plugin declares — gates the * "Instance settings" menu item without a per-plugin fetch. */ instanceSettingsCount?: number + /** How many `scope:'instance'` actions the plugin declares — a plugin with actions + * but no settings fields still needs the menu item to run them. */ + instanceActionsCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -579,9 +583,9 @@ export default function AdminPluginsPanel() { } } - const openInstanceSettings = (id: string) => { + const openInstanceSettings = (p: { id: string; status: string }) => { setMenu(null) - settings.open(id) + settings.open(p.id, p.status === 'active') } const openErrors = (id: string) => { @@ -973,7 +977,7 @@ export default function AdminPluginsPanel() { code: p.updateBlock!.code, detail: p.updateBlock!.detail, })} onErrors={() => openErrors(p.id)} onEgress={() => openEgress(p.id)} - onSettings={() => openInstanceSettings(p.id)} + onSettings={() => openInstanceSettings(p)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} @@ -1103,11 +1107,50 @@ export default function AdminPluginsPanel() { {f.hint && {f.hint}} ))} + {(() => { + const form = settings.form + if (!form || form.actions.length === 0) return null + return ( +
+ + {t('admin.plugins.actions')} + + {!form.active && ( +

{t('admin.plugins.actions.inactive')}

+ )} +
+ {form.actions.map(a => { + const res = settings.actionResult[a.key] + return ( +
+ + {a.hint && {a.hint}} + {res && ( + + {res.message || (res.ok ? t('common.success') : t('common.error'))} + + )} +
+ ) + })} +
+
+ ) + })()} {settings.error &&

{settings.error}

}
@@ -1116,6 +1159,15 @@ export default function AdminPluginsPanel() {
)} + + setConfirmUninstall(null)} @@ -1402,9 +1454,9 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} - {/* Only a plugin that DECLARES scope:'instance' fields gets the item — an - empty form would invite the admin to configure nothing. */} - {(p.instanceSettingsCount ?? 0) > 0 && ( + {/* A plugin gets the item if it declares scope:'instance' fields OR actions — + an action-only plugin still needs the dialog to run its buttons. */} + {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> diff --git a/client/src/components/Admin/useInstanceSettings.test.ts b/client/src/components/Admin/useInstanceSettings.test.ts new file mode 100644 index 000000000..230ce3daa --- /dev/null +++ b/client/src/components/Admin/useInstanceSettings.test.ts @@ -0,0 +1,152 @@ +// FE-COMP-PLUGINS-ACT-001 to -006 — the shared instance-settings hook, action side. +import { act, renderHook, waitFor } from '@testing-library/react' +import { adminApi } from '../../api/client' +import { useInstanceSettings } from './useInstanceSettings' + +vi.mock('../shared/Toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn() }) })) +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })) + +const FIELDS = [{ key: 'apiUrl', label: 'API URL', input_type: 'text', required: false, secret: false }] +const PURGE = { key: 'purge', label: 'Purge', danger: true, scope: 'instance' as const } +const PING = { key: 'ping', label: 'Ping', danger: false, scope: 'instance' as const } + +beforeEach(() => { + vi.spyOn(adminApi, 'pluginConfig').mockResolvedValue({ fields: FIELDS, config: { apiUrl: 'https://a.example' }, actions: [PING, PURGE] }) + vi.spyOn(adminApi, 'pluginSaveConfig').mockResolvedValue({ config: { apiUrl: 'https://b.example' }, restarted: false }) + vi.spyOn(adminApi, 'runPluginAction').mockResolvedValue({ ok: true, message: 'pong' }) +}) +afterEach(() => vi.restoreAllMocks()) + +async function opened(active = true) { + const hook = renderHook(() => useInstanceSettings()) + act(() => hook.result.current.open('p', active)) + await waitFor(() => expect(hook.result.current.form).not.toBeNull()) + return hook +} + +/** A promise this test controls the settling of, to pin down state mid-flight. */ +function deferred() { + let resolve!: (v: T) => void + const promise = new Promise(r => { resolve = r }) + return { promise, resolve } +} + +describe('useInstanceSettings — actions', () => { + it('FE-COMP-PLUGINS-ACT-001: open carries the instance actions, the active flag, and a clean form', async () => { + const { result } = await opened() + expect(result.current.form?.actions).toEqual([PING, PURGE]) + expect(result.current.form?.active).toBe(true) + expect(result.current.form?.dirty).toBe(false) + }) + + it('FE-COMP-PLUGINS-ACT-002: a clean form runs the action straight away and keeps the dialog open', async () => { + const { result } = await opened() + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })) + expect(adminApi.pluginSaveConfig).not.toHaveBeenCalled() + expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping') + expect(result.current.form).not.toBeNull() + expect(result.current.runningAction).toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-003: a dirty form saves first, then runs, and is clean afterwards', async () => { + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'https://b.example')) + expect(result.current.form?.dirty).toBe(true) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toBeDefined()) + expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://b.example' }) + expect(vi.mocked(adminApi.pluginSaveConfig).mock.invocationCallOrder[0]).toBeLessThan(vi.mocked(adminApi.runPluginAction).mock.invocationCallOrder[0]) + expect(result.current.form?.dirty).toBe(false) + expect(result.current.form).not.toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-004: a save failure surfaces in error and the action never runs', async () => { + vi.mocked(adminApi.pluginSaveConfig).mockRejectedValue({ response: { data: { error: 'nope' } } }) + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'x')) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.error).toBe('nope')) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + }) + + it('FE-COMP-PLUGINS-ACT-005: a danger action waits for confirmation; cancel drops it, confirm runs it', async () => { + const { result } = await opened() + act(() => result.current.runAction(PURGE)) + expect(result.current.pendingAction).toEqual(PURGE) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + act(() => result.current.cancelPendingAction()) + expect(result.current.pendingAction).toBeNull() + act(() => result.current.runAction(PURGE)) + act(() => result.current.confirmPendingAction()) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'purge')) + expect(result.current.pendingAction).toBeNull() + }) + + it('FE-COMP-PLUGINS-ACT-006: an inactive plugin never posts, and a failed result is stored as such', async () => { + const { result } = await opened(false) + act(() => result.current.runAction(PING)) + await new Promise(r => setTimeout(r, 0)) + expect(adminApi.runPluginAction).not.toHaveBeenCalled() + + vi.mocked(adminApi.runPluginAction).mockResolvedValue({ ok: false, message: 'credentials rejected' }) + const active = await opened(true) + act(() => active.result.current.runAction(PING)) + await waitFor(() => expect(active.result.current.actionResult.ping).toEqual({ ok: false, message: 'credentials rejected' })) + }) + + it('FE-COMP-PLUGINS-ACT-007: an edit made while the pre-action save is in flight is not clobbered when dirty clears', async () => { + const savePromise = deferred<{ config: Record; restarted: boolean }>() + vi.mocked(adminApi.pluginSaveConfig).mockReturnValue(savePromise.promise) + const { result } = await opened() + act(() => result.current.setValue('apiUrl', 'https://a-edit.example')) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.pluginSaveConfig).toHaveBeenCalledWith('p', { apiUrl: 'https://a-edit.example' })) + + // A concurrent edit lands while the save is still pending. + act(() => result.current.setValue('apiUrl', 'https://b-edit.example')) + + await act(async () => { + savePromise.resolve({ config: { apiUrl: 'https://a-edit.example' }, restarted: false }) + await savePromise.promise + }) + + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')) + expect(result.current.form?.dirty).toBe(true) + expect(result.current.form?.values.apiUrl).toBe('https://b-edit.example') + }) + + it('FE-COMP-PLUGINS-ACT-008: an in-flight action from a closed dialog does not leak into the next one', async () => { + const runPromise = deferred<{ ok: boolean; message?: string }>() + vi.mocked(adminApi.runPluginAction).mockReturnValueOnce(runPromise.promise) + const { result } = await opened() + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('p', 'ping')) + + act(() => result.current.close()) + act(() => result.current.open('q', true)) + await waitFor(() => expect(result.current.form?.id).toBe('q')) + expect(result.current.runningAction).toBeNull() + + act(() => result.current.runAction(PING)) + await waitFor(() => expect(adminApi.runPluginAction).toHaveBeenCalledWith('q', 'ping')) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' })) + + // The stale request from the closed dialog settles after the fact — it must not overwrite the new dialog's result. + await act(async () => { + runPromise.resolve({ ok: true, message: 'stale pong' }) + await runPromise.promise + }) + expect(result.current.actionResult.ping).toEqual({ ok: true, message: 'pong' }) + }) + + it('FE-COMP-PLUGINS-ACT-009: a 404 "Plugin is not active" from the server (DB active, no live child) shows the inactive message and flips active off', async () => { + vi.mocked(adminApi.runPluginAction).mockRejectedValue({ response: { status: 404, data: { error: 'Plugin is not active' } } }) + const { result } = await opened(true) + expect(result.current.form?.active).toBe(true) + act(() => result.current.runAction(PING)) + await waitFor(() => expect(result.current.actionResult.ping).toEqual({ ok: false, message: 'admin.plugins.actions.inactive' })) + expect(result.current.form?.active).toBe(false) + expect(result.current.runningAction).toBeNull() + }) +}) diff --git a/client/src/components/Admin/useInstanceSettings.ts b/client/src/components/Admin/useInstanceSettings.ts index 5ac6bdbb9..23db2e09b 100644 --- a/client/src/components/Admin/useInstanceSettings.ts +++ b/client/src/components/Admin/useInstanceSettings.ts @@ -1,24 +1,34 @@ -import { useState } from 'react' -import { adminApi, type PluginUserSettingField } from '../../api/client' +import { useRef, useState } from 'react' +import { adminApi, type PluginAction, type PluginUserSettingField } from '../../api/client' +import type { PluginActionResult } from '@trek/shared' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm' -/** The server's stand-in for a stored secret — a display artifact, never a value to send back. */ - export interface InstanceSettingsForm { id: string fields: PluginUserSettingField[] values: Record + /** The plugin's `scope:'instance'` action buttons. */ + actions: PluginAction[] + /** Whether the plugin is active: an action needs a running child to execute. */ + active: boolean + /** An edit since open/last save. An action saves a dirty form before it runs. */ + dirty: boolean } /** * The admin-owned `scope:'instance'` settings form — the ONE logic path behind * both admin shells (the desktop modal and the phone sheet render their own * markup over this state). Open fetches the declared fields plus the stored - * (masked) values; save skips an untouched secret mask so it never overwrites - * the stored ciphertext, and reports a restart when the server re-spawned a - * running plugin (the child reads its config once, at init). + * (masked) values and the instance-scope actions; save skips an untouched secret + * mask so it never overwrites the stored ciphertext, and reports a restart when + * the server re-spawned a running plugin (the child reads its config once, at init). + * + * Actions run AS the clicking admin. A dirty form is saved first (so the action + * sees the config the admin is looking at), the dialog stays open, and the result + * shows beside the button. A `danger` action parks in `pendingAction` until the + * shell's confirm UI calls `confirmPendingAction`. */ export function useInstanceSettings() { const { t } = useTranslation() @@ -26,40 +36,112 @@ export function useInstanceSettings() { const [form, setForm] = useState(null) const [saving, setSaving] = useState(false) const [error, setError] = useState('') + const [actionResult, setActionResult] = useState>({}) + const [runningAction, setRunningAction] = useState(null) + const [pendingAction, setPendingAction] = useState(null) + // Bumped on every open()/close() so an in-flight action from a closed/replaced + // dialog can tell it's stale and bail without touching the new dialog's state. + const openSeq = useRef(0) - const open = (id: string) => { + const open = (id: string, active: boolean) => { + openSeq.current++ setError('') + setActionResult({}) + setPendingAction(null) + setRunningAction(null) adminApi.pluginConfig(id) .then(d => { - setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config) }) + setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config), actions: d.actions ?? [], active, dirty: false }) }) .catch(() => toast.error(t('common.error'))) } const setValue = (key: string, value: string | boolean) => - setForm(s => s && ({ ...s, values: { ...s.values, [key]: value } })) + setForm(s => s && ({ ...s, values: { ...s.values, [key]: value }, dirty: true })) - const close = () => setForm(null) + const close = () => { openSeq.current++; setForm(null); setPendingAction(null); setRunningAction(null) } - const save = async () => { - if (!form) return - const missing = findMissingRequired(form.fields, form.values) + /** Persist the form. Returns false when the save was refused (error already shown). */ + const persist = async (f: InstanceSettingsForm): Promise => { + const missing = findMissingRequired(f.fields, f.values) if (missing) { setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key })) - return + return false } setSaving(true); setError('') try { - const d = await adminApi.pluginSaveConfig(form.id, settingsPatch(form.fields, form.values)) + const d = await adminApi.pluginSaveConfig(f.id, settingsPatch(f.fields, f.values)) toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) - setForm(null) + return true } catch (e) { const err = e as { response?: { data?: { error?: string } } } setError(err.response?.data?.error || t('common.error')) + return false } finally { setSaving(false) } } - return { form, saving, error, open, setValue, close, save } + const save = async () => { + if (!form) return + if (await persist(form)) setForm(null) + } + + const perform = async (a: PluginAction) => { + if (!form || !form.active || runningAction) return + const seq = openSeq.current + // Set BEFORE the dirty-save window, not just around the invoke call, so a second + // click (or the shells' Save button, see M7) is blocked for the whole run — not just + // once the pre-run save has already gone out. + setRunningAction(a.key) + try { + if (form.dirty) { + if (!(await persist(form))) return + if (openSeq.current !== seq) return + // Only clear dirty if the values are still the snapshot we just persisted — + // a reference check, since setValue always creates a new values object. A + // concurrent edit during the save produced a new object, so dirty stays true + // and the edit isn't silently dropped from the next save/action. + setForm(s => s && s.values === form.values ? { ...s, dirty: false } : s) + } + if (openSeq.current !== seq) return + const res = await adminApi.runPluginAction(form.id, a.key) + if (openSeq.current !== seq) return + setActionResult(r => ({ ...r, [a.key]: res })) + } catch (e) { + if (openSeq.current !== seq) return + const err = e as { response?: { status?: number; data?: { error?: string } } } + if (err.response?.status === 404 && err.response?.data?.error === 'Plugin is not active') { + // The DB `status` column said active when the dialog opened, but the server's + // live-process check (the supervisor's actual child map) disagrees — a stale + // row with no running child. Believe the server: show the real reason instead + // of a bare "Error", and disable the buttons instead of leaving them clickable. + setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('admin.plugins.actions.inactive') } })) + setForm(s => s && { ...s, active: false }) + } else { + setActionResult(r => ({ ...r, [a.key]: { ok: false, message: t('common.error') } })) + } + } finally { + if (openSeq.current === seq) setRunningAction(null) + } + } + + const runAction = (a: PluginAction) => { + if (!form || !form.active) return + if (a.danger) { setPendingAction(a); return } + void perform(a) + } + + const confirmPendingAction = () => { + const a = pendingAction + setPendingAction(null) + if (a) void perform(a) + } + + const cancelPendingAction = () => setPendingAction(null) + + return { + form, saving, error, open, setValue, close, save, + actionResult, runningAction, pendingAction, runAction, confirmPendingAction, cancelPendingAction, + } } diff --git a/client/src/components/Settings/PluginSettingsTab.test.tsx b/client/src/components/Settings/PluginSettingsTab.test.tsx index d206fa42d..c4eb0b8ab 100644 --- a/client/src/components/Settings/PluginSettingsTab.test.tsx +++ b/client/src/components/Settings/PluginSettingsTab.test.tsx @@ -275,7 +275,7 @@ describe('PluginSettingsTab', () => { }); it('FE-COMP-PLUGINSETTINGS-014: a plugin with only actions gets a card without a Save button', async () => { - serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false }] }); + serve('weather', { actions: [{ key: 'test', label: 'Test connection', hint: 'Pings the API', danger: false, scope: 'user' }] }); setPlugins([plugin()]); render(); @@ -288,7 +288,7 @@ describe('PluginSettingsTab', () => { it('FE-COMP-PLUGINSETTINGS-015: running an action shows the message it returns', async () => { const user = userEvent.setup(); - serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] }); + serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] }); server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: true, message: 'Reached the API' }))); setPlugins([plugin()]); @@ -300,7 +300,7 @@ describe('PluginSettingsTab', () => { it('FE-COMP-PLUGINSETTINGS-016: a message-less result falls back to the generic ok/error labels', async () => { const user = userEvent.setup(); - serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] }); + serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] }); server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ ok: false }))); setPlugins([plugin()]); render(); @@ -311,7 +311,7 @@ describe('PluginSettingsTab', () => { it('FE-COMP-PLUGINSETTINGS-017: a failing action reports an error result', async () => { const user = userEvent.setup(); - serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false }] }); + serve('weather', { actions: [{ key: 'test', label: 'Test connection', danger: false, scope: 'user' }] }); server.use(http.post('/api/plugin-settings/weather/actions/test', () => HttpResponse.json({ error: 'down' }, { status: 500 }))); setPlugins([plugin()]); @@ -327,7 +327,7 @@ describe('PluginSettingsTab', () => { const user = userEvent.setup(); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); let ran = 0; - serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true }] }); + serve('weather', { actions: [{ key: 'wipe', label: 'Wipe cache', danger: true, scope: 'user' }] }); server.use(http.post('/api/plugin-settings/weather/actions/wipe', () => { ran += 1; return HttpResponse.json({ ok: true, message: 'Wiped' }); diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index 5fb0e6579..f84b38493 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -55,6 +55,9 @@ interface PluginRow { /** How many `scope:'instance'` settings fields the plugin declares — gates the * "Instance settings" sheet action without a per-plugin fetch. */ instanceSettingsCount?: number + /** How many `scope:'instance'` actions the plugin declares — a plugin with actions + * but no settings fields still needs the sheet action to run them. */ + instanceActionsCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -591,9 +594,9 @@ export default function MAdminPluginsPanel() { } } - const openInstanceSettings = (id: string) => { + const openInstanceSettings = (p: { id: string; status: string }) => { setMenu(null) - settings.open(id) + settings.open(p.id, p.status === 'active') } const openErrors = (id: string) => { @@ -972,7 +975,7 @@ export default function MAdminPluginsPanel() { onRestart={() => restart(rowMenuPlugin.id)} onErrors={() => openErrors(rowMenuPlugin.id)} onEgress={() => openEgress(rowMenuPlugin.id)} - onSettings={() => openInstanceSettings(rowMenuPlugin.id)} + onSettings={() => openInstanceSettings(rowMenuPlugin)} onChangeVersion={() => openVersionPicker(rowMenuPlugin)} onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} /> )} @@ -1127,8 +1130,45 @@ export default function MAdminPluginsPanel() { {f.hint && {f.hint}} ))} + {(() => { + const form = settings.form + if (!form || form.actions.length === 0) return null + return ( +
+ + {t('admin.plugins.actions')} + + {!form.active && ( +

{t('admin.plugins.actions.inactive')}

+ )} +
+ {form.actions.map(a => { + const res = settings.actionResult[a.key] + return ( +
+ settings.runAction(a)} + > + {a.label} + + {a.hint && {a.hint}} + {res && ( + + {res.message || (res.ok ? t('common.success') : t('common.error'))} + + )} +
+ ) + })} +
+
+ ) + })()} {settings.error &&

{settings.error}

} - void settings.save()} className="h-[42px] w-full"> + void settings.save()} className="h-[42px] w-full"> {t('common.save')} @@ -1151,6 +1191,18 @@ export default function MAdminPluginsPanel() { danger /> + {/* Instance action confirm */} + + {signatureBlock && ( {t('admin.plugins.restart')} )} - {/* Only a plugin that DECLARES scope:'instance' fields gets the action — an - empty form would invite the admin to configure nothing. */} - {(p.instanceSettingsCount ?? 0) > 0 && ( + {/* A plugin gets the action if it declares scope:'instance' fields OR actions — + an action-only plugin still needs the sheet to run them. */} + {((p.instanceSettingsCount ?? 0) > 0 || (p.instanceActionsCount ?? 0) > 0) && ( )} diff --git a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx index 3aadd7a99..ac82f6f0d 100644 --- a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx @@ -1667,4 +1667,60 @@ describe('MAdminPluginsPanel — instance settings', () => { expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument(); }); }); + + const ACTIONS = [ + { key: 'ping', label: 'Ping server', danger: false, scope: 'instance' }, + { key: 'purge', label: 'Purge cache', hint: 'Drops every cached tile', danger: true, scope: 'instance' }, + ]; + + async function openWithActions(p: Row = plugin({ instanceSettingsCount: 2 })) { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com' }, actions: ACTIONS })), + ); + await openRowSheet(p); + fireEvent.click(screen.getByText('Instance settings')); + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }); + } + + it('FE-MOB-PLUGP-ACT-010: instance actions render from the config response and post to the admin action route', async () => { + let posted = ''; + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key); + return HttpResponse.json({ ok: true, message: 'pong' }); + }), + ); + await openWithActions(); + expect(screen.getByText('Drops every cached tile')).toBeInTheDocument(); // hint + fireEvent.click(screen.getByRole('button', { name: 'Ping server' })); + expect(await screen.findByText('pong', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(posted).toBe('ping'); + // The sheet stays open — the admin may run another one. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-ACT-011: a danger action opens the MConfirmSheet first; confirming posts', async () => { + let posted = ''; + server.use( + http.post('*/api/admin/plugins/trek-gotify/actions/:key', ({ params }) => { + posted = String(params.key); + return HttpResponse.json({ ok: false, message: 'cache locked' }); + }), + ); + await openWithActions(); + fireEvent.click(screen.getByRole('button', { name: 'Purge cache' })); + const dialog = await screen.findByRole('dialog', { name: 'Purge cache' }); + expect(within(dialog).getByText('Run this action?')).toBeInTheDocument(); + expect(posted).toBe(''); + fireEvent.click(within(dialog).getByRole('button', { name: 'Purge cache' })); + expect(await screen.findByText('cache locked', {}, { timeout: 5000 })).toBeInTheDocument(); + expect(posted).toBe('purge'); + }); + + it('FE-MOB-PLUGP-ACT-012: an inactive plugin renders the action buttons disabled with the activation hint', async () => { + await openWithActions(plugin({ instanceSettingsCount: 2, status: 'inactive', enabled: 0 })); + expect(screen.getByRole('button', { name: 'Ping server' })).toBeDisabled(); + expect(screen.getByText('Activate the plugin to run its actions')).toBeInTheDocument(); + }); }); diff --git a/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx b/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx index c1e871d79..fdafd7ae4 100644 --- a/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx +++ b/client/tests/unit/mobile/settings/MSettingsPlugins.test.tsx @@ -43,8 +43,8 @@ const CONFIG: Record = { }; const ACTIONS: PluginAction[] = [ - { key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false }, - { key: 'reset', label: 'Reset cache', danger: true }, + { key: 'test', label: 'Test connection', hint: 'Pings the provider', danger: false, scope: 'user' }, + { key: 'reset', label: 'Reset cache', danger: true, scope: 'user' }, ]; const ACTIVITY = [ @@ -284,7 +284,7 @@ describe('MSettingsPlugins', () => { it('FE-MOB-SETPLG-013: an action result without a message falls back to the generic label', async () => { const user = userEvent.setup(); usePluginStore.setState({ plugins: [PLUGIN], loaded: true }); - seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false }] }); + seedPluginApi('p1', { actions: [ACTIONS[0], { key: 'probe', label: 'Probe', danger: false, scope: 'user' }] }); server.use( http.post('/api/plugin-settings/p1/actions/test', () => HttpResponse.json({ ok: true })), http.post('/api/plugin-settings/p1/actions/probe', () => HttpResponse.json({ ok: false })), diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index 4c00de9fe..5bfa8d3f7 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -156,6 +156,32 @@ Declare settings in `trek-plugin.json`; TREK renders the form, you write no sett Any other attribute is **silently dropped at install**. `trek-plugin validate` warns on one (`manifest.settings-known-keys`) so a typo like `"defalt"` doesn't quietly do nothing. +### Actions + +Up to 8 buttons the host renders on a settings form, declared in `trek-plugin.json` +and implemented in `definePlugin({ actions: { : async (ctx) => ({ ok, message }) } })`: + +```json +"actions": [ + { "key": "testConnection", "label": "Test connection", "hint": "Pings the API." }, + { "key": "purgeCache", "label": "Purge cache", "scope": "instance", "danger": true } +] +``` + +| Attribute | Meaning | +|---|---| +| `scope` | `user` (default) — renders on the user Settings tab and runs as the clicking user. `instance` — renders in the admin instance-settings dialog and runs as the clicking admin. The default differs from settings fields on purpose: existing manifests keep their user-tab buttons. A host older than TREK 4.2.0 (which introduced action scope) ignores this field entirely and renders the button on every user's Settings tab, running it as each user — so if you declare `scope: "instance"`, set the manifest's `trek` range floor to `>=4.2.0` or the button will run in the wrong place on an older host. | +| `danger` | Rendered destructive; the host asks for confirmation first. | +| `hint` | Shown beside the button (≤ 200 chars). | + +Either way the handler gets the clicking person as acting user: `ctx.config` is the +instance config, `ctx.settings.get()` returns their own user value, and trip reads are +membership-checked against them. Return `{ ok, message }` or throw to report a failed +action (the host shows the message, emoji-stripped, ≤ 200 chars). An instance action is +disabled in the dialog until the plugin is active — there is no child process to run it. +In the mock host, pass `declaredActions: ['testConnection', { key: 'purgeCache', scope: 'instance' }]` +and drive either with `drv.action(key)`. + `ctx.config` is frozen at activation — the host re-spawns the plugin when the admin saves. `ctx.settings.get()` is live per call and returns `undefined` in a userless context (`onLoad`, jobs, scheduler) — fall back to `ctx.config` there. diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 1ade37925..7a45d0021 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -813,13 +813,15 @@ export interface PluginDefinition { exportUserData?(input: { userId: number }, ctx: PluginContext): Promise | unknown; events?: PluginEventSubscription[]; /** - * 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 Promise | PluginActionResult | void>; hooks?: { diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 77ec1da58..01d5b8d27 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -60,6 +60,10 @@ export interface ManifestAction { label?: string; hint?: string; danger?: boolean; + /** Which settings form renders the button: `'user'` (default) on the user Settings + * tab, run as the clicking user; `'instance'` in the admin instance-settings dialog, + * run as the clicking admin. Unlike settings fields, the default is `'user'`. */ + scope?: 'user' | 'instance'; } export interface ManifestCapabilities { settingsUi?: boolean; @@ -446,6 +450,9 @@ export function validateManifest(raw: unknown): ValidationResult { if (seen.has(key)) errors.push(`duplicate action "${key}"`); seen.add(key); if (a.label !== undefined && typeof a.label !== 'string') errors.push(`action "${key}" label must be a string`); + if (a.scope !== undefined && a.scope !== 'user' && a.scope !== 'instance') { + errors.push(`action "${key}".scope must be "user" or "instance"`); + } } } } diff --git a/plugin-sdk/src/mock-host.ts b/plugin-sdk/src/mock-host.ts index b6b0b5395..519946030 100644 --- a/plugin-sdk/src/mock-host.ts +++ b/plugin-sdk/src/mock-host.ts @@ -72,10 +72,12 @@ export interface MockHostOptions { * refuses it (and the SDK client swallows the rejection, so subscribers silently * never see it). Unset = record every emit, as before. */ declaredEmits?: string[]; - /** Action keys this plugin declares in its manifest `actions`. When set, driving an + /** Action keys this plugin declares in its manifest `actions`, as plain keys or + * `{ key, scope }` entries (the scope is accepted for fixture fidelity; the mock runs + * both scopes with the acting-user ctx, as the host does). When set, driving an * undeclared key throws — production refuses it before the child is ever woken. * Unset = any key the plugin implements can be driven. */ - declaredActions?: string[]; + declaredActions?: Array; /** Hosts an ADMIN supplies at runtime to an `operatorEgress: true` plugin, which by * definition cannot name them in its manifest. Only `trek-plugin dev` reads this (to * widen its egress guard); the mock ctx makes no network calls of its own. It lives @@ -1580,7 +1582,8 @@ export function createMockHost(opts: MockHostOptions = {}): MockHost { return impl[fn](...args, name === 'notificationChannel' ? userlessCtx : ctx) as never; }, action: async (key) => { - if (opts.declaredActions && !opts.declaredActions.includes(key)) { + const declared = opts.declaredActions?.map((a) => (typeof a === 'string' ? a : a.key)); + if (declared && !declared.includes(key)) { throw new Error(`RESOURCE_FORBIDDEN: plugin did not declare action "${key}"`); } const fn = def.actions?.[key]; diff --git a/plugin-sdk/test/manifest-roundtrip.test.ts b/plugin-sdk/test/manifest-roundtrip.test.ts index f9fa9cd56..06e88f814 100644 --- a/plugin-sdk/test/manifest-roundtrip.test.ts +++ b/plugin-sdk/test/manifest-roundtrip.test.ts @@ -9,7 +9,7 @@ describe('validateManifest normalized output', () => { homepage: 'https://example.com', tags: ['fun'], license: 'MIT', capabilities: { widget: { title: 'W', slot: 'hero' }, provides: ['getState'] }, settings: [{ key: 'mode', scope: 'user', options: ['a', 'b'] }], - actions: [{ key: 'ping', label: 'Ping' }], + actions: [{ key: 'ping', label: 'Ping' }, { key: 'purge', label: 'Purge', scope: 'instance', danger: true }], }; const r = validateManifest(input); expect(r.ok).toBe(true); @@ -17,6 +17,7 @@ describe('validateManifest normalized output', () => { expect(m.capabilities?.widget?.slot).toBe('hero'); expect(m.settings?.[0].options).toEqual(['a', 'b']); expect(m.actions?.[0]).toEqual({ key: 'ping', label: 'Ping' }); + expect(m.actions?.[1]).toEqual({ key: 'purge', label: 'Purge', scope: 'instance', danger: true }); expect(m.icon).toBe('Bell'); expect(m.operatorEgress).toBe(false); expect(m.nativeModules).toBe(false); // still forced diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index cde76e4d8..53ba0b723 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -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. diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 0b8b22ead..087046333 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -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) { diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index 637f4bdfe..2019f39e8 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -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); diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 1129cc72d..7c8fa0f88 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -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; + const { key, label, hint, danger, scope } = a as Record; 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; diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index 5ca00c3da..ae9a42f70 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -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 { + 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 { diff --git a/server/src/nest/plugins/plugin-user-settings.controller.ts b/server/src/nest/plugins/plugin-user-settings.controller.ts index 284f0a3cd..cd7250261 100644 --- a/server/src/nest/plugins/plugin-user-settings.controller.ts +++ b/server/src/nest/plugins/plugin-user-settings.controller.ts @@ -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; - 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 { 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) }; diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 784703864..452ed4b52 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -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 { + 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 diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 765cb1010..978d77264 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -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, diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 3fbd0fa50..a8a324424 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -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; /** - * 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 Promise | PluginActionResult | void>; events?: PluginEventSubscription[]; diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index 79dfa6783..1cf1eb5f4 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -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' }); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index 624b22349..2880fca44 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -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/); diff --git a/server/tests/unit/plugins/operator-egress.test.ts b/server/tests/unit/plugins/operator-egress.test.ts index 25392eabe..93adbdcd8 100644 --- a/server/tests/unit/plugins/operator-egress.test.ts +++ b/server/tests/unit/plugins/operator-egress.test.ts @@ -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/); }); }); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index 66adca849..6880e69fc 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -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) => 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' }); + }); +}); diff --git a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts index 37d03aa70..722e20af6 100644 --- a/server/tests/unit/plugins/plugin-user-settings.controller.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.controller.test.ts @@ -27,7 +27,7 @@ function ctrl() { updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record) => ({ ...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'); + }); }); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 86951ff2a..a9109fa3f 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -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 }); }); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index d9c6fbd5b..e1484998a 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -454,6 +454,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم', 'admin.plugins.settingsSaved': 'تم حفظ الإعدادات', 'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة', + 'admin.plugins.actions': 'الإجراءات', + 'admin.plugins.actions.confirm': 'تنفيذ هذا الإجراء؟', + 'admin.plugins.actions.inactive': 'فعّل الإضافة لتشغيل إجراءاتها', 'admin.plugins.requiredMissing': '"{field}" مطلوب', 'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك', 'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 75481a3d7..2cb02fcfc 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -443,6 +443,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Configurações da instância', 'admin.plugins.settingsSaved': 'Configurações salvas', 'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado', + 'admin.plugins.actions': 'Ações', + 'admin.plugins.actions.confirm': 'Executar esta ação?', + 'admin.plugins.actions.inactive': 'Ative o plugin para executar suas ações', 'admin.plugins.requiredMissing': '"{field}" é obrigatório', 'admin.plugins.cap.readsTrips': 'Lê suas viagens', 'admin.plugins.cap.readsUsers': 'Lê perfis básicos', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 71dbd1327..0afcb5991 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -577,6 +577,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Configuració de la instància', 'admin.plugins.settingsSaved': 'Configuració desada', 'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat', + 'admin.plugins.actions': 'Accions', + 'admin.plugins.actions.confirm': 'Vols executar aquesta acció?', + 'admin.plugins.actions.inactive': 'Activa el connector per executar les seves accions', 'admin.plugins.requiredMissing': '"{field}" és obligatori', 'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges', 'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 7815ea3a1..7d9ee4c82 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -441,6 +441,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Nastavení instance', 'admin.plugins.settingsSaved': 'Nastavení uloženo', 'admin.plugins.settingsSavedRestarted': 'Nastavení uloženo — plugin restartován', + 'admin.plugins.actions': 'Akce', + 'admin.plugins.actions.confirm': 'Spustit tuto akci?', + 'admin.plugins.actions.inactive': 'Aktivujte plugin, abyste mohli spouštět jeho akce', 'admin.plugins.requiredMissing': '„{field}" je povinné', 'admin.plugins.cap.readsTrips': 'Čte vaše cesty', 'admin.plugins.cap.readsUsers': 'Čte základní profily', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index c14d05002..d5247d980 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -450,6 +450,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instanz-Einstellungen', 'admin.plugins.settingsSaved': 'Einstellungen gespeichert', 'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet', + 'admin.plugins.actions': 'Aktionen', + 'admin.plugins.actions.confirm': 'Diese Aktion ausführen?', + 'admin.plugins.actions.inactive': 'Aktiviere das Plugin, um seine Aktionen auszuführen', 'admin.plugins.requiredMissing': '"{field}" ist erforderlich', 'admin.plugins.cap.readsTrips': 'Liest deine Reisen', 'admin.plugins.cap.readsUsers': 'Liest Basis-Profile', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 199469261..af5b90801 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -488,6 +488,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instance settings', 'admin.plugins.settingsSaved': 'Settings saved', 'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted', + 'admin.plugins.actions': 'Actions', + 'admin.plugins.actions.confirm': 'Run this action?', + 'admin.plugins.actions.inactive': 'Activate the plugin to run its actions', 'admin.plugins.requiredMissing': '"{field}" is required', 'admin.plugins.cap.readsTrips': 'Reads your trips', 'admin.plugins.cap.readsUsers': 'Reads basic profiles', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index 56428a83a..857ca1da9 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -452,6 +452,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ajustes de la instancia', 'admin.plugins.settingsSaved': 'Ajustes guardados', 'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado', + 'admin.plugins.actions': 'Acciones', + 'admin.plugins.actions.confirm': '¿Ejecutar esta acción?', + 'admin.plugins.actions.inactive': 'Activa el plugin para ejecutar sus acciones', 'admin.plugins.requiredMissing': '"{field}" es obligatorio', 'admin.plugins.cap.readsTrips': 'Lee tus viajes', 'admin.plugins.cap.readsUsers': 'Lee perfiles básicos', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index f2cc94c5f..61f0c2da8 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -455,6 +455,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Paramètres de l’instance', 'admin.plugins.settingsSaved': 'Paramètres enregistrés', 'admin.plugins.settingsSavedRestarted': 'Paramètres enregistrés — plugin redémarré', + 'admin.plugins.actions': 'Actions', + 'admin.plugins.actions.confirm': 'Exécuter cette action ?', + 'admin.plugins.actions.inactive': 'Activez le plugin pour exécuter ses actions', 'admin.plugins.requiredMissing': '« {field} » est obligatoire', 'admin.plugins.cap.readsTrips': 'Lit vos voyages', 'admin.plugins.cap.readsUsers': 'Lit les profils de base', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 4ac8cb8de..5fac6f035 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -503,6 +503,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης', 'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν', 'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε', + 'admin.plugins.actions': 'Ενέργειες', + 'admin.plugins.actions.confirm': 'Εκτέλεση αυτής της ενέργειας;', + 'admin.plugins.actions.inactive': 'Ενεργοποιήστε το plugin για να εκτελέσετε τις ενέργειές του', 'admin.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό', 'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας', 'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 6c19187e3..faf5b293e 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -455,6 +455,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Példányszintű beállítások', 'admin.plugins.settingsSaved': 'Beállítások elmentve', 'admin.plugins.settingsSavedRestarted': 'Beállítások elmentve — plugin újraindítva', + 'admin.plugins.actions': 'Műveletek', + 'admin.plugins.actions.confirm': 'Végrehajtod ezt a műveletet?', + 'admin.plugins.actions.inactive': 'Aktiváld a plugint a műveletei futtatásához', 'admin.plugins.requiredMissing': '"{field}" kötelező', 'admin.plugins.cap.readsTrips': 'Olvassa az utazásaidat', 'admin.plugins.cap.readsUsers': 'Olvassa az alapprofilokat', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 0e8dd2f67..4cfb0cdcd 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -497,6 +497,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Pengaturan instans', 'admin.plugins.settingsSaved': 'Pengaturan disimpan', 'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang', + 'admin.plugins.actions': 'Tindakan', + 'admin.plugins.actions.confirm': 'Jalankan tindakan ini?', + 'admin.plugins.actions.inactive': 'Aktifkan plugin untuk menjalankan tindakannya', 'admin.plugins.requiredMissing': '"{field}" wajib diisi', 'admin.plugins.cap.readsTrips': 'Membaca perjalananmu', 'admin.plugins.cap.readsUsers': 'Membaca profil dasar', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index b08426d3a..36009254d 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -450,6 +450,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Impostazioni dell’istanza', 'admin.plugins.settingsSaved': 'Impostazioni salvate', 'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato', + 'admin.plugins.actions': 'Azioni', + 'admin.plugins.actions.confirm': 'Eseguire questa azione?', + 'admin.plugins.actions.inactive': 'Attiva il plugin per eseguire le sue azioni', 'admin.plugins.requiredMissing': '"{field}" è obbligatorio', 'admin.plugins.cap.readsTrips': 'Legge i tuoi viaggi', 'admin.plugins.cap.readsUsers': 'Legge i profili di base', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 1fdc218a8..9ef76898b 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -462,6 +462,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'インスタンス設定', 'admin.plugins.settingsSaved': '設定を保存しました', 'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました', + 'admin.plugins.actions': 'アクション', + 'admin.plugins.actions.confirm': 'このアクションを実行しますか?', + 'admin.plugins.actions.inactive': 'アクションを実行するにはプラグインを有効化してください', 'admin.plugins.requiredMissing': '「{field}」は必須です', 'admin.plugins.cap.readsTrips': '旅行を読み取り', 'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 038a9715e..f0b226ba2 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -462,6 +462,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '인스턴스 설정', 'admin.plugins.settingsSaved': '설정이 저장되었습니다', 'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다', + 'admin.plugins.actions': '작업', + 'admin.plugins.actions.confirm': '이 작업을 실행할까요?', + 'admin.plugins.actions.inactive': '작업을 실행하려면 플러그인을 활성화하세요', 'admin.plugins.requiredMissing': '"{field}"은(는) 필수입니다', 'admin.plugins.cap.readsTrips': '여행 읽기', 'admin.plugins.cap.readsUsers': '기본 프로필 읽기', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index a04d55b9f..9ce42c5e9 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -449,6 +449,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instantie-instellingen', 'admin.plugins.settingsSaved': 'Instellingen opgeslagen', 'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart', + 'admin.plugins.actions': 'Acties', + 'admin.plugins.actions.confirm': 'Deze actie uitvoeren?', + 'admin.plugins.actions.inactive': 'Activeer de plugin om de acties uit te voeren', 'admin.plugins.requiredMissing': '"{field}" is vereist', 'admin.plugins.cap.readsTrips': 'Leest je reizen', 'admin.plugins.cap.readsUsers': 'Leest basisprofielen', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index f24405f45..f28affc30 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -447,6 +447,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ustawienia instancji', 'admin.plugins.settingsSaved': 'Ustawienia zapisane', 'admin.plugins.settingsSavedRestarted': 'Ustawienia zapisane — wtyczka została uruchomiona ponownie', + 'admin.plugins.actions': 'Akcje', + 'admin.plugins.actions.confirm': 'Wykonać tę akcję?', + 'admin.plugins.actions.inactive': 'Aktywuj wtyczkę, aby uruchomić jej akcje', 'admin.plugins.requiredMissing': '"{field}" jest wymagane', 'admin.plugins.cap.readsTrips': 'Odczytuje Twoje podróże', 'admin.plugins.cap.readsUsers': 'Odczytuje podstawowe profile', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 8e58cafe8..967479d47 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -451,6 +451,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Настройки экземпляра', 'admin.plugins.settingsSaved': 'Настройки сохранены', 'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен', + 'admin.plugins.actions': 'Действия', + 'admin.plugins.actions.confirm': 'Выполнить это действие?', + 'admin.plugins.actions.inactive': 'Активируйте плагин, чтобы выполнять его действия', 'admin.plugins.requiredMissing': '«{field}» обязательно', 'admin.plugins.cap.readsTrips': 'Читает ваши поездки', 'admin.plugins.cap.readsUsers': 'Читает основные профили', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index 1c1d12377..48f1149a7 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -508,6 +508,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instansinställningar', 'admin.plugins.settingsSaved': 'Inställningarna har sparats', 'admin.plugins.settingsSavedRestarted': 'Inställningarna har sparats — pluginet har startats om', + 'admin.plugins.actions': 'Åtgärder', + 'admin.plugins.actions.confirm': 'Kör den här åtgärden?', + 'admin.plugins.actions.inactive': 'Aktivera pluginet för att köra dess åtgärder', 'admin.plugins.requiredMissing': '"{field}" krävs', 'admin.plugins.cap.readsTrips': 'Läser dina resor', 'admin.plugins.cap.readsUsers': 'Läser grundläggande profiler', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 2364743ba..45c7db749 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -494,6 +494,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Örnek ayarları', 'admin.plugins.settingsSaved': 'Ayarlar kaydedildi', 'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı', + 'admin.plugins.actions': 'Eylemler', + 'admin.plugins.actions.confirm': 'Bu eylem çalıştırılsın mı?', + 'admin.plugins.actions.inactive': 'Eylemlerini çalıştırmak için eklentiyi etkinleştirin', 'admin.plugins.requiredMissing': '"{field}" gerekli', 'admin.plugins.cap.readsTrips': 'Seyahatlerinizi okur', 'admin.plugins.cap.readsUsers': 'Temel profilleri okur', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index e77cab8ca..61a5f70c0 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -448,6 +448,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Налаштування інстансу', 'admin.plugins.settingsSaved': 'Налаштування збережено', 'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено', + 'admin.plugins.actions': 'Дії', + 'admin.plugins.actions.confirm': 'Виконати цю дію?', + 'admin.plugins.actions.inactive': 'Активуйте плагін, щоб виконувати його дії', 'admin.plugins.requiredMissing': '«{field}» обов’язкове', 'admin.plugins.cap.readsTrips': 'Читає ваші подорожі', 'admin.plugins.cap.readsUsers': 'Читає базові профілі', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 30789b200..3342859a5 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -450,6 +450,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Cài đặt toàn hệ thống', 'admin.plugins.settingsSaved': 'Đã lưu cài đặt', 'admin.plugins.settingsSavedRestarted': 'Đã lưu cài đặt — đã khởi động lại plugin', + 'admin.plugins.actions': 'Hành động', + 'admin.plugins.actions.confirm': 'Chạy hành động này?', + 'admin.plugins.actions.inactive': 'Kích hoạt plugin để chạy các hành động của nó', 'admin.plugins.requiredMissing': '"{field}" là bắt buộc', 'admin.plugins.cap.readsTrips': 'Đọc chuyến đi của bạn', 'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 226ddbf41..9422c3198 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -444,6 +444,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '執行個體設定', 'admin.plugins.settingsSaved': '設定已儲存', 'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動', + 'admin.plugins.actions': '操作', + 'admin.plugins.actions.confirm': '執行此操作?', + 'admin.plugins.actions.inactive': '啟用外掛以執行其操作', 'admin.plugins.requiredMissing': '「{field}」為必填項', 'admin.plugins.cap.readsTrips': '讀取你的行程', 'admin.plugins.cap.readsUsers': '讀取基本個人資料', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 4f4e203d2..10b76c0de 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -404,6 +404,9 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '实例设置', 'admin.plugins.settingsSaved': '设置已保存', 'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启', + 'admin.plugins.actions': '操作', + 'admin.plugins.actions.confirm': '执行此操作?', + 'admin.plugins.actions.inactive': '激活插件以运行其操作', 'admin.plugins.requiredMissing': '「{field}」为必填项', 'admin.plugins.cap.readsTrips': '读取你的旅行', 'admin.plugins.cap.readsUsers': '读取基本个人资料', diff --git a/shared/src/plugins/plugins.schema.spec.ts b/shared/src/plugins/plugins.schema.spec.ts index e123070e6..54e6b7a36 100644 --- a/shared/src/plugins/plugins.schema.spec.ts +++ b/shared/src/plugins/plugins.schema.spec.ts @@ -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); + }); +}); diff --git a/shared/src/plugins/plugins.schema.ts b/shared/src/plugins/plugins.schema.ts index ff6bcbb45..a5bcd9abe 100644 --- a/shared/src/plugins/plugins.schema.ts +++ b/shared/src/plugins/plugins.schema.ts @@ -31,11 +31,37 @@ export const pluginSettingsFieldSchema = z.object({ }); export type PluginSettingsField = z.infer; +/** + * 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; + +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; + +/** 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; + /** 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; diff --git a/wiki/Plugin-Cookbook.md b/wiki/Plugin-Cookbook.md index 1979db3e5..64d4c317c 100644 --- a/wiki/Plugin-Cookbook.md +++ b/wiki/Plugin-Cookbook.md @@ -299,6 +299,23 @@ beside them. Return `{ ok, message? }`; throwing is the same as `{ ok: false }` the error text. `danger: true` asks for confirmation first. The host refuses any key your manifest didn't declare, and bounds the message it shows (200 chars, emoji stripped). +**…and one for the admin.** A button that acts on **instance** configuration rather than one +person's credentials takes `"scope": "instance"`: + +```json +"actions": [ + { "key": "purgeCache", "label": "Purge cache", "scope": "instance" } +] +``` + +It renders in **Admin → Plugins → ⋯ → Instance settings** instead of on anyone's settings +page, and runs as the clicking **admin** — so `ctx.config` is the instance settings shown +right above it, while `ctx.settings.get()` is still that admin's own value. The button is +**disabled until the plugin is active** (an action needs a running child), and an edited +form is saved before the action fires, so you never test against configuration the plugin +hasn't been given. Needs a manifest `trek` floor of `>=4.2.0` — hosts older than 4.2.0 +ignore `scope` and would show the button to every user. + Contrast with the `notificationChannel` hook, which is **host**-initiated and therefore has *no* acting user — there, `ctx.settings.get()` returns `undefined` and the recipient's credentials arrive as an argument instead. @@ -430,7 +447,7 @@ hooks: { } ``` -`tone` is `'default' | 'success' | 'warn' | 'danger'`; any `url` must be http/https/mailto; `icon` is a lucide icon name. A table `action` (instead of a `column`) is a labelled button whose target opens your sandboxed frame or calls one of your routes. +`tone` is `'default' | 'success' | 'warn' | 'danger'`; any `url` must be http/https/mailto; `icon` is a lucide icon name — and it is the only way to get a glyph in, because every string a hook returns is emoji-stripped at the render boundary before TREK draws it. A table `action` (instead of a `column`) is a labelled button whose target opens your sandboxed frame or calls one of your routes. --- diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 50da22e08..e8f524fc7 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -701,9 +701,11 @@ context, so media queries inside it measure the frame, and they work. ## Settings Declare settings in the manifest; TREK renders the form (you write no settings -UI). `scope: "instance"` settings are set once by the admin and arrive resolved in -`ctx.config`; `scope: "user"` settings are per-user and are read one key at a time -with `await ctx.settings.get(key)` (a userless job / `onLoad` gets `undefined` — +UI). `scope: "instance"` settings are set once by the admin — under +**Admin → Plugins → ⋯ → Instance settings**, where only the fields your manifest declares +are stored and saving re-spawns a running plugin — and arrive resolved in `ctx.config`. +`scope: "user"` settings are edited by each user under **Settings → Plugins** and are read +one key at a time with `await ctx.settings.get(key)` (a userless job / `onLoad` gets `undefined` — fall back to `ctx.config` there). `secret: true` fields are stored encrypted and delivered decrypted through whichever of the two applies (server-side only) — never to the iframe. @@ -803,7 +805,7 @@ module.exports = definePlugin({ | Hook | Permission | Status | |---|---|---| | `placeDetailProvider.getDetails(placeId, ctx)` → `{ label, value?, url? }[]` | `hook:place-detail-provider` | **live** — shown in the place-detail panel; also `GET /api/place-details/:placeId` | -| `warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | `hook:trip-warning-provider` | **live** — validation warnings shown as a non-blocking banner in the trip planner; also `GET /api/trip-warnings/:tripId` | +| `warningProvider.getWarnings(tripId, ctx)` → `{ level, message, dayId?, placeId? }[]` | `hook:trip-warning-provider` | **live** — validation warnings shown as a non-blocking banner in the trip planner; also `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use` | | `tableContributor.getContributions(view, tripId, ctx)` → `TableContribution[]` | `hook:table-contributor` | **live** — host-rendered **columns/actions** keyed by `entityId` in the reservations, transports, places, day, costs, packing, files and todos views. A `column` is `{kind:'column', entityId, id, label, value?, url?, icon?, tone?}` (url is http/https/mailto only); an `action` is `{kind:'action', entityId, id, label, icon?, target}` where `target` opens your sandboxed frame (`{kind:'frame', sub}`) or calls a route (`{kind:'route', method, sub}`). All fields are bounded + normalized host-side; also `GET /api/view-contributions/:view/:tripId` | | `mapMarkerProvider.getMarkers(tripId, ctx)` → `MapMarkerContribution[]` | `hook:map-marker-provider` | **live** — bounded markers overlaid on the trip map (#587). Each is `{id, lat, lng, label?, popupText?, url?, icon?, tone?}`; coordinates are range-checked (−90..90 / −180..180), text length-capped, url http/https/mailto-only, count capped (≤200/plugin). Declarative only — plugin JS never runs on the map canvas. Also `GET /api/map-markers/:tripId` | | `mapLayerProvider.getLayers(tripId, ctx)` → `MapLayerContribution[]` | `hook:map-layer-provider` | **live** — bounded vector overlays on the trip map: a computed route, a reachable-range corridor, a zone. Each layer is `{id, name?, features}` with features `{type: 'polyline'\|'polygon'\|'circle', points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. Styling stays in the tone palette; width (1–8), opacity (0.05–1) and radius (≤2000 km) are clamped, `dash` is an enum. Budgets per plugin: ≤4 layers, ≤150 features, ≤8000 vertices, ≤2000 vertices/shape — an oversized or partly-invalid shape is dropped whole, never truncated. Declarative only; drawn beneath TREK's own day route on both the Leaflet and GL renderers. Also `GET /api/map-layers/:tripId` | @@ -823,6 +825,12 @@ Each hook method receives its args plus the per-invocation `ctx`, so any `ctx.tr read it makes is membership-checked against the current user (like a route handler) — with **one exception**, the notification channel, which has no acting user at all. +Every string a hook returns is **emoji-stripped at the render boundary** — badges, columns, +warnings, PDF sections, map-marker labels, calendar and photo titles, notifications — so +plugin text stays inside TREK's own icon language; use the declarative `icon` field (a +lucide name) instead. Your plugin's own sandboxed frame is untouched: that markup is yours +to design. + ## Notification channels `hook:notification-channel` lets your plugin become a delivery channel alongside TREK's @@ -954,8 +962,13 @@ Semantics worth knowing before you ship one: runs.** `required` is enforced, `additionalProperties: false` rejects extras, enum/const/ranges/pattern/format are enforced — and nothing may be advertised that cannot be enforced, so an unsupported schema keyword (`oneOf`, `$ref`, an - unknown `format`) **fails the install**, not silently drops. `default` is - advertisement only — the host does not inject it; apply defaults yourself. + unknown `format`) **fails the install**, not silently drops. A `pattern` is capped at + **200 chars** and may not be ReDoS-shaped (a quantified group that itself repeats or + alternates); property names must look like identifiers + (`^[A-Za-z_][A-Za-z0-9_.-]{0,63}$`, so `__proto__` & co are rejected); + `additionalProperties` must be a boolean; and the schema **root** may not use `enum`, + `const` or `nullable`. `default` is advertisement only — the host does not inject it; + apply defaults yourself. - **A throw is a tool error, not a crash** — the assistant sees a sanitised message (≤ 300 chars). Results are capped at 64 KiB / 32 content blocks with a visible `[truncated: …]` marker; every string the assistant reads is sanitised @@ -981,13 +994,15 @@ load about a `mcpToolProvider` without the `mcp:tools` grant and 403s ## Settings-page actions -A plugin can put **buttons on its own settings page** — "Test connection", "Sync now", -"Clear cache". Declare them in the manifest and implement them on the definition: +A plugin can put **buttons on its own settings form** — "Test connection", "Sync now", +"Clear cache" — on a user's settings page or, with `scope: "instance"`, in the admin's +instance-settings dialog. Declare them in the manifest and implement them on the definition: ```json "actions": [ { "key": "testConnection", "label": "Test connection", "hint": "Pings the API." }, - { "key": "purge", "label": "Delete my data", "danger": true } + { "key": "purge", "label": "Delete my data", "danger": true }, + { "key": "purgeCache", "label": "Purge cache", "scope": "instance" } ] ``` @@ -1008,6 +1023,15 @@ An action is **user-initiated**, which is what makes it different from the `ctx.settings.get()` returns **their** value and any trip read is membership-checked against them — exactly what a "test my credentials" button needs. +`scope` decides *which* clicker, and where the button renders. The default, `"user"`, puts +it under **Settings → Plugins** beneath that plugin's own fields. `"instance"` puts it in +the admin's instance-settings dialog instead (**Admin → Plugins → ⋯ → Instance settings**), +next to the `scope: "instance"` fields it operates on — a "Purge cache" or a "Test SMTP" +that acts on instance configuration rather than one person's credentials. Both bind the +clicker as acting user, so an instance action is admin-*surfaced*, not admin-privileged: it +still sees that admin's own `ctx.settings` alongside `ctx.config`, and its trip reads are +membership-checked against them. + Notes: - Return `{ ok, message? }`. Throwing is the same as `{ ok: false }` with the error text. @@ -1015,7 +1039,18 @@ Notes: - `danger: true` renders it destructively and asks for confirmation first. - The key must be a valid settings key, and the host refuses any key the manifest didn't declare — the key arrives from the URL, so it is never forwarded to your plugin blindly. -- Max 8 actions; label capped at 60 chars, hint at 200. + The scope is fixed by the route as well: a `"user"` key can never be fired from the admin + route, nor an `"instance"` key from the settings page. +- Max 8 actions **across both scopes**, with keys unique across them too; label capped at 60 + chars (a blank one falls back to the key), hint at 200. A `scope` that is anything but + `"user"` or `"instance"` fails install and `trek-plugin validate` with + `action "".scope must be "user" or "instance"`. +- An instance action needs a **running** plugin: the button is disabled until the plugin is + active, the dialog saves an edited form before firing it, and the host answers + `404 { error: 'Plugin is not active' }` if the child is gone anyway. +- **Hosts older than TREK 4.2.0 ignore `scope`** and render the button on every user's + settings tab, so a plugin using `scope: "instance"` should set a manifest `trek` floor of + `>=4.2.0`. ## Operator-supplied egress hosts (`operatorEgress`) @@ -1318,6 +1353,20 @@ set — is refused rather than delivered, so a test can't pass on a notification host would never route to you. Pass `declaredActions` / `channelEvents` to model what your manifest declares. +`declaredActions` takes plain keys or `{ key, scope? }` entries, so a manifest with both +scopes models faithfully: + +```js +const host = createMockHost({ + actingUserId: 7, + declaredActions: ['testConnection', { key: 'purgeCache', scope: 'instance' }], +}) +``` + +`app.action(key)` drives either one, and driving a key you didn't declare throws +(`RESOURCE_FORBIDDEN`) like the host. The scope itself has no runtime effect in the mock: +both scopes get the acting-user ctx, exactly as the host gives both the clicker's. + ## Rules - **No native modules** (`.node`, `binding.gyp`, `prebuilds/`) — rejected at pack @@ -1354,7 +1403,7 @@ what your manifest declares. | `egress` | string[] | allowed outbound hosts; required (non-empty, no bare `*`) when any `http:outbound` permission is present — **unless** `operatorEgress` is `true`, in which case it may be empty/omitted and the admin supplies the hosts. | | `capabilities.widget` | object | `{ title, slot, defaultSize }` — `slot` is `sidebar` (default), `hero`, `place-detail`, `day-detail`, or `reservation-detail`. | | `capabilities.tripPage` | object | `{ replaces?, position? }` for `trip-page` plugins — `replaces` names core planner tabs to hide while active (`transports`, `buchungen`, `listen`, `finanzplan`, `dateien`, `collab`; never `plan`), `position` is the tab's 0-based index in the bar (0–50; omitted = appended). | -| `actions` | array | Buttons on the plugin's own settings page — `{ key, label, hint?, danger? }` (max 8). Implement each as `actions[key](ctx)` on the definition. **User-initiated**, so `ctx.settings.get()` returns the clicking user's value. See [Settings-page actions](#settings-page-actions). | +| `actions` | array | Buttons on the plugin's settings form — `{ key, label, hint?, danger?, scope? }` (max 8 across both scopes, keys unique across them). Implement each as `actions[key](ctx)` on the definition. `scope` is `user` (default — renders under **Settings → Plugins**) or `instance` (renders in **Admin → Plugins → ⋯ → Instance settings**, needs `trek` ≥ 4.2.0). Either way the action is **initiated by the person who clicked it** and runs as them, so `ctx.settings.get()` returns *that* user's value. See [Settings-page actions](#settings-page-actions). | | `operatorEgress` | boolean | The plugin talks to a **self-hosted** service whose hostname only the operator knows. The admin adds the real hosts after install (Admin → Plugins → Allowed hosts) and the runtime unions them into the egress allow-list. Requires an `http:outbound` permission, and is the only way to declare one with an empty `egress[]`. See [Operator-supplied egress hosts](#operator-supplied-egress-hosts-operatoregress). | | `capabilities.notificationChannel` | object | `{ title?, events? }` for a plugin implementing the `notificationChannel` hook — `title` names the column in the notification preferences matrix (default: the plugin's `name`), `events` **narrows** which events the channel carries (default: all ten plugin-deliverable events; `events` may only narrow that set). Requires the `hook:notification-channel` permission. See [Notification channels](#notification-channels). | | `capabilities.routeProfiles` | array | up to 3 `{ id, label, icon? }` entries for a plugin implementing the `routeProvider` hook — each becomes a selectable mode in the planner's route toggle (next to Driving/Walking). `id` is lowercase `[a-z][a-z0-9-]` (max 24 chars) and is what `getRoute` receives as `request.profile`; `label` (≤40 chars) is shown to the user. Requires the `hook:route-provider` permission. | diff --git a/wiki/Plugin-Permissions.md b/wiki/Plugin-Permissions.md index 73f437185..25abf404e 100644 --- a/wiki/Plugin-Permissions.md +++ b/wiki/Plugin-Permissions.md @@ -58,7 +58,7 @@ ungranted capability is physically unreachable**, not just disallowed. See | `hook:photo-provider` | Register as a photo provider in Memories | Implement the `PhotoProvider` interface. | | `hook:calendar-source` | Register as a calendar source | Implement the `CalendarSource` interface. | | `hook:place-detail-provider` | Contribute extra details (reviews, ratings, links) to a place via the `hooks.placeDetailProvider` provider hook | Implement `PlaceDetailProvider` in `hooks` on the plugin definition (not on `ctx`) — shown in the place-detail panel; also exposed at `GET /api/place-details/:placeId`. | -| `hook:trip-warning-provider` | Raise validation warnings on a trip via the `hooks.warningProvider` provider hook | Implement `WarningProvider` in `hooks` on the plugin definition (not on `ctx`) — shown as a non-blocking banner in the planner; also exposed at `GET /api/trip-warnings/:tripId`. | +| `hook:trip-warning-provider` | Raise validation warnings on a trip via the `hooks.warningProvider` provider hook | Implement `WarningProvider` in `hooks` on the plugin definition (not on `ctx`) — shown as a non-blocking banner in the planner; also exposed at `GET /api/trip-warnings/:tripId`, and to a connected assistant as the `get_trip_warnings` MCP tool (≤20 warnings per provider, message ≤300 chars) — that path needs only the trips read scope, not `plugins:use`. | | `hook:map-marker-provider` | Overlay bounded **markers** on the trip map via the `hooks.mapMarkerProvider` provider hook | Implement `MapMarkerProvider` in `hooks` — returns `{id, lat, lng, label?, popupText?, url?, icon?, tone?}[]` (#587 "show bookings on map"). **Declarative only** — plugin JS never runs on the map canvas. The host range-checks coordinates (−90..90 / −180..180), String-coerces + length-caps text, allowlists the popup url (http/https/mailto), and caps the marker count (≤200) per plugin; a failing provider is skipped. Exposed at `GET /api/map-markers/:tripId`. | | `hook:map-layer-provider` | Overlay bounded **vector layers** (polylines, polygons, metric circles) on the trip map via the `hooks.mapLayerProvider` provider hook | Implement `MapLayerProvider` in `hooks` — returns `{id, name?, features}[]` where a feature is `{type, points?/center?+radiusM?, tone?, width?, dash?, opacity?, fill?, label?}`. **Declarative only** — the host draws everything; styling is the tone palette plus clamped numerics (width 1–8, opacity 0.05–1, radius ≤2000 km) and a dash enum, so a layer can't impersonate core UI. Budgets per plugin: ≤4 layers / ≤150 features / ≤8000 vertices / ≤2000 vertices per shape; invalid or oversized shapes are dropped whole (never truncated); a failing provider is skipped. Drawn beneath TREK's own routes. Exposed at `GET /api/map-layers/:tripId`. | | `hook:route-provider` | Offer **routing profiles** the planner's route toggle can route days with, via the `hooks.routeProvider` provider hook | Implement `RouteProvider` in `hooks` and declare the profiles in `capabilities.routeProfiles` (max 3; each `{id, label, icon?}`). TREK calls `getRoute({tripId, dayId, profile, waypoints})` for exactly the profile the user picked — this is **targeted**, not a fan-out — with a 20 s timeout so the plugin can call an external solver through its declared egress. The result (`coordinates`, `distance`, `duration`, `legs`, `viaPoints?`) is validated whole (leg count must equal `waypoints−1`, ≤10000 vertices, ≤40 vias); anything malformed is discarded and the planner falls back to straight lines. Exposed at `POST /api/plugin-routes/:pluginId/:profileId` (member-gated per trip). | @@ -188,13 +188,21 @@ flow is in [[Publishing a Plugin|Plugin-Publishing]]. ## Not a permission — settings-page actions -A plugin can contribute buttons to its own settings page (`actions` in the manifest — a +A plugin can contribute buttons to its own settings form (`actions` in the manifest — a "Test connection", a "Sync now"). These need **no permission**: an action is the plugin's own code, and it is run **for the user who clicked it**, so `ctx.settings.get()` returns that user's value and any trip read is membership-checked against them — the same gates as a route handler. Anything the action *does* (an outbound call, a trip write) still needs that capability's own permission. The host refuses any action key the manifest didn't -declare. See [[Plugin Development#settings-page-actions|Plugin-Development]]. +declare. + +A `scope: "instance"` action, which renders in **Admin → Plugins → ⋯ → Instance settings**, +is **admin-surfaced but not admin-privileged**: it runs as the clicking admin's own user +like any other action, so `ctx.settings.get()` returns *their* value and trip reads are +membership-checked against them — an admin who is not on a trip still cannot read it +through a plugin button. The scope is fixed by the route each button posts to, so a +user-scoped key can never be fired from the admin route, nor an instance-scoped one from a +user's settings page. See [[Plugin Development#settings-page-actions|Plugin-Development]]. ## Not a permission — inter-plugin calls & events diff --git a/wiki/Plugins.md b/wiki/Plugins.md index 893b36712..c45ac853e 100644 --- a/wiki/Plugins.md +++ b/wiki/Plugins.md @@ -48,10 +48,21 @@ Two guarantees hold for a plugin channel specifically: ### Plugin settings actions -A plugin can put **buttons on its own settings page** — a "Test connection", a "Sync now". -Users find them under **Settings → Plugins**, beneath that plugin's fields. An action runs -**as the user who clicked it**, so a "Test connection" checks *their* credentials and can -never see anyone else's. TREK refuses any action the plugin didn't declare in its manifest. +A plugin can put **buttons on its own settings form** — a "Test connection", a "Sync now", +a "Purge cache". Each button declares a `scope`, which decides where it lands and who runs it: + +- **`scope: "user"`** (the default) — the button sits under **Settings → Plugins**, beneath + that plugin's fields, and runs **as the user who clicked it**, so a "Test connection" + checks *their* credentials and can never see anyone else's. +- **`scope: "instance"`** — the button sits in **Admin → Plugins → ⋯ → Instance settings**, + next to the instance-wide fields it works on. It runs as the clicking **admin** — surfaced + in the admin panel, but not admin-privileged — is **disabled until the plugin is active** + ("Activate the plugin to run its actions"), and saves an edited form before it fires, so + the action sees the configuration you are looking at. + +TREK refuses any action the plugin didn't declare in its manifest, and the scope is fixed by +the route each button posts to: a user-tab action can never be fired from the admin dialog, +and an instance action never appears on anyone's settings page. ### Plugin MCP tools @@ -62,8 +73,9 @@ you consent to at install. Three guarantees hold: - **You see the surface before it exists.** Admin → Plugins shows exactly which tools a plugin will advertise (they appear to assistants as - `plugin__`), and any activate/deactivate/update/uninstall closes open - assistant sessions so the surface is re-read. + `plugin__`), and activating, deactivating, updating, re-trusting or + uninstalling a plugin — or turning off an addon it requires — closes open assistant + sessions so the surface is re-read. - **A tool call runs as the requesting user.** The host binds the user from the assistant's session — a plugin can never pick one — so every read is membership-checked exactly like the plugin's normal API calls, and the @@ -175,8 +187,14 @@ manifest (at the reviewed commit) and lays out: - **Connects to** — the outbound hosts it declared (`egress`). - **Setup** — configuration fields it will ask for, with scope (instance/user) and whether each is required. +- **AI tools it publishes** — for a plugin declaring `capabilities.mcpTools`: each tool's + name, title and the assistant-facing description, so you read what a connected assistant + will be told about the plugin *before* you grant it. - **Details** — version, download size, minimum TREK version, review date, plus links to the source repo and homepage. +- **Versions** — every published version (listed once a plugin has more than one) with its + date, a signature shield and a compatibility verdict, each with its own **Install v{X}** + button. A version this TREK can't run is explained ("needs TREK {range}"), never offered. A **Reviewed** badge means a TREK maintainer scanned that exact version's source for malware — **not** that it works well or is harmless. It is not an ongoing @@ -186,10 +204,14 @@ guarantee. Read the access list and outbound hosts, not just the description. Three ways, all from **Admin → Plugins**: -1. **From the registry.** In **Discover**, open a plugin and click **Install** - (also available directly on the card). TREK downloads the pinned version, verifies - its SHA-256 against the registry (and an author signature if the plugin ships one), - safely unpacks it, re-validates the manifest, and registers it — **inactive**. +1. **From the registry.** In **Discover**, open a plugin and click **Install** (also + available directly on the card), which takes the **newest version this TREK can run**. + To take a different one, use the per-version **Install v{X}** button in the detail + modal's **Versions** list — deliberately installing a version that isn't the newest + compatible one **pauses updates** for that plugin until you resume them (see + [Updating a plugin](#updating-a-plugin)). Either way TREK downloads that exact version, + verifies its SHA-256 against the registry (and an author signature if the plugin ships + one), safely unpacks it, re-validates the manifest, and registers it — **inactive**. Nothing runs yet. 2. **By upload (sideload).** Drag a plugin `.zip`/`.tar.gz` onto the panel, or use the **Upload** button. TREK extracts it into staging with the same hard guards as @@ -239,12 +261,20 @@ plugin can't reach one it didn't declare). See ## Managing a plugin -The **⋯** menu on each row: +The **⋯** menu on each row, in order: - **Restart** — stop and re-spawn the process (shown only while active). +- **Instance settings** — the admin-owned `scope: "instance"` fields, plus the plugin's + instance-scoped action buttons (shown only when it declares one or the other). Secret + fields stay masked, only fields the manifest declares are stored, and saving re-spawns a + running plugin so it picks the new configuration up — the child reads it once, at start. + See [Plugin settings actions](#plugin-settings-actions). - **View error log** — the plugin's own crash/failed-request log. - **Allowed hosts** — add the hosts a plugin may reach, for a plugin that talks to a - service only *you* can name. See [Allowed hosts](#allowed-hosts) below. + service only *you* can name (shown only for a plugin whose manifest declares + `operatorEgress`). See [Allowed hosts](#allowed-hosts) below. +- **Change version** — switch to any published version of the plugin (registry installs + only). See [Pinning and rolling back](#pinning-and-rolling-back). - **Source repository** — opens the plugin's GitHub repo (registry installs only). - **Report an issue** — opens that repo's issue tracker (registry installs only). - **Delete** — uninstalls, after a confirmation: it stops the plugin, removes its code, @@ -297,7 +327,12 @@ grant allows in the first place. ## Updating a plugin When the registry lists a newer version, an **Update to vX.Y.Z** pill appears on -the row, and an **Update all** bar summarises how many are available. +the row, and an **Update all** bar summarises how many are available — unless one of two +things is true. A newer version whose `trek` range this host can't satisfy shows an +informational **"v{version} available — needs TREK {range}"** line and deliberately never a +button: the fix is a TREK upgrade, not an install that would be refused. And a plugin whose +updates are held shows **"Updates paused at v{version}"** with a **Resume updates** link +instead (see below). Updating swaps in the new code and, by default, transparently restarts the plugin on it. But if the new version declares **more permissions or new outbound hosts**, @@ -306,6 +341,20 @@ TREK installs the new code and **leaves the plugin off**, then shows a only runs again once you approve — an update can never silently widen what a plugin may do. Choosing "Later" keeps the new code installed but inactive. +### Pinning and rolling back + +**⋯ → Change version** lists every published version of a registry-installed plugin with +its date, signature shield and compatibility verdict, and switches to the one you pick; an +incompatible version is explained, never offered. Switching **down** to an older version +asks for confirmation first — the plugin's stored data stays behind, and older code may not +understand it. + +A deliberate switch to a version that isn't the newest compatible one sets an **update +hold**, so a rollback you meant isn't nagged away by the update pill: the row shows +"Updates paused at v{version}" with a **Resume updates** link, and clicking it puts the +plugin back in the normal update flow. Landing on the newest compatible version again, by +any path, lifts the hold by itself. + ## Building your own Plugins are built with the **plugin SDK** and its `trek-plugin` CLI. The CLI