From 394201cd32ea170ae307ad5b57e0231767ddfcce Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Tue, 1 Sep 2026 06:45:24 +0200 Subject: [PATCH] feat(plugins): instance-wide settings editor in the admin UI (#2170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump version to 4.1.0 [skip ci] * fix(collections): stop findDuplicateCollectionPlace merging places by coordinates alone Three places answered "is this a place the trip already has?", not two: PlacesService.findDuplicatePlace and isPlaceDuplicate got the shared strategy list in #2130; CollectionsService.findDuplicateCollectionPlace was missed and kept its own hand-rolled copy of the pre-#2130 order. Unlike findDuplicatePlace, this one had no isPlaceDuplicate() guard in front of it — savePlace calls it directly — so the coordinate fallback for a NAMED candidate was live, not latent: two distinct places sharing an address (a restaurant and the bar upstairs) would report each other as duplicates the moment their coordinates round to the same ~11 m box, regardless of name. It also never read google_place_id/google_ftid/osm_id at all, even though every collection_places row stores them, so a place renamed since it was saved could be saved again instead of being recognised by its provider id. Now walks placeMatchStrategies() from @trek/shared, same as findMatchingPlaceId: provider id, then name, then coordinates only when there is no name. savePlace passes the provider id fields through since they are already on the request body; the two bulk call sites (importablePlaces, saveFromTripPlaces) are unchanged, since their source rows don't carry provider ids to pass. Closes #2137 * fix(collections): ask the same question at all three call sites savePlace was not the only caller of findDuplicateCollectionPlace. The bulk copy already carries google_place_id/google_ftid/osm_id into the row it writes but asked without them, and the import picker never selected them at all, so a renamed place was recognised on one path and not on the other two. The picker answering differently from the import is the drift the method exists to prevent: a row shown as new would come back refused. COLLECTIONS-SVC-102 and -103 cover the bulk copy and the picker. * fix(map): give the Leaflet map its own zoom ceiling, so a vector basemap cannot take the planner down Opening a trip on 4.1.0 could land on the error boundary with "Map has no maxZoom specified", and so could the map settings tab, which left no screen from which to pick a different basemap. Leaflet answers getMaxZoom() from the map options first and otherwise from a layer that brought one, and only a GridLayer ever contributes via its beforeAdd hook. A vector basemap is a GL canvas on a plain L.Layer, so it contributes nothing, and MapContainer set no ceiling of its own. MarkerClusterGroup.onAdd refuses an infinite one by throwing, before any clustering, so an empty trip hit it too. That path opened when the default basemap became a MapLibre style. It reaches further than an operator choosing OpenFreeMap: resolveTileUrl sends a keyless CARTO template to the app default, which is now a vector style, so every instance still carrying the old CARTO basemap fell into it on upgrade. The ceiling belongs on the map rather than on the base layer, because it has to hold whichever of the three branches renders. 19 matches what the raster and satellite layers already carried, so nothing changes for the maps that worked. SharedTripPage gets the same treatment: it has no cluster to throw, but it draws the same vector basemap and its fitBounds asks for a ceiling. mapZoomCeiling.test.ts pins Leaflet's own semantics against the real library rather than a mock, since a mocked react-leaflet would have kept passing through all of this. * fix(map): put the CARTO key back on the template the settings preview draws The map settings fields hold what the user is editing rather than what useTileUrl already resolved, so the preview received a bare template with no key on it. resolveTileUrl then read it as a keyless CARTO url and did what it is meant to do with one: fall back to the app default. The preview drew OpenFreeMap while the fields said CARTO, whether or not a key was saved. Same reason the tab crashed for anyone on a CARTO basemap even after entering a key, which is the loop that made this hard to escape: the key is entered on the page the fallback had taken down. FE-COMP-MAP-033b pins the template the preview is handed, with and without a key. * fix(admin): put the CARTO key back on the default-settings map preview too Same shape as the user-facing map tab, and reported alongside it: the field holds what the admin is editing, so the preview resolved a keyless CARTO template and drew the app default instead of the basemap being set. * fix(mobile): put the CARTO key back on the phone map settings preview too The third preview with the same shape, and the one that matters most while the crash is live: the phone layout is where someone would go to change the basemap when the desktop tab will not open. * fix(calendar): a fully timed stay is its two hand-overs, not a week-long bar Closes #2136 A stay was always exported as an all-day block across its whole range, and the check-in and check-out markers came on top of it. When the stay records both ends of its clock the markers already say everything the block does, and the block is the half nobody can act on: it takes a week of calendar to repeat what two one-hour events state precisely. Same shape as the parking and rental windows in #2068. Only where the markers actually stand in for the booking, though. They are emitted once per stay and titled from its lowest-id reservation, so a second room on the same stay would otherwise be left with nothing at all. A stay that knows only one end keeps its block too, since nothing else carries the other end's date. CAL-025b covers the drop, -025c the one-sided stay, -025d the second room. * fix(mobile): the phone admin map preview needs the CARTO key too The fourth preview with the same shape, missed when the other three were fixed. Sonar pointed at it: the desktop and phone admin pages are near-identical, so the duplication report named the file the change had not reached. * chore: bump version to 4.1.1 [skip ci] * feat(plugins): admin editor for instance-wide plugin settings The scope:'instance' settings a plugin declares had API endpoints but no UI. - shared: plugins.schema.ts — the settings-field descriptor and the admin instance-config GET/PUT wire contracts, imported by both sides - server: GET /api/admin/plugins/:id/config now returns the declared fields alongside the (masked) values; PUT re-spawns an ACTIVE plugin (its child reads config once, at init) and reports it via 'restarted'; the admin list carries instanceSettingsCount so the UI gates its menu item without a fetch - client: 'Instance settings' row action opening a form modal (desktop) / MSheet (phone) in both admin shells — checkbox/select/secret rendering as in the user settings form; an untouched secret mask is never sent back - i18n: three new admin.plugins.* keys in every locale * refactor(plugins): move the admin body contracts to @trek/shared The remaining plugin DTO schemas (install, link, activate, uninstall, retrust, update, egress-hosts, user-settings update, route) now live in shared/src/plugins/plugins.schema.ts with their inferred types, alongside the instance-config contracts. The journey-contract doctrine notes (#1842) moved with them — each body stays deliberately loose so the handlers keep the rejections and fallbacks they already own, and new spec tests pin that looseness so a well-meant tightening breaks a test that explains itself. plugins.dto.ts is now thin createZodDto wrappers over the shared schemas (same class names, no controller changes), and the client's pluginInstall types its options and body against PluginInstallRequest. * fix(admin): offer Allowed hosts only to plugins that declared operatorEgress The row's egress chip was already gated on the declaration, but the kebab menu (desktop) and row action sheet (phone) offered Allowed hosts on every installed plugin — inviting the admin to widen egress for plugins that never asked for it. Both entries now render only when the plugin declares operatorEgress; the dialog's unsupported-notice fallback stays as the truth-teller for stale list data. * test(admin): close the Sonar gaps on the instance-settings form The PR gate flagged new-code coverage (76.4%) and duplication (10.9%). - The two identical 39-line handler blocks were logic, not markup — they move into one shared useInstanceSettings hook; each shell keeps its own markup (the deliberate desktop/phone divergence stays) - New tests per shell cover the untested branches: every field type renders and round-trips into the save payload (select, checkbox/MToggle, number, hint, no-label key fallback), a failed fetch is a toast, a rejected save shows the server reason and keeps the edits, and the dialog/sheet closes without saving - INS-009 covers the controller's no-body '|| {}' fallback --------- Co-authored-by: github-actions[bot] Co-authored-by: michael-bohr <263459669+michael-bohr@users.noreply.github.com> Co-authored-by: Maurice --- charts/trek/Chart.yaml | 4 +- client/package.json | 2 +- client/src/api/client.surface.test.ts | 2 + client/src/api/client.ts | 23 ++- .../Admin/AdminPluginsPanel.test.tsx | 157 ++++++++++++++++- .../components/Admin/AdminPluginsPanel.tsx | 84 ++++++++- .../components/Admin/useInstanceSettings.ts | 71 ++++++++ .../screens/admin/MAdminPluginsPanel.tsx | 78 ++++++++- .../mobile/admin/MAdminPluginsPanel.test.tsx | 152 +++++++++++++++- package-lock.json | 10 +- package.json | 2 +- server/package.json | 2 +- .../nest/plugins/plugin-runtime.service.ts | 13 ++ server/src/nest/plugins/plugins.controller.ts | 15 +- server/src/nest/plugins/plugins.dto.ts | 99 +++-------- server/src/nest/plugins/plugins.service.ts | 46 ++++- .../plugins/plugin-instance-settings.test.ts | 165 ++++++++++++++++++ .../unit/plugins/plugins-service.test.ts | 9 +- shared/package.json | 2 +- shared/src/i18n/ar/admin.ts | 3 + shared/src/i18n/br/admin.ts | 3 + shared/src/i18n/ca/admin.ts | 3 + shared/src/i18n/cs/admin.ts | 3 + shared/src/i18n/de/admin.ts | 3 + shared/src/i18n/en/admin.ts | 3 + shared/src/i18n/es/admin.ts | 3 + shared/src/i18n/fr/admin.ts | 3 + shared/src/i18n/gr/admin.ts | 3 + shared/src/i18n/hu/admin.ts | 3 + shared/src/i18n/id/admin.ts | 3 + shared/src/i18n/it/admin.ts | 3 + shared/src/i18n/ja/admin.ts | 3 + shared/src/i18n/ko/admin.ts | 3 + shared/src/i18n/nl/admin.ts | 3 + shared/src/i18n/pl/admin.ts | 3 + shared/src/i18n/ru/admin.ts | 3 + shared/src/i18n/sv/admin.ts | 3 + shared/src/i18n/tr/admin.ts | 3 + shared/src/i18n/uk/admin.ts | 3 + shared/src/i18n/vi/admin.ts | 3 + shared/src/i18n/zh-TW/admin.ts | 3 + shared/src/i18n/zh/admin.ts | 3 + shared/src/index.ts | 2 + shared/src/plugins/plugins.schema.spec.ts | 133 ++++++++++++++ shared/src/plugins/plugins.schema.ts | 131 ++++++++++++++ 45 files changed, 1153 insertions(+), 118 deletions(-) create mode 100644 client/src/components/Admin/useInstanceSettings.ts create mode 100644 server/tests/unit/plugins/plugin-instance-settings.test.ts create mode 100644 shared/src/plugins/plugins.schema.spec.ts create mode 100644 shared/src/plugins/plugins.schema.ts 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;