Feat/plugin instance actions (#2209)

* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

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