mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Feat/plugin instance actions (#2209)
* feat(shared): scope plugin action descriptors * feat(plugin-sdk): instance-scoped settings actions * feat(plugins): run instance-scoped actions from the admin settings dialog * feat(admin): instance-scoped plugin actions in the settings dialog * fix(plugins): surface instance actions without instance fields and harden the action gate * docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope * fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window * docs(wiki): plugin pages for instance actions and the admin panel changes
This commit is contained in:
@@ -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' },
|
||||
|
||||
@@ -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<string, unknown>): Promise<PluginInstanceConfigUpdated> =>
|
||||
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<PluginActionResult> =>
|
||||
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<string, unknown>) =>
|
||||
apiClient.post(`/plugin-settings/${id}`, { config }).then(r => r.data as { config: Record<string, unknown> }),
|
||||
// Host-brokered outbound OAuth (the host owns the tokens; the plugin only triggers).
|
||||
|
||||
@@ -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<string, unknown> | 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<string, unknown> = 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 })),
|
||||
|
||||
@@ -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) }} />
|
||||
))}
|
||||
</div>
|
||||
@@ -1103,11 +1107,50 @@ export default function AdminPluginsPanel() {
|
||||
{f.hint && <span className="block text-xs text-content-muted mt-1">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{(() => {
|
||||
const form = settings.form
|
||||
if (!form || form.actions.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-edge-secondary pt-4">
|
||||
<span className="block text-xs font-semibold uppercase tracking-wide text-content-muted mb-2">
|
||||
{t('admin.plugins.actions')}
|
||||
</span>
|
||||
{!form.active && (
|
||||
<p className="text-xs text-content-muted mb-2">{t('admin.plugins.actions.inactive')}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.actions.map(a => {
|
||||
const res = settings.actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<button type="button"
|
||||
onClick={() => settings.runAction(a)}
|
||||
disabled={!form.active || settings.runningAction !== null || settings.saving}
|
||||
className={`inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-semibold disabled:opacity-60 ${
|
||||
a.danger ? 'border-danger text-danger' : 'border-edge text-content'
|
||||
}`}
|
||||
>
|
||||
{settings.runningAction === a.key && <Loader2 className="w-4 h-4 animate-spin" />}
|
||||
{a.label}
|
||||
</button>
|
||||
{a.hint && <span className="text-xs text-content-muted">{a.hint}</span>}
|
||||
{res && (
|
||||
<span aria-live="polite" className={`text-xs font-medium ${res.ok ? 'text-success' : 'text-danger'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{settings.error && <p className="text-xs text-danger">{settings.error}</p>}
|
||||
</div>
|
||||
<div className="px-5 py-3.5 border-t border-edge-secondary flex justify-end">
|
||||
<button type="button"
|
||||
disabled={settings.saving}
|
||||
disabled={settings.saving || settings.runningAction !== null}
|
||||
onClick={() => void settings.save()}
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm font-semibold text-accent-text disabled:opacity-60"
|
||||
>{t('common.save')}</button>
|
||||
@@ -1116,6 +1159,15 @@ export default function AdminPluginsPanel() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={settings.pendingAction !== null}
|
||||
onClose={settings.cancelPendingAction}
|
||||
onConfirm={settings.confirmPendingAction}
|
||||
title={settings.pendingAction?.label ?? ''}
|
||||
message={t('admin.plugins.actions.confirm')}
|
||||
confirmLabel={t('common.confirm')}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!confirmUninstall}
|
||||
onClose={() => setConfirmUninstall(null)}
|
||||
@@ -1402,9 +1454,9 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn
|
||||
{p.enabled === 1 && (
|
||||
<MenuItem icon={<RotateCw size={14} />} 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) && (
|
||||
<MenuItem icon={<SlidersHorizontal size={14} />} label={t('admin.plugins.instanceSettings')} onClick={onSettings} />
|
||||
)}
|
||||
<MenuItem icon={<Bug size={14} />} label={t('admin.plugins.viewErrors')} onClick={onErrors} />
|
||||
|
||||
@@ -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<T>() {
|
||||
let resolve!: (v: T) => void
|
||||
const promise = new Promise<T>(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<string, unknown>; 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()
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | boolean>
|
||||
/** 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<InstanceSettingsForm | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [actionResult, setActionResult] = useState<Record<string, PluginActionResult>>({})
|
||||
const [runningAction, setRunningAction] = useState<string | null>(null)
|
||||
const [pendingAction, setPendingAction] = useState<PluginAction | null>(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<boolean> => {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(<PluginSettingsTab />);
|
||||
|
||||
@@ -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(<PluginSettingsTab />);
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 && <span className="mt-1 block font-geist text-[0.625rem] text-m-faint">{f.hint}</span>}
|
||||
</label>
|
||||
))}
|
||||
{(() => {
|
||||
const form = settings.form
|
||||
if (!form || form.actions.length === 0) return null
|
||||
return (
|
||||
<div className="border-t border-[color:var(--m-rowbr)] pt-4">
|
||||
<span className="mb-2 block font-geist text-[0.625rem] font-bold uppercase tracking-wide text-m-faint">
|
||||
{t('admin.plugins.actions')}
|
||||
</span>
|
||||
{!form.active && (
|
||||
<p className="mb-2 font-geist text-[0.625rem] text-m-faint">{t('admin.plugins.actions.inactive')}</p>
|
||||
)}
|
||||
<div className="flex flex-col gap-2">
|
||||
{form.actions.map(a => {
|
||||
const res = settings.actionResult[a.key]
|
||||
return (
|
||||
<div key={a.key} className="flex flex-wrap items-center gap-2">
|
||||
<MAdminButton
|
||||
variant={a.danger ? 'danger' : 'ghost'}
|
||||
busy={settings.runningAction === a.key}
|
||||
disabled={!form.active || settings.runningAction !== null || settings.saving}
|
||||
onClick={() => settings.runAction(a)}
|
||||
>
|
||||
{a.label}
|
||||
</MAdminButton>
|
||||
{a.hint && <span className="font-geist text-[0.625rem] text-m-faint">{a.hint}</span>}
|
||||
{res && (
|
||||
<span aria-live="polite" className={`text-[0.625rem] font-bold ${res.ok ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}>
|
||||
{res.message || (res.ok ? t('common.success') : t('common.error'))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{settings.error && <p className="text-[0.6875rem] text-[color:var(--m-st-danger)]">{settings.error}</p>}
|
||||
<MAdminButton disabled={settings.saving} onClick={() => void settings.save()} className="h-[42px] w-full">
|
||||
<MAdminButton disabled={settings.saving || settings.runningAction !== null} onClick={() => void settings.save()} className="h-[42px] w-full">
|
||||
{t('common.save')}
|
||||
</MAdminButton>
|
||||
</div>
|
||||
@@ -1151,6 +1191,18 @@ export default function MAdminPluginsPanel() {
|
||||
danger
|
||||
/>
|
||||
|
||||
{/* Instance action confirm */}
|
||||
<MConfirmSheet
|
||||
open={settings.pendingAction !== null}
|
||||
onClose={settings.cancelPendingAction}
|
||||
onConfirm={settings.confirmPendingAction}
|
||||
title={settings.pendingAction?.label ?? ''}
|
||||
message={t('admin.plugins.actions.confirm')}
|
||||
confirmLabel={settings.pendingAction?.label}
|
||||
cancelLabel={t('common.cancel')}
|
||||
danger
|
||||
/>
|
||||
|
||||
{signatureBlock && (
|
||||
<SignatureBlockSheet
|
||||
data={signatureBlock} entry={regById[signatureBlock.subject.id]} busy={retrusting} t={t}
|
||||
@@ -1409,9 +1461,9 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
|
||||
{p.enabled === 1 && (
|
||||
<button type="button" className={rowClass} onClick={onRestart}><RotateCw size={16} /> {t('admin.plugins.restart')}</button>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<button type="button" className={rowClass} onClick={onSettings}><SlidersHorizontal size={16} /> {t('admin.plugins.instanceSettings')}</button>
|
||||
)}
|
||||
<button type="button" className={rowClass} onClick={onErrors}><Bug size={16} /> {t('admin.plugins.viewErrors')}</button>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,8 +43,8 @@ const CONFIG: Record<string, unknown> = {
|
||||
};
|
||||
|
||||
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 })),
|
||||
|
||||
@@ -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: { <key>: 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.
|
||||
|
||||
@@ -813,13 +813,15 @@ export interface PluginDefinition {
|
||||
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | 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<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | PluginActionResult | void>;
|
||||
hooks?: {
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string | { key: string; scope?: 'user' | 'instance' }>;
|
||||
/** 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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -4226,6 +4226,16 @@ function runMigrations(db: Database.Database): void {
|
||||
db.exec('ALTER TABLE plugin_settings_fields ADD COLUMN default_value TEXT');
|
||||
}
|
||||
},
|
||||
|
||||
// Settings-form actions gain a scope (#plugins): 'user' renders on the user Settings
|
||||
// tab, 'instance' in the admin instance-settings dialog. Existing rows predate the
|
||||
// column and were all user-tab buttons, so the default keeps them where they were.
|
||||
() => {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>;
|
||||
if (cols.length > 0 && !cols.some((c) => c.name === 'scope')) {
|
||||
db.exec("ALTER TABLE plugin_actions ADD COLUMN scope TEXT NOT NULL DEFAULT 'user'");
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
if (currentVersion < migrations.length) {
|
||||
|
||||
@@ -81,9 +81,9 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
|
||||
// Refresh the settings-page action descriptors from the manifest.
|
||||
db.prepare('DELETE FROM plugin_actions WHERE plugin_id = ?').run(m.id);
|
||||
const insertAction = db.prepare(
|
||||
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
);
|
||||
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, i));
|
||||
m.actions.forEach((a, i) => insertAction.run(m.id, a.key, a.label, a.hint ?? null, a.danger ? 1 : 0, a.scope, i));
|
||||
|
||||
// Refresh the settings-field descriptors from the manifest.
|
||||
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface ManifestAction {
|
||||
hint?: string;
|
||||
/** Render as destructive and ask for confirmation before running. */
|
||||
danger?: boolean;
|
||||
/** Which settings form renders the button. `'user'` (default): the user Settings tab,
|
||||
* run as the clicking user. `'instance'`: the admin instance-settings dialog, run as
|
||||
* the clicking admin. The default is `'user'` (not `'instance'` like settings fields)
|
||||
* so every manifest written before scope existed keeps its user-tab buttons. */
|
||||
scope: 'user' | 'instance';
|
||||
}
|
||||
|
||||
/** A routing profile a routeProvider plugin offers — one entry in the planner's
|
||||
@@ -611,15 +616,24 @@ function parseActions(raw: unknown): ManifestAction[] {
|
||||
const out: ManifestAction[] = [];
|
||||
for (const a of raw) {
|
||||
if (!a || typeof a !== 'object') throw new ManifestError('each action must be an object');
|
||||
const { key, label, hint, danger } = a as Record<string, unknown>;
|
||||
const { key, label, hint, danger, scope } = a as Record<string, unknown>;
|
||||
const k = assertSettingKey(String(key ?? ''));
|
||||
if (!k) throw new ManifestError('action must have a "key"');
|
||||
if (out.some((x) => x.key === k)) throw new ManifestError(`duplicate action "${k}"`);
|
||||
if (scope !== undefined && scope !== 'user' && scope !== 'instance') {
|
||||
throw new ManifestError(`action "${k}".scope must be "user" or "instance"`);
|
||||
}
|
||||
// `label ?? k` only defaults on null/undefined — an explicit `label: ''` would pass
|
||||
// straight through and reach the client as an empty button/confirm label (which
|
||||
// MConfirmSheet then refuses to render a confirm button for). Fall back to the key
|
||||
// whenever the label is empty after trimming.
|
||||
const rawLabel = label == null ? '' : String(label);
|
||||
out.push({
|
||||
key: k,
|
||||
label: String(label ?? k).slice(0, 60),
|
||||
label: (rawLabel.trim() === '' ? k : rawLabel).slice(0, 60),
|
||||
hint: hint === undefined ? undefined : String(hint).slice(0, 200),
|
||||
danger: danger === true,
|
||||
scope: scope === 'instance' ? 'instance' : 'user',
|
||||
});
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -35,6 +35,7 @@ import type { VersionMismatch, PluginDepRow } from './dependencies';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, enableOrder, findDependentsTransitive, DependencyCycleError } from './dependencies';
|
||||
|
||||
import { HTTP_OUTBOUND_PREFIX as HTTP_OUTBOUND, PLUGIN_API_VERSION } from './protocol/envelope';
|
||||
import type { PluginActionDescriptor, PluginActionResult, PluginActionScope } from '@trek/shared';
|
||||
|
||||
// Mirrors HOST_RE in install/manifest.ts: an exact hostname or a `*.`-prefixed wildcard
|
||||
// with a real multi-label suffix. Rejects a bare `*`, a whole-TLD wildcard, a scheme and
|
||||
@@ -1124,27 +1125,29 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
});
|
||||
}
|
||||
|
||||
/** The settings-page action buttons a plugin declared (descriptors, from the DB). */
|
||||
actionsOf(id: string): Array<{ key: string; label: string; hint?: string; danger: boolean }> {
|
||||
/** The settings-form action buttons a plugin declared for ONE scope (descriptors, from the DB). */
|
||||
actionsOf(id: string, scope: PluginActionScope): PluginActionDescriptor[] {
|
||||
try {
|
||||
return (
|
||||
this.db.prepare('SELECT action_key, label, hint, danger FROM plugin_actions WHERE plugin_id = ? ORDER BY sort_order').all(id) as Array<{
|
||||
action_key: string; label: string; hint: string | null; danger: number;
|
||||
}>
|
||||
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1 }));
|
||||
this.db
|
||||
.prepare('SELECT action_key, label, hint, danger, scope FROM plugin_actions WHERE plugin_id = ? AND scope = ? ORDER BY sort_order')
|
||||
.all(id, scope) as Array<{ action_key: string; label: string; hint: string | null; danger: number; scope: PluginActionScope }>
|
||||
).map((r) => ({ key: r.action_key, label: r.label, hint: r.hint ?? undefined, danger: r.danger === 1, scope: r.scope }));
|
||||
} catch {
|
||||
return []; // table absent (a slimmed test app)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a settings-page action for the user who clicked it. The acting user is bound
|
||||
* host-side (never named by the plugin), so the action reads THAT user's settings and
|
||||
* any trip read it makes is membership-checked against them.
|
||||
* Run a settings-form action for the person who clicked it. The acting user is bound
|
||||
* host-side (never named by the plugin): a user for a `scope:'user'` button, an admin
|
||||
* for a `scope:'instance'` one. Either way the action reads THAT person's settings and
|
||||
* any trip read it makes is membership-checked against them. The scope is the
|
||||
* CALLER's route, so a user route can never fire an admin button, nor the reverse.
|
||||
*/
|
||||
async invokeAction(id: string, key: string, actingUserId: number): Promise<{ ok: boolean; message?: string }> {
|
||||
if (!this.actionsOf(id).some((a) => a.key === key)) {
|
||||
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}"`);
|
||||
async invokeAction(id: string, key: string, actingUserId: number, scope: PluginActionScope): Promise<PluginActionResult> {
|
||||
if (!this.actionsOf(id, scope).some((a) => a.key === key)) {
|
||||
throw new ForbiddenResource(`plugin ${id} did not declare action "${key}" in scope ${scope}`);
|
||||
}
|
||||
const cap = (v: unknown) => stripEmoji(String(v)).slice(0, 200);
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PluginsService, MissingRequiredSettingError } from './plugins.service';
|
||||
import { PluginRuntimeService } from './plugin-runtime.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import { PluginUserSettingsUpdateDto } from './plugins.dto';
|
||||
import type { PluginActionDescriptor, PluginActionResult } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* GET/POST /api/plugin-settings/:id — a USER's own `scope:'user'` settings for a
|
||||
@@ -36,14 +37,14 @@ export class PluginUserSettingsController {
|
||||
get(@Param('id') id: string, @Req() req: Request & { user?: { id: number } }): {
|
||||
fields: unknown[];
|
||||
config: Record<string, unknown>;
|
||||
actions: Array<{ key: string; label: string; hint?: string; danger: boolean }>;
|
||||
actions: PluginActionDescriptor[];
|
||||
} {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { fields: [], config: {}, actions: [] };
|
||||
return {
|
||||
fields: this.plugins.userSettingsFields(id),
|
||||
config: this.plugins.getUserConfig(id, userId),
|
||||
actions: this.runtime.actionsOf(id),
|
||||
actions: this.runtime.actionsOf(id, 'user'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -59,13 +60,13 @@ export class PluginUserSettingsController {
|
||||
@Param('id') id: string,
|
||||
@Param('key') key: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<{ ok: boolean; message?: string }> {
|
||||
): Promise<PluginActionResult> {
|
||||
const userId = req.user?.id;
|
||||
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) {
|
||||
throw new HttpException({ error: 'Plugin is not active' }, 404);
|
||||
}
|
||||
try {
|
||||
return await this.runtime.invokeAction(id, key, userId);
|
||||
return await this.runtime.invokeAction(id, key, userId, 'user');
|
||||
} catch (e) {
|
||||
// A failing action is a RESULT, not a server error — show the user why.
|
||||
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
|
||||
|
||||
@@ -13,7 +13,7 @@ import { pluginsEnabled } from './kill-switch';
|
||||
import { devLinkEnabled } from './dev-link';
|
||||
import { PluginActivateDto, PluginConfigDto, PluginEgressHostsDto, PluginInstallDto, PluginLinkDto, PluginRetrustDto, PluginUninstallDto, PluginUpdateDto } from './plugins.dto';
|
||||
import { ManagedForbidden, isManagedBlocked, MANAGED_FORBIDDEN_ERROR } from '../common/managed';
|
||||
import type { PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
|
||||
import type { PluginActionResult, PluginInstanceConfigResponse, PluginInstanceConfigUpdated } from '@trek/shared';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
// Straight from sessionManager, not the src/mcp barrel: that one evaluates
|
||||
// readEnv().mcp at module scope and installs the sweep interval, which a domain
|
||||
@@ -141,7 +141,11 @@ export class PluginsController {
|
||||
|
||||
@Get(':id/config')
|
||||
getConfig(@Param('id') id: string): PluginInstanceConfigResponse {
|
||||
return { fields: this.plugins.instanceSettingsFields(id), config: this.plugins.getInstanceConfig(id) };
|
||||
return {
|
||||
fields: this.plugins.instanceSettingsFields(id),
|
||||
config: this.plugins.getInstanceConfig(id),
|
||||
actions: this.runtime.actionsOf(id, 'instance'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -181,6 +185,41 @@ export class PluginsController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the plugin's `scope:'instance'` actions ("Purge cache", "Test SMTP").
|
||||
* ADMIN-INITIATED: the acting user is the clicking admin, bound host-side, so the
|
||||
* handler sees `ctx.config` plus the admin's own settings and any trip read is checked
|
||||
* against them. The scope is fixed by this route — a user-tab key is refused here.
|
||||
*
|
||||
* Returns the SAME 404 `{ error: 'Plugin is not active' }` body as
|
||||
* PluginUserSettingsController.runAction, and a failing action is a RESULT, not a
|
||||
* server error, on both — but the two routes reach 404 by checking DIFFERENT things.
|
||||
* This route asks `runtime.isActive(id)`, the supervisor's live-process check: is
|
||||
* there actually a forked child to run the action in. The user route instead reads
|
||||
* the plugin's DB `status` column. They can disagree — a plugin can be `status =
|
||||
* 'active'` in the DB with no live child (e.g. it crashed and hasn't been
|
||||
* respawned yet) — so a 404 here does not always mean the row says inactive; it can
|
||||
* also mean a stale-active row with nothing behind it. The client (`useInstanceSettings`)
|
||||
* treats this 404 as authoritative and flips its own `active` flag to match.
|
||||
*/
|
||||
@Post(':id/actions/:key')
|
||||
@HttpCode(200)
|
||||
async runAction(
|
||||
@Param('id') id: string,
|
||||
@Param('key') key: string,
|
||||
@Req() req: Request & { user?: { id: number } },
|
||||
): Promise<PluginActionResult> {
|
||||
const adminId = req.user?.id;
|
||||
if (!pluginsEnabled() || adminId == null || !this.runtime.isActive(id)) {
|
||||
throw new HttpException({ error: 'Plugin is not active' }, 404);
|
||||
}
|
||||
try {
|
||||
return await this.runtime.invokeAction(id, key, adminId, 'instance');
|
||||
} catch (e) {
|
||||
return { ok: false, message: (e instanceof Error ? e.message : 'Action failed').slice(0, 200) };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Operator-supplied egress hosts. A plugin that talks to a SELF-HOSTED service can't
|
||||
* name the operator's hostname in its manifest, so an admin adds it here — and the
|
||||
|
||||
@@ -79,6 +79,9 @@ export interface PluginListItem {
|
||||
/** How many `scope:'instance'` settings fields the plugin declares — gates the admin
|
||||
* settings menu item without a per-plugin fetch. */
|
||||
instanceSettingsCount: number;
|
||||
/** How many `scope:'instance'` actions the plugin declares — gates the admin settings
|
||||
* menu item together with instanceSettingsCount (a plugin can have actions and no fields). */
|
||||
instanceActionsCount: number;
|
||||
/** Declared dependencies (parsed) — required addons + plugin deps. */
|
||||
dependencies: PluginDependencies;
|
||||
/** Whether this plugin can currently activate, and why not if it can't. */
|
||||
@@ -136,6 +139,16 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
|
||||
private instanceActionsCount(id: string): number {
|
||||
try {
|
||||
return (
|
||||
this.db.prepare("SELECT COUNT(*) AS n FROM plugin_actions WHERE plugin_id = ? AND scope = 'instance'").get(id) as { n: number }
|
||||
).n;
|
||||
} catch {
|
||||
return 0; // table absent (a slimmed test app)
|
||||
}
|
||||
}
|
||||
|
||||
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
@@ -178,6 +191,7 @@ export class PluginsService {
|
||||
operatorEgress: _oe === 1,
|
||||
egressHostCount: this.egressHostCount(r.id),
|
||||
instanceSettingsCount: this.instanceSettingsCount(r.id),
|
||||
instanceActionsCount: this.instanceActionsCount(r.id),
|
||||
dependencies: deps,
|
||||
dependencyStatus,
|
||||
trekRange: trek_range,
|
||||
|
||||
@@ -705,13 +705,15 @@ export interface PluginDefinition {
|
||||
* JSON-serialisable value the host aggregates. Userless. Needs `hook:user-data`. */
|
||||
exportUserData?(input: { userId: number }, ctx: PluginContext): Promise<unknown> | unknown;
|
||||
/**
|
||||
* Buttons on the plugin's own settings page ("Test connection", "Sync now"). The key
|
||||
* must match an entry in the manifest's `actions`.
|
||||
* Buttons on the plugin's settings forms ("Test connection", "Sync now", "Purge
|
||||
* cache"). The key must match an entry in the manifest's `actions`; that entry's
|
||||
* `scope` decides WHERE the button renders — `'user'` (default) on the user Settings
|
||||
* tab, `'instance'` in the admin instance-settings dialog.
|
||||
*
|
||||
* USER-INITIATED, so unlike the notificationChannel hook there IS an acting user — the
|
||||
* person who clicked. `ctx.settings.get()` returns THEIR value and trip reads are
|
||||
* membership-checked against them, which is what makes a "test my credentials" button
|
||||
* possible at all.
|
||||
* USER-INITIATED either way, so unlike the notificationChannel hook there IS an acting
|
||||
* user — the person who clicked (a user, or an admin for an instance action).
|
||||
* `ctx.settings.get()` returns THEIR value, `ctx.config` is the instance config, and
|
||||
* trip reads are membership-checked against them.
|
||||
*/
|
||||
actions?: Record<string, (ctx: PluginContext) => Promise<PluginActionResult | void> | PluginActionResult | void>;
|
||||
events?: PluginEventSubscription[];
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -330,11 +330,21 @@ describe('settings-page actions', () => {
|
||||
it('parses actions with label/hint/danger', () => {
|
||||
const m = parseManifest({ ...base, actions: [{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.' }, { key: 'purge', label: 'Purge', danger: true }] });
|
||||
expect(m.actions).toEqual([
|
||||
{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false },
|
||||
{ key: 'purge', label: 'Purge', hint: undefined, danger: true },
|
||||
{ key: 'testConnection', label: 'Test connection', hint: 'Pings the API.', danger: false, scope: 'user' },
|
||||
{ key: 'purge', label: 'Purge', hint: undefined, danger: true, scope: 'user' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults an action to the user scope, accepts instance, refuses anything else', () => {
|
||||
const m = parseManifest({ ...base, actions: [{ key: 'ping' }, { key: 'purge', scope: 'instance' }, { key: 'me', scope: 'user' }] });
|
||||
expect(m.actions.map((a) => a.scope)).toEqual(['user', 'instance', 'user']);
|
||||
expect(() => parseManifest({ ...base, actions: [{ key: 'x', scope: 'global' }] })).toThrow(/scope must be "user" or "instance"/);
|
||||
// one key, one form — a duplicate across scopes is still a duplicate
|
||||
expect(() => parseManifest({ ...base, actions: [{ key: 'a', scope: 'user' }, { key: 'a', scope: 'instance' }] })).toThrow(/duplicate action/);
|
||||
// the cap counts both scopes
|
||||
expect(() => parseManifest({ ...base, actions: Array.from({ length: 9 }, (_, i) => ({ key: `a${i}`, scope: i % 2 ? 'instance' : 'user' })) })).toThrow(/at most 8/);
|
||||
});
|
||||
|
||||
it('defaults the label to the key, and bounds label/hint', () => {
|
||||
const m = parseManifest({ ...base, actions: [{ key: 'sync', label: 'L'.repeat(200), hint: 'H'.repeat(500) }] });
|
||||
expect(m.actions[0].label.length).toBe(60);
|
||||
@@ -342,6 +352,11 @@ describe('settings-page actions', () => {
|
||||
expect(parseManifest({ ...base, actions: [{ key: 'sync' }] }).actions[0].label).toBe('sync');
|
||||
});
|
||||
|
||||
it('falls back to the key when label is an empty (or whitespace-only) string, not just null/undefined', () => {
|
||||
expect(parseManifest({ ...base, actions: [{ key: 'sync', label: '' }] }).actions[0].label).toBe('sync');
|
||||
expect(parseManifest({ ...base, actions: [{ key: 'sync', label: ' ' }] }).actions[0].label).toBe('sync');
|
||||
});
|
||||
|
||||
it('rejects a prototype-chain key, a duplicate, a non-array and too many', () => {
|
||||
expect(() => parseManifest({ ...base, actions: [{ key: '__proto__' }] })).toThrow(ManifestError);
|
||||
expect(() => parseManifest({ ...base, actions: [{ key: 'a' }, { key: 'a' }] })).toThrow(/duplicate action/);
|
||||
|
||||
@@ -127,15 +127,17 @@ describe('operator-supplied egress hosts', () => {
|
||||
});
|
||||
|
||||
describe('settings-page actions (runtime)', () => {
|
||||
function declareAction(id: string, key: string) {
|
||||
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES (?, ?, ?, NULL, 0, 0)')
|
||||
.run(id, key, key);
|
||||
function declareAction(id: string, key: string, scope: 'user' | 'instance' = 'user') {
|
||||
testDb.prepare('INSERT OR REPLACE INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
|
||||
.run(id, key, key, scope);
|
||||
}
|
||||
|
||||
it('ACT-001 — actionsOf returns the declared descriptors', () => {
|
||||
it('ACT-001 — actionsOf returns the declared descriptors of ONE scope', () => {
|
||||
install('p', false);
|
||||
declareAction('p', 'testConnection');
|
||||
expect(rt.actionsOf('p')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false }]);
|
||||
declareAction('p', 'purge', 'instance');
|
||||
expect(rt.actionsOf('p', 'user')).toEqual([{ key: 'testConnection', label: 'testConnection', hint: undefined, danger: false, scope: 'user' }]);
|
||||
expect(rt.actionsOf('p', 'instance')).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
|
||||
});
|
||||
|
||||
it('ACT-002 — invoking an action the plugin never declared is REFUSED', async () => {
|
||||
@@ -143,13 +145,21 @@ describe('settings-page actions (runtime)', () => {
|
||||
declareAction('p', 'testConnection');
|
||||
// The key is caller-supplied (it comes off the URL), so the host must check it
|
||||
// against the manifest rather than forwarding whatever it is handed to the child.
|
||||
await expect(rt.invokeAction('p', 'somethingElse', 1)).rejects.toThrow(/did not declare action/);
|
||||
await expect(rt.invokeAction('p', '__proto__', 1)).rejects.toThrow(/did not declare action/);
|
||||
await expect(rt.invokeAction('p', 'somethingElse', 1, 'user')).rejects.toThrow(/did not declare action/);
|
||||
await expect(rt.invokeAction('p', '__proto__', 1, 'user')).rejects.toThrow(/did not declare action/);
|
||||
});
|
||||
|
||||
it('ACT-003 — a plugin with no actions can never be invoked', async () => {
|
||||
install('p', false);
|
||||
await expect(rt.invokeAction('p', 'testConnection', 1)).rejects.toThrow(/did not declare action/);
|
||||
await expect(rt.invokeAction('p', 'testConnection', 1, 'user')).rejects.toThrow(/did not declare action/);
|
||||
});
|
||||
|
||||
it('ACT-004 — a key declared in the OTHER scope is refused (the user route cannot fire an admin button)', async () => {
|
||||
install('p', false);
|
||||
declareAction('p', 'purge', 'instance');
|
||||
declareAction('p', 'testConnection', 'user');
|
||||
await expect(rt.invokeAction('p', 'purge', 1, 'user')).rejects.toThrow(/did not declare action "purge" in scope user/);
|
||||
await expect(rt.invokeAction('p', 'testConnection', 1, 'instance')).rejects.toThrow(/did not declare action "testConnection" in scope instance/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { HttpException } from '@nestjs/common';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Request } from 'express';
|
||||
|
||||
const { testDb, dbMock } = vi.hoisted(() => {
|
||||
const Database = require('better-sqlite3');
|
||||
@@ -120,6 +121,22 @@ describe('instance settings fields', () => {
|
||||
expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 });
|
||||
});
|
||||
|
||||
it('INS-004 — the admin list carries the instance-action count (gates the menu item even with no settings fields)', () => {
|
||||
install('with-action');
|
||||
install('plain');
|
||||
testDb
|
||||
.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
|
||||
.run('with-action', 'purge', 'Purge', 'instance');
|
||||
testDb
|
||||
.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
|
||||
.run('with-action', 'notify', 'Notify', 'user'); // user-scope actions must not count
|
||||
|
||||
const plugins = svc().list().plugins;
|
||||
expect(plugins.find((p) => p.id === 'with-action')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 1 });
|
||||
expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0, instanceActionsCount: 0 });
|
||||
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id = 'with-action'").run();
|
||||
});
|
||||
|
||||
it('INS-010 — persists a settings-field default and serves it on the fields list', () => {
|
||||
installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] });
|
||||
const fields = svc().instanceSettingsFields('fixture-id');
|
||||
@@ -227,7 +244,7 @@ describe('admin config endpoints (controller)', () => {
|
||||
declareField('p', 'apiUrl', 'instance');
|
||||
testDb.prepare("UPDATE plugins SET config = '{\"apiUrl\":\"https://x.example\"}' WHERE id = 'p'").run();
|
||||
|
||||
const out = controllerWith({}).getConfig('p');
|
||||
const out = controllerWith({ actionsOf: () => [] }).getConfig('p');
|
||||
expect(out.config).toEqual({ apiUrl: 'https://x.example' });
|
||||
expect(out.fields.map((f: Record<string, unknown>) => f.key)).toEqual(['apiUrl']);
|
||||
});
|
||||
@@ -328,3 +345,63 @@ describe('defaults reach the child at spawn', () => {
|
||||
expect(config).toEqual({ api_url: 'https://mine.example', retries: 3 }); // note: no default, no value → absent
|
||||
});
|
||||
});
|
||||
|
||||
describe('plugin_actions.scope migration', () => {
|
||||
it('MIG-ACT-001 — the column exists on a migrated DB and defaults to user', () => {
|
||||
const cols = testDb.prepare("SELECT name FROM pragma_table_info('plugin_actions')").all() as Array<{ name: string }>;
|
||||
expect(cols.some((c) => c.name === 'scope')).toBe(true);
|
||||
testDb.prepare("INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, sort_order) VALUES ('m', 'k', 'K', NULL, 0, 0)").run();
|
||||
expect((testDb.prepare("SELECT scope FROM plugin_actions WHERE plugin_id='m'").get() as { scope: string }).scope).toBe('user');
|
||||
testDb.prepare("DELETE FROM plugin_actions WHERE plugin_id='m'").run();
|
||||
});
|
||||
});
|
||||
|
||||
describe('instance-scope actions (admin)', () => {
|
||||
function declareAction(pluginId: string, key: string, scope: 'user' | 'instance') {
|
||||
testDb.prepare('INSERT INTO plugin_actions (plugin_id, action_key, label, hint, danger, scope, sort_order) VALUES (?, ?, ?, NULL, 0, ?, 0)')
|
||||
.run(pluginId, key, key, scope);
|
||||
}
|
||||
const adminReq = { user: { id: 42 } } as unknown as Request;
|
||||
function controller(invoke = vi.fn(async () => ({ ok: true, message: 'pong' }))) {
|
||||
const rt = createPluginRuntime(new DatabaseService(dbConn));
|
||||
// isActive normally reflects the supervisor's live child map, which nothing here
|
||||
// spawns — so it's stubbed to read the same DB status the test itself flips,
|
||||
// mirroring what an actually-activated plugin would report.
|
||||
const isActive = (id: string) => !!testDb.prepare("SELECT 1 FROM plugins WHERE id = ? AND status = 'active'").get(id);
|
||||
const runtime = Object.assign(rt, { invokeAction: invoke, isActive }) as unknown as PluginRuntimeService;
|
||||
const c = new PluginsController(svc(), runtime, {} as unknown as PluginRegistryService, { isManaged: () => false } as unknown as RuntimeEnvService);
|
||||
return { c, invoke };
|
||||
}
|
||||
|
||||
beforeEach(() => { testDb.prepare('DELETE FROM plugin_actions').run(); });
|
||||
|
||||
it('ACT-ADM-001 — GET config lists the instance actions and none of the user ones', () => {
|
||||
install('p');
|
||||
declareAction('p', 'purge', 'instance');
|
||||
declareAction('p', 'testConnection', 'user');
|
||||
const { c } = controller();
|
||||
expect(c.getConfig('p').actions).toEqual([{ key: 'purge', label: 'purge', hint: undefined, danger: false, scope: 'instance' }]);
|
||||
});
|
||||
|
||||
it('ACT-ADM-002 — POST runs the action as the clicking admin in the instance scope', async () => {
|
||||
install('p');
|
||||
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
|
||||
const { c, invoke } = controller();
|
||||
expect(await c.runAction('p', 'purge', adminReq)).toEqual({ ok: true, message: 'pong' });
|
||||
expect(invoke).toHaveBeenCalledWith('p', 'purge', 42, 'instance');
|
||||
});
|
||||
|
||||
it('ACT-ADM-003 — an inactive plugin answers 404 like the user route', async () => {
|
||||
install('p');
|
||||
const { c, invoke } = controller();
|
||||
await expect(c.runAction('p', 'purge', adminReq)).rejects.toMatchObject({ status: 404, response: { error: 'Plugin is not active' } });
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ACT-ADM-004 — a refused key is a failed RESULT, not a server error', async () => {
|
||||
install('p');
|
||||
testDb.prepare("UPDATE plugins SET status = 'active', enabled = 1 WHERE id = 'p'").run();
|
||||
const { c } = controller(vi.fn(async () => { throw new Error('plugin p did not declare action "x" in scope instance'); }));
|
||||
expect(await c.runAction('p', 'x', adminReq)).toEqual({ ok: false, message: 'plugin p did not declare action "x" in scope instance' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,7 +27,7 @@ function ctrl() {
|
||||
updateUserConfig: vi.fn((_id: string, _uid: number, patch: Record<string, unknown>) => ({ ...patch, apiKey: '••••••••' })),
|
||||
} as unknown as PluginsService;
|
||||
// The controller now also takes the runtime (for settings-page actions).
|
||||
const runtime = { actionsOf: () => [], invokeAction: vi.fn() } as unknown as PluginRuntimeService;
|
||||
const runtime = { actionsOf: vi.fn(() => []), invokeAction: vi.fn(async () => ({ ok: true })) } as unknown as PluginRuntimeService;
|
||||
return { c: new PluginUserSettingsController(svc, runtime, new DatabaseService(dbConn)), svc, runtime };
|
||||
}
|
||||
|
||||
@@ -55,4 +55,12 @@ describe('PluginUserSettingsController', () => {
|
||||
getMock.mockReturnValue(undefined as never);
|
||||
expect(c.update('p', { config: { units: 'metric' } }, req(5))).toEqual({ config: {} });
|
||||
});
|
||||
|
||||
it('GET lists USER-scope actions only, and POST runs one as the caller in the user scope', async () => {
|
||||
const { c, runtime } = ctrl();
|
||||
c.get('p', req(5));
|
||||
expect(runtime.actionsOf).toHaveBeenCalledWith('p', 'user');
|
||||
expect(await c.runAction('p', 'sync', req(5))).toEqual({ ok: true });
|
||||
expect(runtime.invokeAction).toHaveBeenCalledWith('p', 'sync', 5, 'user');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,9 +236,9 @@ describe('PluginsController M2 endpoints', () => {
|
||||
});
|
||||
|
||||
it('get/update config delegate to the service (get carries the form fields, update the restart)', async () => {
|
||||
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false) } as never;
|
||||
const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn(), respawnIfActive: vi.fn(async () => false), actionsOf: vi.fn(() => []) } as never;
|
||||
const c = new PluginsController(svc, rt, {} as never, envStub);
|
||||
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 } });
|
||||
expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 }, actions: [] });
|
||||
expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false });
|
||||
});
|
||||
|
||||
|
||||
@@ -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': 'يقرأ الملفات الشخصية الأساسية',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Διαβάζει βασικά προφίλ',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '基本プロフィールを読み取り',
|
||||
|
||||
@@ -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': '기본 프로필 읽기',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Читает основные профили',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Читає базові профілі',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '讀取基本個人資料',
|
||||
|
||||
@@ -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': '读取基本个人资料',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,11 +31,37 @@ export const pluginSettingsFieldSchema = z.object({
|
||||
});
|
||||
export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof pluginActionScopeSchema>;
|
||||
|
||||
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<typeof pluginActionDescriptorSchema>;
|
||||
|
||||
/** 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<typeof pluginActionResultSchema>;
|
||||
|
||||
/** 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<typeof pluginInstanceConfigResponseSchema>;
|
||||
|
||||
|
||||
+18
-1
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+60
-11
@@ -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 "<key>".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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+62
-13
@@ -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_<id>_<name>`), and any activate/deactivate/update/uninstall closes open
|
||||
assistant sessions so the surface is re-read.
|
||||
`plugin_<id>_<name>`), 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
|
||||
|
||||
Reference in New Issue
Block a user