diff --git a/charts/trek/Chart.yaml b/charts/trek/Chart.yaml index 0cca3367b..a0d958f11 100644 --- a/charts/trek/Chart.yaml +++ b/charts/trek/Chart.yaml @@ -1,5 +1,5 @@ apiVersion: v2 name: trek -version: 4.0.0 +version: 4.1.1 description: Minimal Helm chart for TREK app -appVersion: "4.0.0" +appVersion: "4.1.1" diff --git a/client/package.json b/client/package.json index edf11fccb..37c54687d 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@trek/client", - "version": "4.0.0", + "version": "4.1.1", "private": true, "type": "module", "scripts": { diff --git a/client/src/api/client.surface.test.ts b/client/src/api/client.surface.test.ts index bca591154..46b04b075 100644 --- a/client/src/api/client.surface.test.ts +++ b/client/src/api/client.surface.test.ts @@ -301,6 +301,8 @@ describe('client > endpoint wiring', () => { { n: 'pluginRescan', r: () => adminApi.pluginRescan(), e: 'POST /api/admin/plugins/rescan' }, { n: 'pluginLink', r: () => adminApi.pluginLink('/srv/plugin'), e: 'POST /api/admin/plugins/link' }, { 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: 'pluginEgressHosts', r: () => adminApi.pluginEgressHosts('koffi'), e: 'GET /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginSetEgressHosts', r: () => adminApi.pluginSetEgressHosts('koffi', ['a.example']), e: 'PUT /api/admin/plugins/koffi/egress-hosts' }, { n: 'pluginErrors', r: () => adminApi.pluginErrors('koffi'), e: 'GET /api/admin/plugins/koffi/errors' }, diff --git a/client/src/api/client.ts b/client/src/api/client.ts index 67264d9e2..ed3313836 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -53,6 +53,10 @@ import { type StorageConfigPut, type StorageTestResponse, type StorageUsage, + type PluginSettingsField, + type PluginInstanceConfigResponse, + type PluginInstanceConfigUpdated, + type PluginInstallRequest, } from '@trek/shared' import { getSocketId } from './websocket' import { probeNow } from '../sync/connectivity' @@ -535,8 +539,8 @@ export const adminApi = { plugins: () => apiClient.get('/admin/plugins').then(r => r.data), pluginBrowse: (refresh?: boolean) => apiClient.get('/admin/plugins/registry', { params: refresh ? { refresh: 1 } : undefined }).then(r => r.data), pluginDetail: (id: string) => apiClient.get(`/admin/plugins/registry/${encodeURIComponent(id)}`).then(r => r.data), - pluginInstall: (id: string, opts?: { version?: string; constraint?: string; withDependencies?: boolean }) => - apiClient.post('/admin/plugins/install', { id, ...opts }).then(r => r.data), + pluginInstall: (id: string, opts?: Pick) => + apiClient.post('/admin/plugins/install', { id, ...opts } satisfies PluginInstallRequest).then(r => r.data), pluginActivate: (id: string, consent?: boolean) => apiClient.post(`/admin/plugins/${id}/activate`, consent ? { consent: true } : {}).then(r => r.data), pluginDeactivate: (id: string) => apiClient.post(`/admin/plugins/${id}/deactivate`).then(r => r.data), // `version` pins the exact version to install (the rollback path); omitted, the server @@ -556,6 +560,13 @@ export const adminApi = { // Dev-link (dev-only): register a plugin from a local built dir + hot-reload it. pluginLink: (path: string) => apiClient.post('/admin/plugins/link', { path }).then(r => r.data), pluginReload: (id: string) => apiClient.post(`/admin/plugins/${id}/reload`).then(r => r.data), + // The admin-owned `scope:'instance'` settings: the declared fields plus the stored + // values (secrets masked). Saving may RESTART a running plugin — the child gets its + // config once at init — and `restarted` reports whether that happened. + pluginConfig: (id: string): Promise => + apiClient.get(`/admin/plugins/${id}/config`).then(r => r.data), + pluginSaveConfig: (id: string, config: Record): Promise => + apiClient.put(`/admin/plugins/${id}/config`, config).then(r => r.data), // 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. @@ -782,11 +793,9 @@ export interface PluginAtlasLayer { countries: Array<{ code: string; tone: 'default' | 'success' | 'warn' | 'danger'; label?: string }> } -export interface PluginUserSettingField { - key: string; label?: string | null; input_type?: string; placeholder?: string | null; - hint?: string | null; required?: boolean; secret?: boolean; - options?: Array<{ value: string; label: string }> -} +/** The settings-field descriptor is the SHARED contract (both scopes emit the same + * 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 { diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index a414c8db7..51f179f81 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -129,7 +129,9 @@ describe('AdminPluginsPanel — Discover modal, operator-egress pill', () => { * the UI. It must escape every overflow ancestor, and flip up when the bottom is tight. */ describe('AdminPluginsPanel — row ⋯ menu is never clipped (#1523)', () => { - const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: false }) + // operatorEgress so the menu shows every action, Allowed hosts included (it is + // gated on the declaration, like the row chip). + const withRepo = plugin({ source_repo: 'trek/gotify', operatorEgress: true }) const realRect = HTMLButtonElement.prototype.getBoundingClientRect afterEach(() => { HTMLButtonElement.prototype.getBoundingClientRect = realRect }) @@ -847,6 +849,15 @@ describe('AdminPluginsPanel — row actions', () => { expect(screen.getByText('View error log')).toBeInTheDocument() expect(screen.queryByText('Restart')).not.toBeInTheDocument() }) + + it('FE-COMP-PLUGINS-PANEL-049: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => { + // An admin must never be invited to widen egress for a plugin that didn't ask for it + // — same rule the row chip already follows. + await openRowMenu(plugin({ operatorEgress: false })) + + expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open + expect(screen.queryByText('Allowed hosts')).not.toBeInTheDocument() + }) }) describe('AdminPluginsPanel — enabling a plugin', () => { @@ -1776,7 +1787,9 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-011: a plugin whose runtime cannot take extra hosts says so', async () => { const user = userEvent.setup() - mockPanel([row()]) + // Declares operatorEgress (so the menu item shows), but the runtime lookup + // disagrees — the dialog's unsupported notice is the truth-teller. + mockPanel([row({ operatorEgress: true })]) server.use( http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] })), ) @@ -1791,7 +1804,7 @@ describe('AdminPluginsPanel toolbar and dialogs', () => { it('FE-W5PLG-012: a failing egress lookup still opens the dialog in the unsupported state', async () => { const user = userEvent.setup() - mockPanel([row()]) + mockPanel([row({ operatorEgress: true })]) server.use( http.get('*/api/admin/plugins/a-widget/egress-hosts', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), ) @@ -2147,3 +2160,141 @@ describe('AdminPluginsPanel — update all', () => { await waitFor(() => expect(started).toEqual(['a-widget', 'b-widget'])) }) }) + +/** + * The admin-owned `scope:'instance'` settings form. The fields are declared manifest + * data (validated at install), the values live in the plugin's instance config, and a + * save may RESTART a running plugin — the child reads config once, at init — so the + * admin must be told when that happened rather than wondering why the plugin blinked. + */ +describe('AdminPluginsPanel — instance settings', () => { + const FIELDS = [ + { key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false }, + { key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true }, + ] + + async function openRowMenu(p: Record) { + mockList(p) + render(<>) + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')) + } + + async function openSettings(config: Record = { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' }) { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ fields: FIELDS, config })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 2 })) + fireEvent.click(screen.getByText('Instance settings')) + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }) + } + + it('FE-COMP-PLUGINS-CFG-001: a plugin with instance fields offers the menu item, opening the form', async () => { + await openSettings() + // Both declared fields render, the secret as a masked password input. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password') + }) + + it('FE-COMP-PLUGINS-CFG-002: a plugin with NO instance fields gets no menu item', async () => { + await openRowMenu(plugin({ instanceSettingsCount: 0 })) + expect(screen.getByText('View error log')).toBeInTheDocument() // menu is open + expect(screen.queryByText('Instance settings')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-003: saving sends the edits but never the untouched secret mask', async () => { + let body: Record | null = null + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record + return HttpResponse.json({ config: body, restarted: false }) + }), + ) + await openSettings() + + fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } }) + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument() + // The mask is a display artifact, not a value — sending it would overwrite the stored secret. + expect(body).toEqual({ apiUrl: 'https://new.example' }) + }) + + it('FE-COMP-PLUGINS-CFG-004: a save that restarted the running plugin says so', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })), + ) + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved — plugin restarted', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-005: renders every declared field type and saves the edited values', async () => { + const RICH = [ + { key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' }, + { key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in + { key: 'retries', label: 'Retries', input_type: 'number', required: true }, + ] + let body: Record | null = null + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })), + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record + return HttpResponse.json({ config: body, restarted: false }) + }), + ) + await openRowMenu(plugin({ instanceSettingsCount: 3 })) + fireEvent.click(screen.getByText('Instance settings')) + + // Stored values land in the right controls; an unset value renders empty. + const select = await screen.findByRole('combobox', {}, { timeout: 5000 }) + expect(select).toHaveValue('slow') + expect(screen.getByRole('checkbox')).toBeChecked() + expect(screen.getByRole('spinbutton')).toHaveValue(null) + expect(screen.getByText('Pick one')).toBeInTheDocument() // hint + expect(screen.getByText('enabled')).toBeInTheDocument() // label falls back to the key + + fireEvent.change(select, { target: { value: 'fast' } }) + fireEvent.click(screen.getByRole('checkbox')) + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } }) + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('Settings saved', {}, { timeout: 5000 })).toBeInTheDocument() + expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' }) + }) + + 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 })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 2 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByText('Error', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-007: a rejected save shows the server reason and keeps the dialog open', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ error: 'config refused' }, { status: 400 })), + ) + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument() + // Still open — the admin's edits are not thrown away on a refusal. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-008: the dialog closes without saving', async () => { + await openSettings() + + fireEvent.click(screen.getByRole('button', { name: /^close$/i })) + + expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument() + }) +}) diff --git a/client/src/components/Admin/AdminPluginsPanel.tsx b/client/src/components/Admin/AdminPluginsPanel.tsx index d96122894..df02b1bc7 100644 --- a/client/src/components/Admin/AdminPluginsPanel.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.tsx @@ -10,6 +10,7 @@ import { } from 'lucide-react' import PluginIcon from '../shared/PluginIcon' import { adminApi } from '../../api/client' +import { useInstanceSettings } from './useInstanceSettings' import { usePluginStore } from '../../store/pluginStore' import { useTranslation } from '../../i18n' import { useToast } from '../shared/Toast' @@ -49,6 +50,9 @@ interface PluginRow { operatorEgress?: boolean /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ egressHostCount?: number + /** How many `scope:'instance'` settings fields the plugin declares — gates the + * "Instance settings" menu item without a per-plugin fetch. */ + instanceSettingsCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -408,6 +412,8 @@ export default function AdminPluginsPanel() { const [detailFor, setDetailFor] = useState(null) const [errorsFor, setErrorsFor] = useState<{ id: string; rows: Array<{ ts: string; level: string; message: string }> } | null>(null) const [egressFor, setEgressFor] = useState<{ id: string; supported: boolean; hosts: string[] } | null>(null) + // The admin-owned scope:'instance' settings form — shared logic with the phone shell. + const settings = useInstanceSettings() const [egressDraft, setEgressDraft] = useState('') const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') @@ -573,6 +579,11 @@ export default function AdminPluginsPanel() { } } + const openInstanceSettings = (id: string) => { + setMenu(null) + settings.open(id) + } + const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -962,6 +973,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)} onUninstall={() => { setMenu(null); setConfirmUninstall(p) }} /> ))} @@ -1048,6 +1060,62 @@ export default function AdminPluginsPanel() { )} + {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} + {settings.form && ( +
+
e.stopPropagation()}> +
+ {settings.form.id} — {t('admin.plugins.instanceSettings')} + +
+
+ {settings.form.fields.map(f => ( + + ))} + {settings.error &&

{settings.error}

} +
+
+ +
+
+
+ )} + setConfirmUninstall(null)} @@ -1185,11 +1253,11 @@ function EmptyState({ t, onDiscover }: { t: T; onDiscover: () => void }) { ) } -function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onUninstall }: { +function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIncompatible, blocked, onToggle, onUpdate, onRestart, onChangeVersion, onResume, onReviewBlock, onErrors, onEgress, onSettings, onUninstall }: { p: PluginRow; t: T; busy: string | null; menu: string | null; setMenu: (v: string | null) => void hasUpdate: boolean; latestVer?: string; newerIncompatible: { version: string; range: string } | null; blocked: boolean onToggle: () => void; onUpdate: () => void; onRestart: () => void; onChangeVersion: () => void; onResume: () => void; onReviewBlock: () => void - onErrors: () => void; onEgress: () => void; onUninstall: () => void + onErrors: () => void; onEgress: () => void; onSettings: () => void; onUninstall: () => void }) { const caps = deriveCaps(parseJson(p.permissions, []), parseJson<{ widget?: { slot?: string } }>(p.capabilities, {}), t) const deps = deriveDeps(p, t) @@ -1334,8 +1402,18 @@ function InstalledRow({ p, t, busy, menu, setMenu, hasUpdate, latestVer, newerIn {p.enabled === 1 && ( } label={t('admin.plugins.restart')} onClick={onRestart} /> )} + {/* Only a plugin that DECLARES scope:'instance' fields gets the item — an + empty form would invite the admin to configure nothing. */} + {(p.instanceSettingsCount ?? 0) > 0 && ( + } label={t('admin.plugins.instanceSettings')} onClick={onSettings} /> + )} } label={t('admin.plugins.viewErrors')} onClick={onErrors} /> - } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + {/* Only a plugin that DECLARED operatorEgress gets the item — an admin must + never be invited to widen egress for a plugin that didn't ask for it + (same rule the row's egress chip follows). */} + {p.operatorEgress && ( + } label={t('admin.plugins.allowedHosts')} onClick={onEgress} /> + )} {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( } label={t('admin.plugins.changeVersion')} onClick={onChangeVersion} /> diff --git a/client/src/components/Admin/useInstanceSettings.ts b/client/src/components/Admin/useInstanceSettings.ts new file mode 100644 index 000000000..2b0dc5581 --- /dev/null +++ b/client/src/components/Admin/useInstanceSettings.ts @@ -0,0 +1,71 @@ +import { useState } from 'react' +import { adminApi, type PluginUserSettingField } from '../../api/client' +import { useToast } from '../shared/Toast' +import { useTranslation } from '../../i18n' + +/** The server's stand-in for a stored secret — a display artifact, never a value to send back. */ +export const SECRET_MASK = '••••••••' + +export interface InstanceSettingsForm { + id: string + fields: PluginUserSettingField[] + values: Record +} + +/** + * 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). + */ +export function useInstanceSettings() { + const { t } = useTranslation() + const toast = useToast() + const [form, setForm] = useState(null) + const [saving, setSaving] = useState(false) + const [error, setError] = useState('') + + const open = (id: string) => { + setError('') + adminApi.pluginConfig(id) + .then(d => { + const values: Record = {} + for (const f of d.fields) { + const v = d.config[f.key] + values[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v)) + } + setForm({ id, fields: d.fields, values }) + }) + .catch(() => toast.error(t('common.error'))) + } + + const setValue = (key: string, value: string | boolean) => + setForm(s => s && ({ ...s, values: { ...s.values, [key]: value } })) + + const close = () => setForm(null) + + const save = async () => { + if (!form) return + setSaving(true); setError('') + try { + const patch: Record = {} + for (const f of form.fields) { + const v = form.values[f.key] + if (f.secret && v === SECRET_MASK) continue + patch[f.key] = v + } + const d = await adminApi.pluginSaveConfig(form.id, patch) + toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) + setForm(null) + } catch (e) { + const err = e as { response?: { data?: { error?: string } } } + setError(err.response?.data?.error || t('common.error')) + } finally { + setSaving(false) + } + } + + return { form, saving, error, open, setValue, close, save } +} diff --git a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx index 756183c83..a0f011e28 100644 --- a/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx +++ b/client/src/mobile/screens/admin/MAdminPluginsPanel.tsx @@ -9,6 +9,7 @@ import { } from 'lucide-react' import PluginIcon from '../../../components/shared/PluginIcon' import { adminApi } from '../../../api/client' +import { useInstanceSettings } from '../../../components/Admin/useInstanceSettings' import { usePluginStore } from '../../../store/pluginStore' import { useTranslation } from '../../../i18n' import { useToast } from '../../../components/shared/Toast' @@ -51,6 +52,9 @@ interface PluginRow { operatorEgress?: boolean /** How many hosts the admin has added — 0 means the plugin can't reach anything yet. */ egressHostCount?: number + /** How many `scope:'instance'` settings fields the plugin declares — gates the + * "Instance settings" sheet action without a per-plugin fetch. */ + instanceSettingsCount?: number dependencies?: PluginDependencies dependencyStatus?: DependencyStatus dependencyIssues?: DependencyIssues @@ -418,6 +422,8 @@ export default function MAdminPluginsPanel() { const [egressDraft, setEgressDraft] = useState('') const [egressSaving, setEgressSaving] = useState(false) const [egressError, setEgressError] = useState('') + // The admin-owned scope:'instance' settings sheet — shared logic with the desktop shell. + const settings = useInstanceSettings() const [confirmUninstall, setConfirmUninstall] = useState(null) // The version picker for an INSTALLED plugin ("Change version…"); versions land async // from the registry detail endpoint, which computes each version's compat verdict. @@ -585,6 +591,11 @@ export default function MAdminPluginsPanel() { } } + const openInstanceSettings = (id: string) => { + setMenu(null) + settings.open(id) + } + const openErrors = (id: string) => { setMenu(null) adminApi.pluginErrors(id) @@ -961,6 +972,7 @@ export default function MAdminPluginsPanel() { onRestart={() => restart(rowMenuPlugin.id)} onErrors={() => openErrors(rowMenuPlugin.id)} onEgress={() => openEgress(rowMenuPlugin.id)} + onSettings={() => openInstanceSettings(rowMenuPlugin.id)} onChangeVersion={() => openVersionPicker(rowMenuPlugin)} onUninstall={() => { setMenu(null); setConfirmUninstall(rowMenuPlugin) }} /> )} @@ -1074,6 +1086,56 @@ export default function MAdminPluginsPanel() { )} + {/* Instance-wide settings (the admin-owned scope:'instance' fields) */} + {settings.form && ( + + {settings.form.id} — {t('admin.plugins.instanceSettings')}} + onClose={settings.close} + > +
+ {settings.form.fields.map(f => ( + + ))} + {settings.error &&

{settings.error}

} + void settings.save()} className="h-[42px] w-full"> + {t('common.save')} + +
+
+
+ )} + {/* Uninstall confirm */} void - onRestart: () => void; onErrors: () => void; onEgress: () => void; onChangeVersion: () => void; onUninstall: () => void + onRestart: () => void; onErrors: () => void; onEgress: () => void; onSettings: () => void; onChangeVersion: () => void; onUninstall: () => void }) { const linkable = p.source_repo && p.source_repo !== 'local:upload' && p.source_repo !== 'local:link' const rowClass = 'flex w-full items-center gap-2.5 rounded-xl px-3 py-3 text-left text-[0.8125rem] font-semibold text-m-ink' @@ -1347,8 +1409,18 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onChang {p.enabled === 1 && ( )} + {/* 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 && ( + + )} - + {/* Only a plugin that DECLARED operatorEgress gets the action — an admin must + never be invited to widen egress for a plugin that didn't ask for it + (same rule the row's egress chip follows). */} + {p.operatorEgress && ( + + )} {/* Registry plugins only — a sideload/dev-link has no registry versions to pick from. */} {isRegistrySourced(p.source_repo) && ( diff --git a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx index 1ab15b531..56ee3939b 100644 --- a/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx +++ b/client/tests/unit/mobile/admin/MAdminPluginsPanel.test.tsx @@ -572,8 +572,17 @@ describe('MAdminPluginsPanel — the row action sheet', () => { return screen.findByRole('dialog'); } + it('FE-MOB-PLUGP-CFG-005: Allowed hosts is offered only to a plugin that declared operatorEgress', async () => { + // An admin must never be invited to widen egress for a plugin that didn't ask for it + // — same rule the row's egress chip already follows. (Fixture default: operatorEgress false.) + const dialog = await openRowMenu(); + + expect(within(dialog).getByText('View error log')).toBeInTheDocument(); // sheet is open + expect(within(dialog).queryByText('Allowed hosts')).not.toBeInTheDocument(); + }); + it('FE-MOB-PLUGP-034: a registry plugin offers every action plus the repository links', async () => { - const dialog = await openRowMenu({ source_repo: 'acme/gotify' }); + const dialog = await openRowMenu({ source_repo: 'acme/gotify', operatorEgress: true }); for (const label of ['Restart', 'View error log', 'Allowed hosts', 'Source repository', 'Report an issue', 'Delete']) { expect(within(dialog).getByText(label)).toBeInTheDocument(); @@ -705,7 +714,9 @@ describe('MAdminPluginsPanel — operator-supplied egress hosts', () => { }); it('FE-MOB-PLUGP-044: a plugin with fixed manifest hosts says the sheet does not apply', async () => { - mockPanel([plugin({ source_repo: 'acme/gotify' })]); + // Declares operatorEgress (so the menu item shows), but the runtime lookup + // disagrees — the sheet's unsupported notice is the truth-teller. + mockPanel([plugin({ source_repo: 'acme/gotify', operatorEgress: true })]); server.use(http.get('*/api/admin/plugins/trek-gotify/egress-hosts', () => HttpResponse.json({ supported: false, hosts: [] }))); render(); @@ -1510,3 +1521,140 @@ describe('MAdminPluginsPanel — the security footer', () => { expect(screen.queryByText('Every plugin runs boxed in')).not.toBeInTheDocument(); }); }); + +/** + * The admin-owned `scope:'instance'` settings sheet — the phone counterpart of the + * desktop modal. Same contract: gated on declared instance fields, secrets stay + * write-only (an untouched mask is never sent), and a save that restarted the + * running plugin must say so. + */ +describe('MAdminPluginsPanel — instance settings', () => { + const FIELDS = [ + { key: 'apiUrl', label: 'API URL', input_type: 'text', required: true, secret: false }, + { key: 'apiKey', label: 'API key', input_type: 'text', required: false, secret: true }, + ]; + + async function openRowSheet(p: Row) { + mockPanel([p]); + render(); + fireEvent.click(await screen.findByTestId('plugin-row-menu-btn-trek-gotify')); + } + + async function openSettings() { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: FIELDS, config: { apiUrl: 'https://gotify.mydomain.com', apiKey: '••••••••' } })), + ); + await openRowSheet(plugin({ instanceSettingsCount: 2 })); + fireEvent.click(screen.getByText('Instance settings')); + await waitFor(() => expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(), { timeout: 5000 }); + } + + it('FE-MOB-PLUGP-CFG-001: a plugin with instance fields offers the sheet action, opening the form', async () => { + await openSettings(); + expect(screen.getByDisplayValue('••••••••')).toHaveAttribute('type', 'password'); + }); + + it('FE-MOB-PLUGP-CFG-002: a plugin with NO instance fields gets no action', async () => { + await openRowSheet(plugin({ instanceSettingsCount: 0 })); + expect(screen.getByText('View error log')).toBeInTheDocument(); // the sheet is open + expect(screen.queryByText('Instance settings')).not.toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-CFG-003: saving sends the edits but never the untouched secret mask', async () => { + let body: Record | null = null; + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ config: body, restarted: false }); + }), + ); + await openSettings(); + + fireEvent.change(screen.getByDisplayValue('https://gotify.mydomain.com'), { target: { value: 'https://new.example' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 }); + expect(body).toEqual({ apiUrl: 'https://new.example' }); + }); + + it('FE-MOB-PLUGP-CFG-004: a save that restarted the running plugin says so', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ config: { apiUrl: 'https://gotify.mydomain.com' }, restarted: true })), + ); + await openSettings(); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => expect(toastMessages()).toContain('Settings saved — plugin restarted'), { timeout: 5000 }); + }); + + it('FE-MOB-PLUGP-CFG-006: renders every declared field type and saves the edited values', async () => { + const RICH = [ + { key: 'mode', label: 'Mode', input_type: 'select', options: [{ value: 'fast', label: 'Fast' }, { value: 'slow', label: 'Slow' }], hint: 'Pick one' }, + { key: 'enabled', input_type: 'checkbox' }, // no label — the key stands in + { key: 'retries', label: 'Retries', input_type: 'number', required: true }, + ]; + let body: Record | null = null; + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ fields: RICH, config: { mode: 'slow', enabled: true } })), + http.put('*/api/admin/plugins/trek-gotify/config', async ({ request }) => { + body = await request.json() as Record; + return HttpResponse.json({ config: body, restarted: false }); + }), + ); + await openRowSheet(plugin({ instanceSettingsCount: 3 })); + fireEvent.click(screen.getByText('Instance settings')); + + const select = await screen.findByRole('combobox', {}, { timeout: 5000 }); + expect(select).toHaveValue('slow'); + expect(screen.getByRole('switch', { name: 'enabled' })).toBeInTheDocument(); // label falls back to the key + expect(screen.getByRole('spinbutton')).toHaveValue(null); + expect(screen.getByText('Pick one')).toBeInTheDocument(); // hint + + fireEvent.change(select, { target: { value: 'fast' } }); + fireEvent.click(screen.getByRole('switch', { name: 'enabled' })); + fireEvent.change(screen.getByRole('spinbutton'), { target: { value: '3' } }); + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + await waitFor(() => expect(toastMessages()).toContain('Settings saved'), { timeout: 5000 }); + expect(body).toEqual({ mode: 'fast', enabled: false, retries: '3' }); + }); + + it('FE-MOB-PLUGP-CFG-007: a failed config fetch is a toast, not a broken sheet', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ error: 'nope' }, { status: 500 })), + ); + await openRowSheet(plugin({ instanceSettingsCount: 2 })); + fireEvent.click(screen.getByText('Instance settings')); + + await waitFor(() => expect(toastMessages()).toContain('Error'), { timeout: 5000 }); + }); + + it('FE-MOB-PLUGP-CFG-008: a rejected save shows the server reason and keeps the sheet open', async () => { + server.use( + http.put('*/api/admin/plugins/trek-gotify/config', () => + HttpResponse.json({ error: 'config refused' }, { status: 400 })), + ); + await openSettings(); + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })); + + expect(await screen.findByText('config refused', {}, { timeout: 5000 })).toBeInTheDocument(); + // Still open — the admin's edits are not thrown away on a refusal. + expect(screen.getByDisplayValue('https://gotify.mydomain.com')).toBeInTheDocument(); + }); + + it('FE-MOB-PLUGP-CFG-009: the sheet closes without saving', async () => { + await openSettings(); + + // Same retry shape as escapeUntilGone, but on the input value — an input's + // value is not text content, so the text-based helper would pass vacuously. + await waitFor(() => { + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/package-lock.json b/package-lock.json index 3f4c4d013..e1f8ad585 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@trek/root", - "version": "4.0.0", + "version": "4.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@trek/root", - "version": "4.0.0", + "version": "4.1.1", "workspaces": [ "client", "server", @@ -25,7 +25,7 @@ }, "client": { "name": "@trek/client", - "version": "4.0.0", + "version": "4.1.1", "dependencies": { "@fontsource/bebas-neue": "^5.3.0", "@fontsource/eb-garamond": "^5.3.0", @@ -21742,7 +21742,7 @@ }, "server": { "name": "@trek/server", - "version": "4.0.0", + "version": "4.1.1", "dependencies": { "@aws-lite/client": "^0.23.7", "@aws-lite/s3": "^0.2.7", @@ -22110,7 +22110,7 @@ }, "shared": { "name": "@trek/shared", - "version": "4.0.0", + "version": "4.1.1", "dependencies": { "isomorphic-dompurify": "^3.15.0", "zod": "^4.3.6" diff --git a/package.json b/package.json index 3ff4cf521..411c7b017 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@trek/root", "private": true, - "version": "4.0.0", + "version": "4.1.1", "workspaces": [ "client", "server", diff --git a/server/package.json b/server/package.json index 269299188..5ecd0a230 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "@trek/server", - "version": "4.0.0", + "version": "4.1.1", "main": "src/index.ts", "scripts": { "start": "node --require tsconfig-paths/register dist/index.js", diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index b678398fc..de2dae056 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -625,6 +625,19 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes return clean; } + /** + * Re-spawn a plugin IF it is running, so the child re-reads its instance config — + * config is handed to the child once, in the init envelope, and a second init is + * refused (same constraint that makes setOperatorEgressHosts re-spawn). An inactive + * plugin is left alone: it will read the new config at its next activation. + */ + async respawnIfActive(id: string): Promise { + if (!this.isActive(id)) return false; + await this.supervisor.disable(id); + await this.activate(id); + return true; + } + async deactivate(id: string): Promise { await this.supervisor.disable(id); closePluginDataDb(id); diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index 7067d5720..a09d6fae4 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -13,6 +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 { 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 @@ -139,13 +140,19 @@ export class PluginsController { } @Get(':id/config') - getConfig(@Param('id') id: string) { - return { config: this.plugins.getInstanceConfig(id) }; + getConfig(@Param('id') id: string): PluginInstanceConfigResponse { + return { fields: this.plugins.instanceSettingsFields(id), config: this.plugins.getInstanceConfig(id) }; } + /** + * Save the admin-owned `scope:'instance'` settings. A RUNNING plugin gets its config + * once, in the child's init envelope — so a save re-spawns it (like the egress-hosts + * PUT), and `restarted` tells the UI whether that happened. + */ @Put(':id/config') - updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto) { - return { config: this.plugins.updateInstanceConfig(id, body || {}) }; + async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise { + const config = this.plugins.updateInstanceConfig(id, body || {}); + return { config, restarted: await this.runtime.respawnIfActive(id) }; } /** diff --git a/server/src/nest/plugins/plugins.dto.ts b/server/src/nest/plugins/plugins.dto.ts index 4f530a9bc..eba092c53 100644 --- a/server/src/nest/plugins/plugins.dto.ts +++ b/server/src/nest/plugins/plugins.dto.ts @@ -1,86 +1,41 @@ import { createZodDto } from 'nestjs-zod'; -import { z } from 'zod'; +import { + pluginActivateRequestSchema, + pluginEgressHostsRequestSchema, + pluginInstallRequestSchema, + pluginInstanceConfigUpdateSchema, + pluginLinkRequestSchema, + pluginRetrustRequestSchema, + pluginRouteRequestSchema, + pluginUninstallRequestSchema, + pluginUpdateRequestSchema, + pluginUserSettingsUpdateRequestSchema, +} from '@trek/shared'; /** - * Body contracts for the plugin surfaces, written to DESCRIBE rather than to - * tighten (the journey-contract doctrine, #1842). - * - * Every one of these handlers already owns its own rejection, and several of - * them answer something other than 400 for a body they dislike: activate and - * install answer 503 when plugins are disabled, and they check that BEFORE - * looking at the body. A schema that rejected first would move a 503 to a 400 - * for an operator whose server has plugins off. So the fields stay optional and - * the objects stay loose: the DTO pins the wire TYPES, and the handler keeps - * every decision it already made, in the order it already made it. + * Nest DTO classes for the plugin surfaces — thin wrappers over the shared + * contracts in @trek/shared (plugins.schema.ts), which carry the schemas, the + * inferred types both sides import, and the journey-contract doctrine notes + * (#1842) explaining why each body stays loose. Nothing here may add or remove + * validation: the schema of record lives in shared. */ -/** `id is required` stays the handler's answer, so id is optional here. */ -export class PluginInstallDto extends createZodDto( - z.looseObject({ - id: z.string().optional(), - version: z.string().optional(), - constraint: z.string().optional(), - withDependencies: z.boolean().optional(), - }), -) {} +export class PluginInstallDto extends createZodDto(pluginInstallRequestSchema) {} -/** `path is required` likewise, and dev-link answers 403 before reading it. */ -export class PluginLinkDto extends createZodDto( - z.looseObject({ path: z.string().optional() }), -) {} +export class PluginLinkDto extends createZodDto(pluginLinkRequestSchema) {} -export class PluginActivateDto extends createZodDto( - z.looseObject({ consent: z.boolean().optional() }), -) {} +export class PluginActivateDto extends createZodDto(pluginActivateRequestSchema) {} -export class PluginUninstallDto extends createZodDto( - z.looseObject({ deleteData: z.boolean().optional() }), -) {} +export class PluginUninstallDto extends createZodDto(pluginUninstallRequestSchema) {} -export class PluginRetrustDto extends createZodDto( - z.looseObject({ version: z.string().optional(), publicKey: z.string().optional() }), -) {} +export class PluginRetrustDto extends createZodDto(pluginRetrustRequestSchema) {} -/** - * `version` pins the exact version to install — the rollback path. Omitted, the - * runtime resolves the newest TREK-compatible version itself (the classic update). - */ -export class PluginUpdateDto extends createZodDto( - z.looseObject({ version: z.string().optional() }), -) {} +export class PluginUpdateDto extends createZodDto(pluginUpdateRequestSchema) {} -/** - * `hosts` is deliberately unknown, not `string[]`. - * - * The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`, - * which means an omitted or non-array `hosts` CLEARS the operator egress list. - * That is the documented way to reset it, so a schema demanding an array would - * not tighten validation, it would remove a working admin action. - */ -export class PluginEgressHostsDto extends createZodDto( - z.looseObject({ hosts: z.unknown().optional() }), -) {} +export class PluginEgressHostsDto extends createZodDto(pluginEgressHostsRequestSchema) {} -/** - * Plugin instance config is defined by the plugin's own manifest, not by TREK, - * so there is no shape to assert here beyond "a JSON object". - */ -export class PluginConfigDto extends createZodDto(z.looseObject({})) {} +export class PluginConfigDto extends createZodDto(pluginInstanceConfigUpdateSchema) {} -/** - * `config` is unknown rather than a record: the handler answers 200 with the - * stored config for anything it cannot use, including a non-object, and a schema - * that rejected first would turn that into a 400. - */ -export class PluginUserSettingsUpdateDto extends createZodDto( - z.looseObject({ config: z.unknown().optional() }), -) {} +export class PluginUserSettingsUpdateDto extends createZodDto(pluginUserSettingsUpdateRequestSchema) {} -/** - * The route hook answers `{ route: null }` at 200 for every input it dislikes: - * a missing trip, a bad profile, unusable waypoints, a slow provider. The client - * draws straight lines on a null and shows nothing on a 400, so a schema that - * rejected a malformed body would turn a graceful fallback into a visible error. - * Loose and fully optional on purpose. - */ -export class PluginRouteDto extends createZodDto(z.looseObject({})) {} +export class PluginRouteDto extends createZodDto(pluginRouteRequestSchema) {} diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index b2f0463f3..35d65b454 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -11,6 +11,7 @@ import { AddonsService } from '../addons/addons.service'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies'; import { hostSatisfies, hostVersion } from './install/host-compat'; import type { PluginDependency } from './install/manifest'; +import type { PluginSettingsField } from '@trek/shared'; const SECRET_MASK = '••••••••'; @@ -66,6 +67,9 @@ export interface PluginListItem { operatorEgress: boolean; /** How many hosts an admin has actually added — so the card can nudge when it's 0. */ egressHostCount: number; + /** How many `scope:'instance'` settings fields the plugin declares — gates the admin + * settings menu item without a per-plugin fetch. */ + instanceSettingsCount: number; /** Declared dependencies (parsed) — required addons + plugin deps. */ dependencies: PluginDependencies; /** Whether this plugin can currently activate, and why not if it can't. */ @@ -113,6 +117,16 @@ export class PluginsService { } } + private instanceSettingsCount(id: string): number { + try { + return ( + this.db.prepare("SELECT COUNT(*) AS n FROM plugin_settings_fields 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( @@ -154,6 +168,7 @@ export class PluginsService { ...rest, operatorEgress: _oe === 1, egressHostCount: this.egressHostCount(r.id), + instanceSettingsCount: this.instanceSettingsCount(r.id), dependencies: deps, dependencyStatus, trekRange: trek_range, @@ -219,24 +234,37 @@ export class PluginsService { } /** The plugin's `scope:'user'` settings fields, in declared order (for the user form). */ - userSettingsFields(id: string): Array> { + userSettingsFields(id: string): PluginSettingsField[] { + return this.settingsFields(id, 'user'); + } + + /** The plugin's `scope:'instance'` settings fields, in declared order (for the ADMIN form). */ + instanceSettingsFields(id: string): PluginSettingsField[] { + return this.settingsFields(id, 'instance'); + } + + private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] { return this.db .prepare( `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options - FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' ORDER BY sort_order, id`, + FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`, ) - .all(id) + .all(id, scope) .map((r) => { const row = r as Record; return { - key: row.key, - label: row.label ?? null, - input_type: row.input_type ?? 'text', - placeholder: row.placeholder ?? null, - hint: row.hint ?? null, + key: String(row.key), + label: (row.label ?? null) as string | null, + input_type: (row.input_type ?? 'text') as string, + placeholder: (row.placeholder ?? null) as string | null, + hint: (row.hint ?? null) as string | null, required: row.required === 1, secret: row.secret === 1, - options: typeof row.options === 'string' && row.options ? safeArray(row.options as string) : undefined, + // Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check. + options: + typeof row.options === 'string' && row.options + ? (safeArray(row.options as string) as PluginSettingsField['options']) + : undefined, }; }); } diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts new file mode 100644 index 000000000..f47f8f340 --- /dev/null +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -0,0 +1,165 @@ +/** + * Instance-wide plugin settings (#plugins): the admin-owned counterpart of the + * per-user settings form. Proves: the field list serves ONLY `scope:'instance'` + * declarations (in declared order, with the metadata the form renders from), the + * admin list carries a count so the UI can gate its menu item without a fetch, + * saving through the controller re-spawns an ACTIVE plugin (config is handed to + * the child once, in its init envelope), and an inactive plugin is left alone. + */ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + return { testDb: db, dbMock: { db, closeDb: () => {}, reinitialize: () => {}, canAccessTrip: () => null } }; +}); +vi.mock('../../../src/db/database', () => dbMock); +import { db as dbConn } from '../../../src/db/database'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +vi.mock('../../../src/config', () => ({ JWT_SECRET: 'x'.repeat(40), ENCRYPTION_KEY: 'a'.repeat(64), updateJwtSecret: () => {} })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { PluginsService } from '../../../src/nest/plugins/plugins.service'; +import { PluginsController } from '../../../src/nest/plugins/plugins.controller'; +import type { PluginRuntimeService } from '../../../src/nest/plugins/plugin-runtime.service'; +import type { PluginRegistryService } from '../../../src/nest/plugins/registry/registry.service'; +import type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service'; +import { AddonsService } from '../../../src/nest/addons/addons.service'; +import { createPluginRuntime } from '../../helpers/plugin-host'; + +function install(id: string) { + testDb + .prepare( + `INSERT OR REPLACE INTO plugins (id, name, status, enabled, version, permissions, granted_permissions, capabilities, config) + VALUES (?, ?, 'inactive', 0, '1.0.0', '[]', '[]', '{}', '{}')`, + ) + .run(id, id); +} + +function declareField(pluginId: string, key: string, scope: 'instance' | 'user', opts: { secret?: boolean; required?: boolean; sortOrder?: number; options?: string } = {}) { + testDb + .prepare( + `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, sort_order) + VALUES (?, ?, ?, 'text', NULL, NULL, ?, ?, ?, ?, ?)`, + ) + .run(pluginId, key, key, opts.required ? 1 : 0, opts.secret ? 1 : 0, scope, opts.options ?? null, opts.sortOrder ?? 0); +} + +const svc = () => { + const dbs = new DatabaseService(dbConn); + return new PluginsService(dbs, new AddonsService(dbs)); +}; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); +beforeEach(() => { + testDb.prepare('DELETE FROM plugins').run(); + testDb.prepare('DELETE FROM plugin_settings_fields').run(); + process.env.TREK_PLUGINS_ENABLED = 'true'; +}); + +describe('instance settings fields', () => { + it('INS-001 — lists only the instance-scope fields, in declared order, with form metadata', () => { + install('p'); + declareField('p', 'apiUrl', 'instance', { required: true, sortOrder: 1 }); + declareField('p', 'apiKey', 'instance', { secret: true, sortOrder: 0 }); + declareField('p', 'units', 'user', { sortOrder: 2 }); // must never leak into the admin form + + const fields = svc().instanceSettingsFields('p'); + expect(fields.map((f) => f.key)).toEqual(['apiKey', 'apiUrl']); + expect(fields[0]).toMatchObject({ secret: true, required: false, input_type: 'text' }); + expect(fields[1]).toMatchObject({ secret: false, required: true }); + }); + + it('INS-002 — parses select options like the user form does', () => { + install('p'); + declareField('p', 'mode', 'instance', { options: '["fast","slow"]' }); + expect(svc().instanceSettingsFields('p')[0].options).toEqual(['fast', 'slow']); + }); + + it('INS-003 — the admin list carries the instance-field count (gates the menu item)', () => { + install('with-fields'); + install('plain'); + declareField('with-fields', 'apiKey', 'instance', { secret: true }); + declareField('with-fields', 'apiUrl', 'instance'); + declareField('with-fields', 'units', 'user'); // user fields must not count + + const plugins = svc().list().plugins; + expect(plugins.find((p) => p.id === 'with-fields')).toMatchObject({ instanceSettingsCount: 2 }); + expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 }); + }); +}); + +describe('respawn on save (runtime)', () => { + it('INS-004 — an inactive plugin is left alone (no respawn, reports false)', async () => { + install('p'); + const rt = createPluginRuntime(new DatabaseService(dbConn)); + await expect(rt.respawnIfActive('p')).resolves.toBe(false); + }); + + it('INS-005 — an active plugin is stopped and re-activated so the child re-reads config', async () => { + install('p'); + const rt = createPluginRuntime(new DatabaseService(dbConn)); + const calls: string[] = []; + vi.spyOn(rt, 'isActive').mockReturnValue(true); + vi.spyOn(rt, 'activate').mockImplementation(async () => { calls.push('activate'); }); + const sup = (rt as unknown as { supervisor: { disable: (id: string) => Promise } }).supervisor; + vi.spyOn(sup, 'disable').mockImplementation(async () => { calls.push('disable'); }); + + await expect(rt.respawnIfActive('p')).resolves.toBe(true); + expect(calls).toEqual(['disable', 'activate']); // stop first, then bring back up + }); +}); + +describe('admin config endpoints (controller)', () => { + const controllerWith = (runtime: Partial) => + new PluginsController( + svc(), + runtime as PluginRuntimeService, + {} as PluginRegistryService, + { isManaged: () => false } as unknown as RuntimeEnvService, + ); + + it('INS-006 — GET :id/config returns the fields alongside the (masked) values', () => { + install('p'); + declareField('p', 'apiUrl', 'instance'); + testDb.prepare("UPDATE plugins SET config = '{\"apiUrl\":\"https://x.example\"}' WHERE id = 'p'").run(); + + const out = controllerWith({}).getConfig('p'); + expect(out.config).toEqual({ apiUrl: 'https://x.example' }); + expect(out.fields.map((f: Record) => f.key)).toEqual(['apiUrl']); + }); + + it('INS-007 — PUT :id/config saves, respawns an active plugin, and reports it', async () => { + install('p'); + declareField('p', 'apiUrl', 'instance'); + const respawnIfActive = vi.fn(async () => true); + + const out = await controllerWith({ respawnIfActive }).updateConfig('p', { apiUrl: 'https://y.example' }); + expect(out.config).toEqual({ apiUrl: 'https://y.example' }); + expect(out.restarted).toBe(true); + expect(respawnIfActive).toHaveBeenCalledWith('p'); + }); + + it('INS-008 — PUT :id/config on an inactive plugin saves without a restart', async () => { + install('p'); + declareField('p', 'apiUrl', 'instance'); + const respawnIfActive = vi.fn(async () => false); + + const out = await controllerWith({ respawnIfActive }).updateConfig('p', { apiUrl: 'https://y.example' }); + expect(out.restarted).toBe(false); + }); + + it('INS-009 — PUT :id/config with no body is an empty patch, not a crash', async () => { + install('p'); + declareField('p', 'apiUrl', 'instance'); + const respawnIfActive = vi.fn(async () => false); + + const out = await controllerWith({ respawnIfActive }).updateConfig('p', undefined as never); + expect(out.config).toEqual({}); + expect(out.restarted).toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index c0db0d8a1..0f5a1b22f 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -222,6 +222,7 @@ describe('PluginsFeedController (client feed)', () => { describe('PluginsController M2 endpoints', () => { const svc = { getInstanceConfig: vi.fn(() => ({ a: 1 })), + instanceSettingsFields: vi.fn(() => [{ key: 'a' }]), updateInstanceConfig: vi.fn(() => ({ a: 2 })), } as unknown as PluginsService; // None of the endpoints below carry the marker, so the ordinary install is the @@ -234,11 +235,11 @@ describe('PluginsController M2 endpoints', () => { process.env.TREK_PLUGINS_ENABLED = 'true'; }); - it('get/update config delegate to the service', () => { - const rt = { activate: vi.fn(), deactivate: vi.fn(), isActive: vi.fn() } as never; + 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 c = new PluginsController(svc, rt, {} as never, envStub); - expect(c.getConfig('x')).toEqual({ config: { a: 1 } }); - expect(c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 } }); + expect(c.getConfig('x')).toEqual({ fields: [{ key: 'a' }], config: { a: 1 } }); + expect(await c.updateConfig('x', { a: 2 })).toEqual({ config: { a: 2 }, restarted: false }); }); it('activate spawns via the runtime when enabled', async () => { diff --git a/shared/package.json b/shared/package.json index 6ce8afd5d..d75d44ca1 100644 --- a/shared/package.json +++ b/shared/package.json @@ -1,6 +1,6 @@ { "name": "@trek/shared", - "version": "4.0.0", + "version": "4.1.1", "private": true, "description": "Shared API contracts (Zod schemas) — single source of truth for TREK server and client.", "type": "module", diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index bbd07b770..9a0a222f7 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -451,6 +451,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'لا توجد إضافات في السجل تطابق بحثك.', 'admin.plugins.restart': 'إعادة التشغيل', 'admin.plugins.restarted': 'تمت إعادة تشغيل الإضافة', + 'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم', + 'admin.plugins.settingsSaved': 'تم حفظ الإعدادات', + 'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة', 'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك', 'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية', 'admin.plugins.cap.readsCosts': 'يقرأ مصروفاتك', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 7500fbf94..6bf9f811b 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -440,6 +440,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Nenhum plugin no registro corresponde à sua busca.', 'admin.plugins.restart': 'Reiniciar', 'admin.plugins.restarted': 'Plugin reiniciado', + 'admin.plugins.instanceSettings': 'Configurações da instância', + 'admin.plugins.settingsSaved': 'Configurações salvas', + 'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado', 'admin.plugins.cap.readsTrips': 'Lê suas viagens', 'admin.plugins.cap.readsUsers': 'Lê perfis básicos', 'admin.plugins.cap.readsCosts': 'Lê suas despesas', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index 02621259d..9a2098607 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -568,6 +568,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Cap connector del registre coincideix amb la teva cerca.', 'admin.plugins.restart': 'Reinicia', 'admin.plugins.restarted': 'Connector reiniciat', + 'admin.plugins.instanceSettings': 'Configuració de la instància', + 'admin.plugins.settingsSaved': 'Configuració desada', + 'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat', 'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges', 'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics', 'admin.plugins.cap.readsCosts': 'Llegeix les teves despeses', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 23a749efc..52cd3c641 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -438,6 +438,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Žádné pluginy v registru neodpovídají vašemu hledání.', 'admin.plugins.restart': 'Restartovat', 'admin.plugins.restarted': 'Plugin restartován', + 'admin.plugins.instanceSettings': 'Nastavení instance', + 'admin.plugins.settingsSaved': 'Nastavení uloženo', + 'admin.plugins.settingsSavedRestarted': 'Nastavení uloženo — plugin restartován', 'admin.plugins.cap.readsTrips': 'Čte vaše cesty', 'admin.plugins.cap.readsUsers': 'Čte základní profily', 'admin.plugins.cap.readsCosts': 'Čte vaše výdaje', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 5eada53c7..c28db2d17 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -447,6 +447,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Keine Plugins in der Registry passen zu deiner Suche.', 'admin.plugins.restart': 'Neu starten', 'admin.plugins.restarted': 'Plugin neu gestartet', + 'admin.plugins.instanceSettings': 'Instanz-Einstellungen', + 'admin.plugins.settingsSaved': 'Einstellungen gespeichert', + 'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet', 'admin.plugins.cap.readsTrips': 'Liest deine Reisen', 'admin.plugins.cap.readsUsers': 'Liest Basis-Profile', 'admin.plugins.cap.readsCosts': 'Liest deine Ausgaben', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 24db6ee78..edccd8c4b 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -485,6 +485,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'No plugins in the registry match your search.', 'admin.plugins.restart': 'Restart', 'admin.plugins.restarted': 'Plugin restarted', + 'admin.plugins.instanceSettings': 'Instance settings', + 'admin.plugins.settingsSaved': 'Settings saved', + 'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted', 'admin.plugins.cap.readsTrips': 'Reads your trips', 'admin.plugins.cap.readsUsers': 'Reads basic profiles', 'admin.plugins.cap.readsCosts': 'Reads your costs', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index fc77c66b8..0bb906ae6 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -449,6 +449,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Ningún plugin del registro coincide con tu búsqueda.', 'admin.plugins.restart': 'Reiniciar', 'admin.plugins.restarted': 'Plugin reiniciado', + 'admin.plugins.instanceSettings': 'Ajustes de la instancia', + 'admin.plugins.settingsSaved': 'Ajustes guardados', + 'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado', 'admin.plugins.cap.readsTrips': 'Lee tus viajes', 'admin.plugins.cap.readsUsers': 'Lee perfiles básicos', 'admin.plugins.cap.readsCosts': 'Lee tus gastos', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 751b222fe..ef0a3b325 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -452,6 +452,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Aucun plugin du registre ne correspond à votre recherche.', 'admin.plugins.restart': 'Redémarrer', 'admin.plugins.restarted': 'Plugin redémarré', + '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.cap.readsTrips': 'Lit vos voyages', 'admin.plugins.cap.readsUsers': 'Lit les profils de base', 'admin.plugins.cap.readsCosts': 'Lit vos dépenses', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index 9c4c97f5e..2176c7dfd 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -500,6 +500,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Κανένα plugin στο μητρώο δεν ταιριάζει με την αναζήτησή σας.', 'admin.plugins.restart': 'Επανεκκίνηση', 'admin.plugins.restarted': 'Το plugin επανεκκινήθηκε', + 'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης', + 'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν', + 'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε', 'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας', 'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ', 'admin.plugins.cap.readsCosts': 'Διαβάζει τα έξοδά σας', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 8de65d772..cd62b76a6 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -452,6 +452,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Nincs a keresésnek megfelelő plugin a tárban.', 'admin.plugins.restart': 'Újraindítás', 'admin.plugins.restarted': 'Plugin újraindítva', + '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.cap.readsTrips': 'Olvassa az utazásaidat', 'admin.plugins.cap.readsUsers': 'Olvassa az alapprofilokat', 'admin.plugins.cap.readsCosts': 'Olvassa a költségeidet', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 817323314..1782f3b2d 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -494,6 +494,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Tidak ada plugin di registri yang cocok dengan pencarianmu.', 'admin.plugins.restart': 'Mulai ulang', 'admin.plugins.restarted': 'Plugin dimulai ulang', + 'admin.plugins.instanceSettings': 'Pengaturan instans', + 'admin.plugins.settingsSaved': 'Pengaturan disimpan', + 'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang', 'admin.plugins.cap.readsTrips': 'Membaca perjalananmu', 'admin.plugins.cap.readsUsers': 'Membaca profil dasar', 'admin.plugins.cap.readsCosts': 'Membaca pengeluaranmu', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index fdd181719..e76c501fa 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -447,6 +447,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Nessun plugin nel registro corrisponde alla tua ricerca.', 'admin.plugins.restart': 'Riavvia', 'admin.plugins.restarted': 'Plugin riavviato', + 'admin.plugins.instanceSettings': 'Impostazioni dell’istanza', + 'admin.plugins.settingsSaved': 'Impostazioni salvate', + 'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato', 'admin.plugins.cap.readsTrips': 'Legge i tuoi viaggi', 'admin.plugins.cap.readsUsers': 'Legge i profili di base', 'admin.plugins.cap.readsCosts': 'Legge le tue spese', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 487fe0cee..029301bb9 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -459,6 +459,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': '検索条件に一致するプラグインはレジストリにありません。', 'admin.plugins.restart': '再起動', 'admin.plugins.restarted': 'プラグインを再起動しました', + 'admin.plugins.instanceSettings': 'インスタンス設定', + 'admin.plugins.settingsSaved': '設定を保存しました', + 'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました', 'admin.plugins.cap.readsTrips': '旅行を読み取り', 'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り', 'admin.plugins.cap.readsCosts': '支出を読み取り', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 7e3fa24c8..510699613 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -459,6 +459,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': '레지스트리에 검색과 일치하는 플러그인이 없습니다.', 'admin.plugins.restart': '다시 시작', 'admin.plugins.restarted': '플러그인이 다시 시작되었습니다', + 'admin.plugins.instanceSettings': '인스턴스 설정', + 'admin.plugins.settingsSaved': '설정이 저장되었습니다', + 'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다', 'admin.plugins.cap.readsTrips': '여행 읽기', 'admin.plugins.cap.readsUsers': '기본 프로필 읽기', 'admin.plugins.cap.readsCosts': '지출 읽기', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index b1c49ded1..969119336 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -446,6 +446,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Geen plugins in het register komen overeen met je zoekopdracht.', 'admin.plugins.restart': 'Opnieuw starten', 'admin.plugins.restarted': 'Plugin opnieuw gestart', + 'admin.plugins.instanceSettings': 'Instantie-instellingen', + 'admin.plugins.settingsSaved': 'Instellingen opgeslagen', + 'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart', 'admin.plugins.cap.readsTrips': 'Leest je reizen', 'admin.plugins.cap.readsUsers': 'Leest basisprofielen', 'admin.plugins.cap.readsCosts': 'Leest je uitgaven', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 274c86556..5dfe7a857 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -444,6 +444,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Żadna wtyczka w rejestrze nie pasuje do wyszukiwania.', 'admin.plugins.restart': 'Uruchom ponownie', 'admin.plugins.restarted': 'Wtyczka została uruchomiona ponownie', + 'admin.plugins.instanceSettings': 'Ustawienia instancji', + 'admin.plugins.settingsSaved': 'Ustawienia zapisane', + 'admin.plugins.settingsSavedRestarted': 'Ustawienia zapisane — wtyczka została uruchomiona ponownie', 'admin.plugins.cap.readsTrips': 'Odczytuje Twoje podróże', 'admin.plugins.cap.readsUsers': 'Odczytuje podstawowe profile', 'admin.plugins.cap.readsCosts': 'Odczytuje Twoje wydatki', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index f52290d79..541e3f568 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -448,6 +448,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'В реестре нет плагинов по вашему запросу.', 'admin.plugins.restart': 'Перезапустить', 'admin.plugins.restarted': 'Плагин перезапущен', + 'admin.plugins.instanceSettings': 'Настройки экземпляра', + 'admin.plugins.settingsSaved': 'Настройки сохранены', + 'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен', 'admin.plugins.cap.readsTrips': 'Читает ваши поездки', 'admin.plugins.cap.readsUsers': 'Читает основные профили', 'admin.plugins.cap.readsCosts': 'Читает ваши расходы', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index de1a46974..08dc66cd9 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -505,6 +505,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Inga plugins i registret matchar din sökning.', 'admin.plugins.restart': 'Starta om', 'admin.plugins.restarted': 'Pluginet har startats om', + '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.cap.readsTrips': 'Läser dina resor', 'admin.plugins.cap.readsUsers': 'Läser grundläggande profiler', 'admin.plugins.cap.readsCosts': 'Läser dina kostnader', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index 7a646c3ff..b0768eee3 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -491,6 +491,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Dizinde aramanızla eşleşen eklenti yok.', 'admin.plugins.restart': 'Yeniden başlat', 'admin.plugins.restarted': 'Eklenti yeniden başlatıldı', + 'admin.plugins.instanceSettings': 'Örnek ayarları', + 'admin.plugins.settingsSaved': 'Ayarlar kaydedildi', + 'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı', 'admin.plugins.cap.readsTrips': 'Seyahatlerinizi okur', 'admin.plugins.cap.readsUsers': 'Temel profilleri okur', 'admin.plugins.cap.readsCosts': 'Harcamalarınızı okur', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 6c33085e5..610dafdd2 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -445,6 +445,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Жоден плагін у реєстрі не відповідає вашому запиту.', 'admin.plugins.restart': 'Перезапустити', 'admin.plugins.restarted': 'Плагін перезапущено', + 'admin.plugins.instanceSettings': 'Налаштування інстансу', + 'admin.plugins.settingsSaved': 'Налаштування збережено', + 'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено', 'admin.plugins.cap.readsTrips': 'Читає ваші подорожі', 'admin.plugins.cap.readsUsers': 'Читає базові профілі', 'admin.plugins.cap.readsCosts': 'Читає ваші витрати', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index e0fd72b1d..5b6bdf681 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -447,6 +447,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': 'Không có plugin nào trong kho đăng ký khớp với tìm kiếm của bạn.', 'admin.plugins.restart': 'Khởi động lại', 'admin.plugins.restarted': 'Đã khởi động lại plugin', + '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.cap.readsTrips': 'Đọc chuyến đi của bạn', 'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản', 'admin.plugins.cap.readsCosts': 'Đọc chi phí của bạn', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 37f9e1317..0bceec3ec 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -441,6 +441,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': '外掛庫中沒有符合搜尋條件的外掛。', 'admin.plugins.restart': '重新啟動', 'admin.plugins.restarted': '外掛已重新啟動', + 'admin.plugins.instanceSettings': '執行個體設定', + 'admin.plugins.settingsSaved': '設定已儲存', + 'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動', 'admin.plugins.cap.readsTrips': '讀取你的行程', 'admin.plugins.cap.readsUsers': '讀取基本個人資料', 'admin.plugins.cap.readsCosts': '讀取你的支出', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index 49fc61add..43b9ca469 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -401,6 +401,9 @@ const admin: TranslationStrings = { 'admin.plugins.noMatchRegistry': '插件库中没有与你的搜索匹配的插件。', 'admin.plugins.restart': '重启', 'admin.plugins.restarted': '插件已重启', + 'admin.plugins.instanceSettings': '实例设置', + 'admin.plugins.settingsSaved': '设置已保存', + 'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启', 'admin.plugins.cap.readsTrips': '读取你的旅行', 'admin.plugins.cap.readsUsers': '读取基本个人资料', 'admin.plugins.cap.readsCosts': '读取你的支出', diff --git a/shared/src/index.ts b/shared/src/index.ts index 249c864eb..450a715c8 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -69,3 +69,5 @@ export * from './i18n/languages'; // Plugin permission list, generated from the host's protocol/envelope.ts // (server/scripts/gen-plugin-facts.ts). The admin consent screens render from it. export * from './plugin-permissions'; +// Plugin settings contracts: the settings-field descriptor + admin instance-config wire shapes. +export * from './plugins/plugins.schema'; diff --git a/shared/src/plugins/plugins.schema.spec.ts b/shared/src/plugins/plugins.schema.spec.ts new file mode 100644 index 000000000..b3bfd1774 --- /dev/null +++ b/shared/src/plugins/plugins.schema.spec.ts @@ -0,0 +1,133 @@ +import { + pluginSettingsFieldSchema, + pluginInstanceConfigResponseSchema, + pluginInstanceConfigUpdateSchema, + pluginInstanceConfigUpdatedSchema, + pluginInstallRequestSchema, + pluginLinkRequestSchema, + pluginActivateRequestSchema, + pluginUninstallRequestSchema, + pluginRetrustRequestSchema, + pluginUpdateRequestSchema, + pluginEgressHostsRequestSchema, + pluginUserSettingsUpdateRequestSchema, + pluginRouteRequestSchema, +} from './plugins.schema'; + +import { describe, expect, it } from 'vitest'; + +/** + * The instance-config contract (#plugins): the settings-field descriptor both + * settings forms render from, and the admin config GET/PUT wire shapes. These + * pin the SHAPE the server emits — which keys are nullable vs optional matters, + * because the client forms branch on them (`f.label || f.key`, `f.secret`). + */ +describe('pluginSettingsFieldSchema', () => { + it('accepts a fully-populated field as the server emits it (nulls, not absences)', () => { + const field = { + key: 'apiKey', + label: 'API key', + input_type: 'text', + placeholder: null, + hint: null, + required: false, + secret: true, + options: undefined, + }; + expect(pluginSettingsFieldSchema.parse(field)).toMatchObject({ key: 'apiKey', secret: true }); + }); + + it('accepts a select field with {value,label} options (the manifest-validated shape)', () => { + const field = { key: 'mode', options: [{ value: 'fast', label: 'Fast' }] }; + expect(pluginSettingsFieldSchema.parse(field).options).toEqual([{ value: 'fast', label: 'Fast' }]); + }); + + it('requires key — a field without one cannot be rendered or saved', () => { + expect(pluginSettingsFieldSchema.safeParse({ label: 'orphan' }).success).toBe(false); + }); +}); + +describe('instance-config wire shapes', () => { + it('GET response carries the form fields alongside the (masked) values', () => { + const res = { + fields: [ + { + key: 'apiUrl', + label: null, + input_type: 'text', + placeholder: null, + hint: null, + required: true, + secret: false, + }, + ], + config: { apiUrl: 'https://x.example', apiKey: '••••••••' }, + }; + expect(pluginInstanceConfigResponseSchema.parse(res).fields).toHaveLength(1); + }); + + it('PUT body is the config record itself — any keys, unknown values', () => { + expect(pluginInstanceConfigUpdateSchema.parse({ apiUrl: 'x', retries: 3, on: true })).toEqual({ + apiUrl: 'x', + retries: 3, + on: true, + }); + }); + + it('PUT body refuses a non-object (the handler never sees garbage shapes)', () => { + expect(pluginInstanceConfigUpdateSchema.safeParse('not a config').success).toBe(false); + expect(pluginInstanceConfigUpdateSchema.safeParse([1, 2]).success).toBe(false); + }); + + it('PUT response reports whether the save restarted a running plugin', () => { + expect(pluginInstanceConfigUpdatedSchema.parse({ config: {}, restarted: true }).restarted).toBe(true); + expect(pluginInstanceConfigUpdatedSchema.safeParse({ config: {} }).success).toBe(false); + }); +}); + +/** + * The admin body contracts, written to DESCRIBE rather than to tighten (the + * journey-contract doctrine, #1842): fields optional, objects loose, so every + * handler keeps the rejection it already owns ('id is required', 503-before-body, + * hosts-clears-on-non-array). These pin exactly that looseness — a "helpful" + * tightening of one of these schemas is a behavior change on the wire. + */ +describe('admin body contracts stay loose by doctrine', () => { + it("install: an empty body parses — `id is required` stays the HANDLER's answer", () => { + expect(pluginInstallRequestSchema.safeParse({}).success).toBe(true); + expect( + pluginInstallRequestSchema.parse({ id: 'p', version: '1.0.0', constraint: '^1', withDependencies: true }), + ).toMatchObject({ id: 'p', withDependencies: true }); + }); + + it('install: declared fields still pin their TYPES', () => { + expect(pluginInstallRequestSchema.safeParse({ withDependencies: 'yes' }).success).toBe(false); + }); + + it('link / activate / uninstall / retrust / update: empty bodies parse', () => { + for (const s of [ + pluginLinkRequestSchema, + pluginActivateRequestSchema, + pluginUninstallRequestSchema, + pluginRetrustRequestSchema, + pluginUpdateRequestSchema, + ]) { + expect(s.safeParse({}).success).toBe(true); + } + }); + + it('egress hosts: a non-array `hosts` parses — that is the documented clear/reset path', () => { + expect(pluginEgressHostsRequestSchema.safeParse({}).success).toBe(true); + expect(pluginEgressHostsRequestSchema.safeParse({ hosts: null }).success).toBe(true); + expect(pluginEgressHostsRequestSchema.safeParse({ hosts: ['a.example'] }).success).toBe(true); + }); + + it('user settings update: a non-object `config` parses — the handler answers 200, not 400', () => { + expect(pluginUserSettingsUpdateRequestSchema.safeParse({ config: 'garbage' }).success).toBe(true); + expect(pluginUserSettingsUpdateRequestSchema.safeParse({}).success).toBe(true); + }); + + it('route: any object parses — the handler answers { route: null }, never a 400', () => { + expect(pluginRouteRequestSchema.safeParse({ anything: [1, 2, 3] }).success).toBe(true); + }); +}); diff --git a/shared/src/plugins/plugins.schema.ts b/shared/src/plugins/plugins.schema.ts new file mode 100644 index 000000000..79c9710c5 --- /dev/null +++ b/shared/src/plugins/plugins.schema.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; + +/** + * Plugin settings contracts (#plugins). + * + * A plugin declares its settings fields in its manifest (validated at install, + * stored in plugin_settings_fields); the server serves them back as form + * metadata and both settings forms — the user Settings tab (`scope:'user'`) + * and the admin instance-settings dialog (`scope:'instance'`) — render from + * this ONE descriptor shape. Nullable-vs-optional is deliberate: the server + * emits SQL NULLs for absent labels/placeholders/hints, and the forms branch + * on them (`f.label || f.key`). + * + * Shared Zod pins SHAPE only. Which keys a plugin may store, secret + * encryption, and the mask sentinel stay server-side (plugins.service.ts) — + * the settled two-layer pattern. + */ +export const pluginSettingsFieldSchema = z.object({ + key: z.string(), + label: z.string().nullish(), + /** 'text' | 'number' | 'checkbox' | 'select' | … — server defaults absent to 'text'. */ + input_type: z.string().optional(), + placeholder: z.string().nullish(), + hint: z.string().nullish(), + required: z.boolean().optional(), + secret: z.boolean().optional(), + options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), +}); +export type PluginSettingsField = z.infer; + +/** GET /api/admin/plugins/:id/config — the declared `scope:'instance'` fields + * plus the stored values, secrets masked. */ +export const pluginInstanceConfigResponseSchema = z.object({ + fields: z.array(pluginSettingsFieldSchema), + config: z.record(z.string(), z.unknown()), +}); +export type PluginInstanceConfigResponse = z.infer; + +/** PUT /api/admin/plugins/:id/config body — the config record itself. Loose on + * purpose (journey-contract doctrine): the service drops undeclared keys and + * keeps every decision it already makes, so the schema only refuses non-objects. */ +export const pluginInstanceConfigUpdateSchema = z.record(z.string(), z.unknown()); +export type PluginInstanceConfigUpdate = z.infer; + +/** PUT /api/admin/plugins/:id/config response — the saved (masked) config, and + * whether the save re-spawned a RUNNING plugin (its child reads config once, at init). */ +export const pluginInstanceConfigUpdatedSchema = z.object({ + config: z.record(z.string(), z.unknown()), + restarted: z.boolean(), +}); +export type PluginInstanceConfigUpdated = z.infer; + +/** + * Body contracts for the admin plugin surfaces, written to DESCRIBE rather than + * to tighten (the journey-contract doctrine, #1842). + * + * Every one of these handlers already owns its own rejection, and several of + * them answer something other than 400 for a body they dislike: activate and + * install answer 503 when plugins are disabled, and they check that BEFORE + * looking at the body. A schema that rejected first would move a 503 to a 400 + * for an operator whose server has plugins off. So the fields stay optional and + * the objects stay loose: the schema pins the wire TYPES, and the handler keeps + * every decision it already made, in the order it already made it. + */ + +/** POST /api/admin/plugins/install — `id is required` stays the handler's answer. */ +export const pluginInstallRequestSchema = z.looseObject({ + id: z.string().optional(), + version: z.string().optional(), + constraint: z.string().optional(), + withDependencies: z.boolean().optional(), +}); +export type PluginInstallRequest = z.infer; + +/** POST /api/admin/plugins/link — `path is required` likewise, and dev-link answers 403 before reading it. */ +export const pluginLinkRequestSchema = z.looseObject({ path: z.string().optional() }); +export type PluginLinkRequest = z.infer; + +/** POST /api/admin/plugins/:id/activate */ +export const pluginActivateRequestSchema = z.looseObject({ consent: z.boolean().optional() }); +export type PluginActivateRequest = z.infer; + +/** POST /api/admin/plugins/:id/uninstall */ +export const pluginUninstallRequestSchema = z.looseObject({ deleteData: z.boolean().optional() }); +export type PluginUninstallRequest = z.infer; + +/** POST /api/admin/plugins/:id/retrust */ +export const pluginRetrustRequestSchema = z.looseObject({ + version: z.string().optional(), + publicKey: z.string().optional(), +}); +export type PluginRetrustRequest = z.infer; + +/** + * POST /api/admin/plugins/:id/update — `version` pins the exact version to + * install (the rollback path). Omitted, the runtime resolves the newest + * TREK-compatible version itself (the classic update). + */ +export const pluginUpdateRequestSchema = z.looseObject({ version: z.string().optional() }); +export type PluginUpdateRequest = z.infer; + +/** + * PUT /api/admin/plugins/:id/egress-hosts — `hosts` is deliberately unknown, + * not `string[]`. + * + * The handler reads `Array.isArray(body.hosts) ? body.hosts.map(String) : []`, + * which means an omitted or non-array `hosts` CLEARS the operator egress list. + * That is the documented way to reset it, so a schema demanding an array would + * not tighten validation, it would remove a working admin action. + */ +export const pluginEgressHostsRequestSchema = z.looseObject({ hosts: z.unknown().optional() }); +export type PluginEgressHostsRequest = z.infer; + +/** + * POST /api/plugin-settings/:id — `config` is unknown rather than a record: the + * handler answers 200 with the stored config for anything it cannot use, + * including a non-object, and a schema that rejected first would turn that + * into a 400. + */ +export const pluginUserSettingsUpdateRequestSchema = z.looseObject({ config: z.unknown().optional() }); +export type PluginUserSettingsUpdateRequest = z.infer; + +/** + * POST /api/plugins/:id/route — the route hook answers `{ route: null }` at 200 + * for every input it dislikes: a missing trip, a bad profile, unusable + * waypoints, a slow provider. The client draws straight lines on a null and + * shows nothing on a 400, so a schema that rejected a malformed body would turn + * a graceful fallback into a visible error. Loose and fully optional on purpose. + */ +export const pluginRouteRequestSchema = z.looseObject({}); +export type PluginRouteRequest = z.infer;