diff --git a/client/src/components/Admin/AdminPluginsPanel.test.tsx b/client/src/components/Admin/AdminPluginsPanel.test.tsx index 51f179f81..7a68862db 100644 --- a/client/src/components/Admin/AdminPluginsPanel.test.tsx +++ b/client/src/components/Admin/AdminPluginsPanel.test.tsx @@ -2297,4 +2297,50 @@ describe('AdminPluginsPanel — instance settings', () => { expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument() }) + + it('FE-COMP-PLUGINS-CFG-009: pre-fills a declared default when no value is stored', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, secret: false, default: 'https://auth.openbnb.org/authorize' }], + config: {}, + })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize', {}, { timeout: 5000 })).toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-010: a stored value wins over the default', async () => { + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: false, secret: false, default: 'https://auth.openbnb.org/authorize' }], + config: { oauth_authorize_url: 'https://mine.example' }, + })), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + + expect(await screen.findByDisplayValue('https://mine.example', {}, { timeout: 5000 })).toBeInTheDocument() + expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument() + }) + + it('FE-COMP-PLUGINS-CFG-011: blocks Save while a required field is empty and names it', async () => { + let called = false + server.use( + http.get('*/api/admin/plugins/trek-gotify/config', () => HttpResponse.json({ + fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true, secret: false }], + config: {}, + })), + http.put('*/api/admin/plugins/trek-gotify/config', () => { called = true; return HttpResponse.json({ config: {}, restarted: false }) }), + ) + await openRowMenu(plugin({ instanceSettingsCount: 1 })) + fireEvent.click(screen.getByText('Instance settings')) + await screen.findByText('Client ID', {}, { timeout: 5000 }) + + fireEvent.click(screen.getByRole('button', { name: /^save$/i })) + + expect(await screen.findByText('"Client ID" is required', {}, { timeout: 5000 })).toBeInTheDocument() + expect(called).toBe(false) + }) }) diff --git a/client/src/components/Admin/useInstanceSettings.ts b/client/src/components/Admin/useInstanceSettings.ts index 2b0dc5581..5ac6bdbb9 100644 --- a/client/src/components/Admin/useInstanceSettings.ts +++ b/client/src/components/Admin/useInstanceSettings.ts @@ -2,9 +2,9 @@ import { useState } from 'react' import { adminApi, type PluginUserSettingField } from '../../api/client' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' +import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm' /** The server's stand-in for a stored secret — a display artifact, never a value to send back. */ -export const SECRET_MASK = '••••••••' export interface InstanceSettingsForm { id: string @@ -31,12 +31,7 @@ export function useInstanceSettings() { 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 }) + setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config) }) }) .catch(() => toast.error(t('common.error'))) } @@ -48,15 +43,14 @@ export function useInstanceSettings() { const save = async () => { if (!form) return + const missing = findMissingRequired(form.fields, form.values) + if (missing) { + setError(t('admin.plugins.requiredMissing', { field: missing.label || missing.key })) + 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) + const d = await adminApi.pluginSaveConfig(form.id, settingsPatch(form.fields, form.values)) toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) setForm(null) } catch (e) { diff --git a/client/src/components/Plugins/settingsForm.test.ts b/client/src/components/Plugins/settingsForm.test.ts new file mode 100644 index 000000000..7206be643 --- /dev/null +++ b/client/src/components/Plugins/settingsForm.test.ts @@ -0,0 +1,58 @@ +/** + * The one set of rules the three plugin-settings forms (admin instance form, desktop user + * tab, mobile user screen) share: how a form is seeded from stored values + manifest + * defaults, which required fields block Save, and what a save patch carries. + */ +import { describe, expect, it } from 'vitest'; +import type { PluginUserSettingField } from '../../api/client'; +import { findMissingRequired, SECRET_MASK, seedSettingsValues, settingsPatch } from './settingsForm'; + +const f = (over: Partial & { key: string }): PluginUserSettingField => + ({ label: over.key, input_type: 'text', required: false, secret: false, ...over }) as PluginUserSettingField; + +describe('seedSettingsValues', () => { + it('seeds a declared default when nothing is stored, and a stored value wins', () => { + const fields = [ + f({ key: 'url', default: 'https://d.example' }), + f({ key: 'retries', input_type: 'number', default: 3 }), + ]; + expect(seedSettingsValues(fields, {})).toEqual({ url: 'https://d.example', retries: '3' }); + expect(seedSettingsValues(fields, { url: 'https://mine.example' })).toEqual({ + url: 'https://mine.example', + retries: '3', + }); + }); + it('coerces a checkbox to a boolean (default or stored) and everything else to a string', () => { + const fields = [ + f({ key: 'on', input_type: 'checkbox', default: true }), + f({ key: 'off', input_type: 'checkbox' }), + f({ key: 'n' }), + ]; + expect(seedSettingsValues(fields, {})).toEqual({ on: true, off: false, n: '' }); + expect(seedSettingsValues(fields, { on: false, n: 5 })).toEqual({ on: false, off: false, n: '5' }); + }); +}); + +describe('findMissingRequired', () => { + it('names the first required non-checkbox field that is blank (whitespace counts as blank)', () => { + const fields = [f({ key: 'a', required: true }), f({ key: 'b', label: 'Bee', required: true })]; + expect(findMissingRequired(fields, { a: 'x', b: ' ' })?.key).toBe('b'); + expect(findMissingRequired(fields, { a: 'x', b: 'y' })).toBeUndefined(); + }); + it('exempts a required checkbox and treats an untouched secret (the mask) as filled', () => { + const fields = [ + f({ key: 'consent', input_type: 'checkbox', required: true }), + f({ key: 'tok', secret: true, required: true }), + ]; + expect(findMissingRequired(fields, { consent: false, tok: SECRET_MASK })).toBeUndefined(); + expect(findMissingRequired(fields, { consent: false, tok: '' })?.key).toBe('tok'); + }); +}); + +describe('settingsPatch', () => { + it('sends every field except an untouched secret still showing the mask', () => { + const fields = [f({ key: 'tok', secret: true }), f({ key: 'url' }), f({ key: 'on', input_type: 'checkbox' })]; + expect(settingsPatch(fields, { tok: SECRET_MASK, url: 'u', on: true })).toEqual({ url: 'u', on: true }); + expect(settingsPatch(fields, { tok: 'new', url: 'u', on: false })).toEqual({ tok: 'new', url: 'u', on: false }); + }); +}); diff --git a/client/src/components/Plugins/settingsForm.ts b/client/src/components/Plugins/settingsForm.ts new file mode 100644 index 000000000..111f4d4c2 --- /dev/null +++ b/client/src/components/Plugins/settingsForm.ts @@ -0,0 +1,58 @@ +/** + * Shared rules for the plugin-settings forms — the admin instance form + * (useInstanceSettings), the desktop user tab (PluginSettingsTab) and the mobile user + * screen (MSettingsPlugins) all render the same `fields` + `config` contract and used to + * each hand-copy these loops, which is how the mobile screen missed `default` and the + * required check. + */ +import type { PluginUserSettingField } from '../../api/client'; + +/** What the server echoes in place of a stored secret; sending it back means "unchanged". */ +export const SECRET_MASK = '••••••••'; + +export type SettingsValues = Record; + +/** + * Form draft from the stored config, falling back to each field's manifest `default` + * when nothing is stored (the same `stored ?? default` rule the runtime applies, so the + * form shows the value the plugin actually gets). Checkboxes become booleans, everything + * else a string. + */ +export function seedSettingsValues(fields: PluginUserSettingField[], config: Record): SettingsValues { + const values: SettingsValues = {}; + for (const f of fields) { + const stored = config[f.key]; + const seeded = stored === undefined || stored === null ? f.default : stored; + values[f.key] = f.input_type === 'checkbox' ? seeded === true : seeded == null ? '' : String(seeded); + } + return values; +} + +/** + * The first `required` field the draft leaves blank, or undefined when Save may proceed. + * Mirrors the server gate: a checkbox is exempt (required would mean "must be true", a + * consent flow, not a setting), whitespace is blank, an untouched secret (still the mask) + * is filled. + */ +export function findMissingRequired( + fields: PluginUserSettingField[], + values: SettingsValues +): PluginUserSettingField | undefined { + return fields.find((f) => { + if (!f.required || f.input_type === 'checkbox') return false; + const v = values[f.key]; + if (f.secret && v === SECRET_MASK) return false; + return String(v ?? '').trim() === ''; + }); +} + +/** The save body: every field's draft value, minus an untouched secret so the mask never overwrites the stored ciphertext. */ +export function settingsPatch(fields: PluginUserSettingField[], values: SettingsValues): Record { + const patch: Record = {}; + for (const f of fields) { + const v = values[f.key]; + if (f.secret && v === SECRET_MASK) continue; + patch[f.key] = v; + } + return patch; +} diff --git a/client/src/components/Settings/PluginSettingsTab.test.tsx b/client/src/components/Settings/PluginSettingsTab.test.tsx index 820f35004..d206fa42d 100644 --- a/client/src/components/Settings/PluginSettingsTab.test.tsx +++ b/client/src/components/Settings/PluginSettingsTab.test.tsx @@ -448,4 +448,67 @@ describe('PluginSettingsTab', () => { expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument(); expect(within(cardFor('Notes')).getByText('Folder')).toBeInTheDocument(); }); + + it('FE-COMP-PLUGINSETTINGS-026: pre-fills a declared default when no value is stored', async () => { + serve('weather', { + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', required: true, default: 'https://auth.openbnb.org/authorize' }], + config: {}, + }); + setPlugins([plugin()]); + render(); + + expect(await screen.findByDisplayValue('https://auth.openbnb.org/authorize')).toBeInTheDocument(); + }); + + it('FE-COMP-PLUGINSETTINGS-027: a stored value wins over the default', async () => { + serve('weather', { + fields: [{ key: 'oauth_authorize_url', label: 'Authorize URL', input_type: 'text', default: 'https://auth.openbnb.org/authorize' }], + config: { oauth_authorize_url: 'https://mine.example' }, + }); + setPlugins([plugin()]); + render(); + + expect(await screen.findByDisplayValue('https://mine.example')).toBeInTheDocument(); + expect(screen.queryByDisplayValue('https://auth.openbnb.org/authorize')).not.toBeInTheDocument(); + }); + + it('FE-COMP-PLUGINSETTINGS-028: blocks Save while a required field is empty and names it', async () => { + const user = userEvent.setup(); + let called = false; + serve('weather', { + fields: [{ key: 'client_id', label: 'Client ID', input_type: 'text', required: true }], + config: {}, + }); + server.use(http.post('/api/plugin-settings/weather', () => { + called = true; + return HttpResponse.json({ config: {} }); + })); + setPlugins([plugin()]); + render(<>); + + await screen.findByText('Client ID'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('"Client ID" is required')).toBeInTheDocument(); + expect(called).toBe(false); + }); + + it('FE-COMP-PLUGINSETTINGS-029: a 400 from the server names the field it refused; a 500 stays generic', async () => { + // The field list was loaded before a plugin update made `city` required, so the + // client pre-check passes and only the server knows — its message must reach the user. + const user = userEvent.setup(); + serve('weather', { + fields: [{ key: 'city', label: 'City', input_type: 'text' }], + config: {}, + }); + server.use(http.post('/api/plugin-settings/weather', () => + HttpResponse.json({ error: 'Missing required setting "city"' }, { status: 400 }))); + setPlugins([plugin()]); + render(<>); + + await screen.findByText('City'); + await user.click(screen.getByRole('button', { name: 'Save' })); + + expect(await screen.findByText('Missing required setting "city"')).toBeInTheDocument(); + }); }); diff --git a/client/src/components/Settings/PluginSettingsTab.tsx b/client/src/components/Settings/PluginSettingsTab.tsx index 25d24499b..4061303f7 100644 --- a/client/src/components/Settings/PluginSettingsTab.tsx +++ b/client/src/components/Settings/PluginSettingsTab.tsx @@ -7,6 +7,7 @@ import { usePluginStore } from '../../store/pluginStore' import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import PluginActivityPanel from './PluginActivityPanel' +import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm' /** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow + * holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */ @@ -51,7 +52,6 @@ function PluginOAuthSection({ id, state, setState }: { ) } -const SECRET_MASK = '••••••••' /** * A user's own per-plugin settings (#plugins). The host renders the plugin's @@ -77,12 +77,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon if (!alive) return setFields(r.fields) setActions(r.actions ?? []) - const init: Record = {} - for (const f of r.fields) { - const v = r.config[f.key] - init[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v)) - } - setValues(init) + setValues(seedSettingsValues(r.fields, r.config)) }) .catch(() => { if (alive) setFields([]) }) pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) }) @@ -109,25 +104,22 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon } const save = async () => { + const missing = findMissingRequired(fields, values) + if (missing) { + toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key })) + return + } setSaving(true) try { - // Skip an untouched secret (still shows the mask) so we never overwrite it with the mask. - const patch: Record = {} - for (const f of fields) { - const v = values[f.key] - if (f.secret && v === SECRET_MASK) continue - patch[f.key] = v - } - const r = await pluginsApi.saveUserSettings(id, patch) - const next: Record = {} - for (const f of fields) { - const v = r.config[f.key] - next[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v)) - } - setValues(next) + const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values)) + setValues(seedSettingsValues(fields, r.config)) toast.success(t('settings.plugins.saved')) - } catch { - toast.error(t('common.error')) + } catch (e) { + // A 4xx names what the server refused (a required field it knows about and this + // stale field list doesn't); a 5xx body is not for the user. + const err = e as { response?: { status?: number; data?: { error?: string } } } + const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined + toast.error(refused || t('common.error')) } finally { setSaving(false) } diff --git a/client/src/mobile/screens/settings/MSettingsPlugins.tsx b/client/src/mobile/screens/settings/MSettingsPlugins.tsx index 8fd2c4498..4dee05e96 100644 --- a/client/src/mobile/screens/settings/MSettingsPlugins.tsx +++ b/client/src/mobile/screens/settings/MSettingsPlugins.tsx @@ -17,6 +17,7 @@ import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint } import MToggle from '../../components/MToggle' import MConfirmSheet from './MConfirmSheet' import MSetPickerSheet from './MSetPickerSheet' +import { seedSettingsValues, findMissingRequired, settingsPatch } from '../../../components/Plugins/settingsForm' /** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow + * holds the tokens; this only triggers connect (redirect to the provider) / disconnect. */ @@ -61,7 +62,6 @@ function PluginOAuthSection({ id, state, setState }: { ) } -const SECRET_MASK = '••••••••' /** * A user's own per-plugin settings (#plugins). The host renders the plugin's @@ -90,12 +90,7 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon if (!alive) return setFields(r.fields) setActions(r.actions ?? []) - const init: Record = {} - for (const f of r.fields) { - const v = r.config[f.key] - init[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v)) - } - setValues(init) + setValues(seedSettingsValues(r.fields, r.config)) }) .catch(() => { if (alive) setFields([]) }) pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) }) @@ -126,25 +121,22 @@ function PluginSettingsForm({ id, name, icon }: { id: string; name: string; icon } const save = async () => { + const missing = findMissingRequired(fields, values) + if (missing) { + toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key })) + return + } setSaving(true) try { - // Skip an untouched secret (still shows the mask) so we never overwrite it with the mask. - const patch: Record = {} - for (const f of fields) { - const v = values[f.key] - if (f.secret && v === SECRET_MASK) continue - patch[f.key] = v - } - const r = await pluginsApi.saveUserSettings(id, patch) - const next: Record = {} - for (const f of fields) { - const v = r.config[f.key] - next[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v)) - } - setValues(next) + const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values)) + setValues(seedSettingsValues(fields, r.config)) toast.success(t('settings.plugins.saved')) - } catch { - toast.error(t('common.error')) + } catch (e) { + // A 4xx names what the server refused (a required field it knows about and this + // stale field list doesn't); a 5xx body is not for the user. + const err = e as { response?: { status?: number; data?: { error?: string } } } + const refused = err.response?.status && err.response.status < 500 ? err.response.data?.error : undefined + toast.error(refused || t('common.error')) } finally { setSaving(false) } diff --git a/plugin-sdk/README.md b/plugin-sdk/README.md index cefa007e4..4c00de9fe 100644 --- a/plugin-sdk/README.md +++ b/plugin-sdk/README.md @@ -127,6 +127,39 @@ Your plugin runs in an **isolated child process**. `ctx` is the only way to reac TREK, and it grants exactly the permissions your `trek-plugin.json` declares — an ungranted call throws `PERMISSION_DENIED`. +## Settings + +Declare settings in `trek-plugin.json`; TREK renders the form, you write no settings UI. + +```json +"settings": [ + { "key": "api_url", "label": "API URL", "default": "https://api.example", "required": true }, + { "key": "retries", "label": "Retries", "input_type": "number", "default": 3 }, + { "key": "api_key", "label": "API key", "scope": "user", "secret": true, "required": true }, + { "key": "region", "label": "Region", "input_type": "select", + "options": [{ "value": "eu", "label": "EU" }, { "value": "us", "label": "US" }], "default": "eu" } +] +``` + +| Attribute | Meaning | +|---|---| +| `key` | Identifier (`^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$`). | +| `scope` | `instance` (default) — set once by the admin, arrives in `ctx.config`. `user` — per user, read with `await ctx.settings.get(key)`. | +| `input_type` | `text` (default), `password`, `number`, `checkbox`, `select` (with `options`). | +| `default` | The field's value wherever nobody set one. The form pre-fills it **and the runtime resolves it** — `ctx.config.api_url` / `ctx.settings.get('region')` return it until someone saves something else — so a plugin with sensible defaults works before anyone opens the form. Satisfies `required`. Not allowed on a `secret` (the manifest is public); must be a boolean on a `checkbox` and one of the `options` when declared. `trek-plugin dev` seeds its fixtures from these too. | +| `required` | Enforced: the form refuses Save while the field is blank, and the host answers `400 { error: 'Missing required setting ""' }` if a save reaches it anyway. A `checkbox` is exempt (that would be consent, not a setting). A required `scope:'user'` field also gates whether a notification channel dispatches to that user. | +| `secret` | Encrypted at rest, decrypted only into the server-side `ctx.config` / `ctx.settings`, masked to the browser, never sent to the iframe. | +| `label`, `placeholder`, `hint` | Form text. | +| `options` | `[{ "value", "label" }]` for a `select` (bare strings are accepted and coerced). | +| `oauth` | Descriptive metadata only — for a host-brokered flow see the OAuth broker below. | + +Any other attribute is **silently dropped at install**. `trek-plugin validate` warns on +one (`manifest.settings-known-keys`) so a typo like `"defalt"` doesn't quietly do nothing. + +`ctx.config` is frozen at activation — the host re-spawns the plugin when the admin +saves. `ctx.settings.get()` is live per call and returns `undefined` in a userless +context (`onLoad`, jobs, scheduler) — fall back to `ctx.config` there. + ## Test without a running TREK ```js @@ -287,6 +320,7 @@ npx trek-plugin-sdk publish --repo you/repo --tag v1.1.0 --sign # or just answ - `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`). - `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest. - `validateManifest(json)` — the manifest rules the server loader uses. +- `settingDefaults(manifest, scope)` — the `default`s of one settings scope, keyed by field: what the host resolves for an unset field, and what `dev` seeds `ctx.config` / `ctx.settings` with. Spread it under your own fixtures when calling `createMockHost` to mirror the host. `SETTING_FIELD_KEYS` is the attribute list the host stores. - `createMockHost(opts)` (from `trek-plugin-sdk/testing`). - `TREK_UI_CSS`, `TREK_THEME_JS`, `TREK_UI_MARKER`, `injectTrekUi(html)` — the design kit, for authors who inline it themselves (a bundler, a custom build). Most plugins just use the `` marker instead. diff --git a/plugin-sdk/src/cli/checks/offline.ts b/plugin-sdk/src/cli/checks/offline.ts index 5d679a4aa..0c9a348d2 100644 --- a/plugin-sdk/src/cli/checks/offline.ts +++ b/plugin-sdk/src/cli/checks/offline.ts @@ -11,7 +11,7 @@ */ import { createHash } from 'node:crypto'; import path from 'node:path'; -import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS } from '../../manifest.js'; +import { validateManifest, isSatisfiableRange, isUnboundedRange, KNOWN_ADDONS, SETTING_FIELD_KEYS } from '../../manifest.js'; // The hosts a plugin can ACTUALLY reach. See `code.egress-reachable` for why this is the // only list that counts — and permissions.ts for why dev's egress guard uses the same one. import { grantedHosts } from '../../permissions.js'; @@ -216,6 +216,33 @@ const manifestAddonsKnown: OfflineCheck = { }, }; +const manifestSettingsKnownKeys: OfflineCheck = { + id: 'manifest.settings-known-keys', + stage: 'manifest', + depth: 'offline', + severity: 'warn', + title: 'settings fields carry only attributes the SDK knows', + run: (c) => { + const settings = c.manifest?.settings; + const fields = (Array.isArray(settings) ? settings : []).filter( + (s): s is Record => !!s && typeof s === 'object', + ); + if (!fields.length) return skip(); + const unknown: string[] = []; + for (const f of fields) { + for (const k of Object.keys(f)) { + if (!(SETTING_FIELD_KEYS as readonly string[]).includes(k)) unknown.push(`${String(f.key ?? '?')}.${k}`); + } + } + return unknown.length + ? fail( + unknown.join(', '), + `TREK stores only the attributes it knows and SILENTLY DROPS the rest at install — a typo ("defalt")\nor an attribute from a newer SDK simply does nothing, with no diagnostic anywhere.\nKnown: ${SETTING_FIELD_KEYS.join(', ')}.`, + ) + : pass(`${fields.length} field(s) clean`); + }, +}; + const manifestNoEmoji: OfflineCheck = { id: 'manifest.no-emoji', stage: 'manifest', @@ -592,6 +619,7 @@ export const OFFLINE_CHECKS: OfflineCheck[] = [ manifestTrekRange, manifestIdMatchesDir, manifestAddonsKnown, + manifestSettingsKnownKeys, manifestNoEmoji, codeServerEntry, codeEgressReachable, diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index 51716929b..1b52855ee 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -18,6 +18,7 @@ * Dependency-free: node:http + node:sqlite (when present) + built-ins. */ import fs from 'node:fs'; +import { settingDefaults } from '../manifest.js'; import path from 'node:path'; import http from 'node:http'; import { createRequire } from 'node:module'; @@ -292,6 +293,14 @@ function loadFixtures(dir: string): Fixtures { } /** Watch a directory tree with per-dir fs.watch (works on every platform, unlike recursive). */ +function withManifestDefaults(fx: Fixtures, manifest: unknown): Fixtures { + return { + ...fx, + config: { ...settingDefaults(manifest, 'instance'), ...(fx.config ?? {}) }, + userSettings: { ...settingDefaults(manifest, 'user'), ...(fx.userSettings ?? {}) }, + }; +} + function watchTree(root: string, onChange: () => void): void { const skip = new Set(['node_modules', '.git', '.trek-dev']); const watch = (d: string) => { @@ -310,7 +319,10 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise const manifest = readJsonFile>(manifestPath); const id = String(manifest.id); const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []); - const fx = loadFixtures(abs); + // Manifest `default`s are the host's effective value for anything unset, so seed the + // fixtures with them (a fixture value still wins) — otherwise a plugin relying on its + // defaults would read undefined here and only here. + const fx = withManifestDefaults(loadFixtures(abs), manifest); const { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite')); const broadcasts: unknown[] = []; installSdkInjection(); diff --git a/plugin-sdk/src/index.ts b/plugin-sdk/src/index.ts index 2f6b848ae..bb7df184b 100644 --- a/plugin-sdk/src/index.ts +++ b/plugin-sdk/src/index.ts @@ -30,10 +30,14 @@ export interface User { id: number; username?: string; display_name?: string | n * A legitimate plugin never hits the generous burst. See README § Runtime limits. */ export interface PluginContext { readonly id: string; + /** Your `scope:'instance'` settings as the admin saved them, secrets decrypted, frozen + * at activation (a save re-spawns you). A field nobody set resolves to its manifest + * `default` — only a field with neither is absent. */ readonly config: Readonly>; /** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields - * (decrypted host-side). Undefined for an unset value or a userless context (job/onLoad) - * — fall back to `config` (the admin-owned instance settings) there. */ + * (decrypted host-side), or the field's manifest `default` when they never set it. + * Undefined for a field with neither, and in a userless context (job/onLoad) — fall + * back to `config` (the admin-owned instance settings) there. */ settings: { get(key: string): Promise; }; @@ -849,6 +853,8 @@ export function definePlugin(def: PluginDefinition): PluginDefinition { export { validateManifest, + settingDefaults, + SETTING_FIELD_KEYS, CHANNEL_EVENTS, type PluginManifest, type NormalizedManifest, diff --git a/plugin-sdk/src/manifest.ts b/plugin-sdk/src/manifest.ts index 2667aa809..77ec1da58 100644 --- a/plugin-sdk/src/manifest.ts +++ b/plugin-sdk/src/manifest.ts @@ -21,6 +21,39 @@ export interface ManifestSettingField { scope?: 'instance' | 'user'; options?: Array; oauth?: { initPath?: string; callbackPath?: string }; + /** The field's value wherever nobody set one: the settings form pre-fills it AND the + * runtime resolves it (`ctx.config` / `ctx.settings.get()`), so the plugin works before + * anyone opens the form. Satisfies `required`. Not accepted on a `secret` field (the + * manifest is public); must be a boolean for a `checkbox` and one of `options` when + * those are declared. */ + default?: string | number | boolean; +} + +/** Every attribute a settings-field object may carry — the host silently drops anything else. + * Mirrors `SETTING_FIELD_KEYS` in the host's install/manifest.ts (parity-tested in + * test/permissions-parity.test.ts); change both together. */ +export const SETTING_FIELD_KEYS = [ + 'key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'scope', 'options', 'oauth', 'default', +] as const; +/** + * The `default`s a manifest declares for one settings scope, keyed by field — the + * host's effective value for any field nobody set. `trek-plugin dev` seeds `ctx.config` + * (instance) and `ctx.settings.get()` (user) from this so dev matches production. + * Secrets are skipped even if present (the host never resolves a secret from a default). + */ +export function settingDefaults(manifest: unknown, scope: 'instance' | 'user'): Record { + const settings = (manifest as { settings?: unknown } | null)?.settings; + const out: Record = {}; + if (!Array.isArray(settings)) return out; + for (const s of settings as unknown[]) { + if (!s || typeof s !== 'object') continue; + const f = s as Record; + if (typeof f.key !== 'string' || !f.key || f.secret === true) continue; + if ((f.scope === 'user' ? 'user' : 'instance') !== scope) continue; + const d = f.default; + if (typeof d === 'string' || typeof d === 'number' || typeof d === 'boolean') out[f.key] = d; + } + return out; } export interface ManifestAction { key: string; @@ -380,6 +413,23 @@ export function validateManifest(raw: unknown): ValidationResult { } } } + if (s.default !== undefined) { + const d = s.default; + if (typeof d !== 'string' && typeof d !== 'number' && typeof d !== 'boolean') { + errors.push(`settings["${key}"].default must be a string, number or boolean`); + } else if (s.secret === true) { + errors.push(`settings["${key}"].default is not allowed on a secret field (the manifest is public — it would ship the secret in plaintext)`); + } else if (s.input_type === 'checkbox' && typeof d !== 'boolean') { + errors.push(`settings["${key}"].default must be a boolean for a checkbox field`); + } else if (Array.isArray(s.options) && s.options.length > 0) { + const values = (s.options as unknown[]).map((o) => + o && typeof o === 'object' ? String((o as { value?: unknown }).value) : String(o), + ); + if (!values.includes(String(d))) { + errors.push(`settings["${key}"].default must be one of the declared options (${values.join(', ')})`); + } + } + } } } // Settings-page action buttons ("Test connection"). Keys share the settings-key rules. diff --git a/plugin-sdk/test/checks.test.ts b/plugin-sdk/test/checks.test.ts index b06ef883b..f5a6b5cc1 100644 --- a/plugin-sdk/test/checks.test.ts +++ b/plugin-sdk/test/checks.test.ts @@ -230,6 +230,20 @@ describe('what blocks what', () => { }); }); +describe('manifest.settings-known-keys', () => { + it('warns on an attribute the SDK does not know', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, settings: [{ key: 'api_url', defalt: 'oops' }] } })); + expect(warned(r, 'manifest.settings-known-keys')).toBe(true); + const outcome = r.outcomes.find((o) => o.id === 'manifest.settings-known-keys'); + expect(outcome?.detail).toMatch(/api_url\.defalt/); + }); + + it('passes when every attribute is known', () => { + const r = runOffline(ctx({ manifest: { ...GOOD_MANIFEST, settings: [{ key: 'api_url', default: 'https://x' }] } })); + expect(warned(r, 'manifest.settings-known-keys')).toBe(false); + }); +}); + describe('README helpers mirror the registry exactly', () => { it('strips headings, tables, links, images and code fences before measuring prose', () => { const md = '# Title\n\n![x](y.png)\n\n| a | b |\n|---|---|\n\n```js\nconst x = 1;\n```\n\n[link](u)\n'; diff --git a/plugin-sdk/test/manifest-settings.test.ts b/plugin-sdk/test/manifest-settings.test.ts index d6b287a44..dc65fa76f 100644 --- a/plugin-sdk/test/manifest-settings.test.ts +++ b/plugin-sdk/test/manifest-settings.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { validateManifest } from '../src/manifest.js'; +import { validateManifest, settingDefaults } from '../src/manifest.js'; const base = () => ({ id: 'my-plugin', name: 'My Plugin', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0' }); const withSetting = (extra: object) => ({ ...base(), settings: [{ key: 'mode', ...extra }] }); @@ -33,3 +33,53 @@ describe('validateManifest settings[].options / settings[].oauth', () => { expect(validateManifest(withSetting({ oauth: { initPath: '/oauth/init' } })).ok).toBe(true); }); }); + +describe('settings default', () => { + it('accepts a string/number/boolean default', () => { + expect(validateManifest(withSetting({ default: 'https://x' })).ok).toBe(true); + expect(validateManifest(withSetting({ default: 42 })).ok).toBe(true); + expect(validateManifest(withSetting({ default: true })).ok).toBe(true); + }); + it('carries the default through onto the normalized manifest', () => { + const r = validateManifest(withSetting({ default: 'https://x' })); + expect(r.manifest?.settings?.[0]?.default).toBe('https://x'); + }); + it('rejects an object default', () => { + const r = validateManifest(withSetting({ key: 'a', default: { nope: 1 } })); + expect(r.errors.join()).toMatch(/settings\["a"\]\.default must be a string, number or boolean/); + }); + it('rejects default on a secret field', () => { + const r = validateManifest(withSetting({ key: 'tok', secret: true, default: 'x' })); + expect(r.errors.join()).toMatch(/not allowed on a secret field/); + }); + it('rejects a non-boolean default on a checkbox', () => { + const r = validateManifest(withSetting({ key: 'on', input_type: 'checkbox', default: 'yes' })); + expect(r.errors.join()).toMatch(/must be a boolean/); + }); + it('rejects a default that is not one of the declared options', () => { + const r = validateManifest(withSetting({ key: 's', input_type: 'select', options: ['a', 'b'], default: 'c' })); + expect(r.errors.join()).toMatch(/one of the declared options/); + }); +}); + +describe('settingDefaults (what `trek-plugin dev` seeds ctx.config / ctx.settings with)', () => { + const manifest = () => ({ + ...base(), + settings: [ + { key: 'api_url', default: 'https://api.example' }, + { key: 'retries', input_type: 'number', default: 3 }, + { key: 'region', scope: 'user', default: 'eu' }, + { key: 'token', secret: true, default: 'never' }, // invalid anyway, but must never leak through + { key: 'note' }, + ], + }); + it('collects the instance-scope defaults (scope omitted = instance)', () => { + expect(settingDefaults(manifest(), 'instance')).toEqual({ api_url: 'https://api.example', retries: 3 }); + }); + it('collects the user-scope defaults', () => { + expect(settingDefaults(manifest(), 'user')).toEqual({ region: 'eu' }); + }); + it('is empty for a manifest without settings', () => { + expect(settingDefaults(base(), 'instance')).toEqual({}); + }); +}); diff --git a/plugin-sdk/test/permissions-parity.test.ts b/plugin-sdk/test/permissions-parity.test.ts index 04d3e30ce..c31baab83 100644 --- a/plugin-sdk/test/permissions-parity.test.ts +++ b/plugin-sdk/test/permissions-parity.test.ts @@ -15,6 +15,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { HOOK_PERMISSION } from '../src/permissions.js'; +import { SETTING_FIELD_KEYS } from '../src/manifest.js'; import { PLUGIN_SESSION_MAX_KEYS, PLUGIN_SESSION_MAX_KEY_LENGTH, PLUGIN_SESSION_MAX_VALUE_BYTES, EVENT_FAMILIES, EVENT_SNAPSHOT_GRANT, KNOWN_PERMISSIONS } from '../src/index.js'; const here = path.dirname(fileURLToPath(import.meta.url)); @@ -96,3 +97,16 @@ describe.skipIf(!inMonorepo)('core event catalog', () => { } }); }); + +describe.skipIf(!inMonorepo)('settings-field attribute parity', () => { + it("SETTING_FIELD_KEYS matches the host's install/manifest.ts", () => { + // The `manifest.settings-known-keys` check warns on an attribute the host would + // silently drop, so the list must be the host's — compared whole, both ways round. + const theirsFile = path.resolve(here, '../../server/src/nest/plugins/install/manifest.ts'); + const src = fs.readFileSync(theirsFile, 'utf8'); + const block = src.match(/export const SETTING_FIELD_KEYS = \[([\s\S]*?)\] as const/); + expect(block, 'SETTING_FIELD_KEYS not found in install/manifest.ts').toBeTruthy(); + const theirs = [...block![1].matchAll(/'([^']+)'/g)].map((m) => m[1]); + expect([...SETTING_FIELD_KEYS]).toEqual(theirs); + }); +}); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 2799c4e7b..cde76e4d8 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -1151,3 +1151,12 @@ describe('validateManifest: the trek range', () => { expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false); }); }); + +describe('settingDefaults is part of the package surface', () => { + it('is exported from the index so a plugin test can seed createMockHost like the host does', async () => { + const sdk = await import('../src/index.js'); + const manifest = { settings: [{ key: 'a', default: 1 }, { key: 'u', scope: 'user', default: 'x' }] }; + expect(sdk.settingDefaults(manifest, 'instance')).toEqual({ a: 1 }); + expect(sdk.settingDefaults(manifest, 'user')).toEqual({ u: 'x' }); + }); +}); diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 62c58881c..0b8b22ead 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -4205,8 +4205,6 @@ function runMigrations(db: Database.Database): void { * should show what its author put in it, and #1260's set is everything the * linked trips happen to contain. Owners who want them back have one * switch in Journey Settings. - * - * Appended LAST: the array is index-addressed against schema_version. */ () => { const cols = db.prepare("SELECT name FROM pragma_table_info('journeys')").all() as Array<{ name: string }>; @@ -4214,6 +4212,20 @@ function runMigrations(db: Database.Database): void { db.exec('ALTER TABLE journeys ADD COLUMN show_trip_tracks INTEGER NOT NULL DEFAULT 0'); } }, + /** + * Settings-field defaults (#plugins, PR-87 feedback). A manifest `default` is the + * field's effective value when nothing is stored — the settings form pre-fills it and + * the runtime resolves it (settings-defaults.ts); it was previously accepted by the + * manifest and silently dropped here. JSON-encoded so string/number/boolean round-trip. + * + * Appended LAST: the array is index-addressed against schema_version. + */ + () => { + const cols = db.prepare("SELECT name FROM pragma_table_info('plugin_settings_fields')").all() as Array<{ name: string }>; + if (!cols.some((c) => c.name === 'default_value')) { + db.exec('ALTER TABLE plugin_settings_fields ADD COLUMN default_value TEXT'); + } + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index 2e000926d..637f4bdfe 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -88,8 +88,8 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void { // Refresh the settings-field descriptors from the manifest. db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id); const insert = db.prepare( - `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, oauth_config, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO plugin_settings_fields (plugin_id, field_key, label, input_type, placeholder, hint, required, secret, scope, options, oauth_config, default_value, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ); m.settings.forEach((f, i) => { insert.run( @@ -104,6 +104,7 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void { f.scope ?? 'instance', f.options ? JSON.stringify(f.options) : null, f.oauth ? JSON.stringify(f.oauth) : null, + f.default === undefined ? null : JSON.stringify(f.default), i, ); }); diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 5fb62588b..1129cc72d 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -26,6 +26,7 @@ export interface ManifestSettingField { hint?: string; required?: boolean; secret?: boolean; + default?: string | number | boolean; scope?: 'instance' | 'user'; options?: Array<{ value: string; label: string }>; oauth?: { initPath?: string; callbackPath?: string }; @@ -524,25 +525,62 @@ function parseCapabilityNames(raw: unknown, field: string): string[] { const SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/; const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']); +/** + * Every attribute a settings-field object may carry — parseSettings reads exactly these and + * silently drops anything else. The SDK mirrors this list (`SETTING_FIELD_KEYS` in + * plugin-sdk/src/manifest.ts, parity-tested against this file) so `trek-plugin validate` + * can warn on a typo'd or unsupported attribute instead of letting it vanish at install. + */ +export const SETTING_FIELD_KEYS = [ + 'key', 'label', 'input_type', 'placeholder', 'hint', 'required', 'secret', 'scope', 'options', 'oauth', 'default', +] as const; + function parseSettings(raw: unknown): ManifestSettingField[] { if (!Array.isArray(raw)) return []; return raw .filter((s): s is Record => !!s && typeof s === 'object') - .map((s): ManifestSettingField => ({ - key: assertSettingKey(String(s.key ?? '')), - label: optStr(s.label), - input_type: optStr(s.input_type) ?? 'text', - placeholder: optStr(s.placeholder), - hint: optStr(s.hint), - required: !!s.required, - secret: !!s.secret, - scope: s.scope === 'user' ? 'user' : 'instance', - options: parseSettingOptions(s.options), - oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined, - })) + .map((s): ManifestSettingField => { + const input_type = optStr(s.input_type) ?? 'text'; + const secret = !!s.secret; + const options = parseSettingOptions(s.options); + return { + key: assertSettingKey(String(s.key ?? '')), + label: optStr(s.label), + input_type, + placeholder: optStr(s.placeholder), + hint: optStr(s.hint), + required: !!s.required, + secret, + default: parseSettingDefault(s.default, { secret, input_type, options }), + scope: s.scope === 'user' ? 'user' : 'instance', + options, + oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined, + }; + }) .filter((s) => s.key); } +/** + * A field's `default` — the field's effective value wherever nothing is stored (the form + * pre-fills it and the runtime resolves it; see settings-defaults.ts). Tolerant on purpose: + * an unusable default degrades to "no default" and never blocks an install (the SDK's + * `validate` is where an author hears about it). Dropped when: + * - the field is a SECRET — the manifest is public, so it would be a plaintext secret; + * - a `checkbox` default is not a boolean — the form would coerce it to unchecked anyway; + * - a field with `options` defaults to a value not among them — the select would render + * blank and Save would persist an undeclared value. + */ +function parseSettingDefault( + raw: unknown, + field: { secret: boolean; input_type: string; options?: Array<{ value: string }> }, +): string | number | boolean | undefined { + if (raw === undefined || raw === null || field.secret) return undefined; + if (typeof raw !== 'string' && typeof raw !== 'number' && typeof raw !== 'boolean') return undefined; + if (field.input_type === 'checkbox' && typeof raw !== 'boolean') return undefined; + if (field.options && field.options.length > 0 && !field.options.some((o) => o.value === String(raw))) return undefined; + return raw; +} + /** * A `select` field's options. The client renders `{value, label}`, so a bare string list — * the obvious thing to write, and what a manifest was silently allowed to carry — rendered diff --git a/server/src/nest/plugins/oauth/plugin-oauth.service.ts b/server/src/nest/plugins/oauth/plugin-oauth.service.ts index c1e658ba4..ee9677ac9 100644 --- a/server/src/nest/plugins/oauth/plugin-oauth.service.ts +++ b/server/src/nest/plugins/oauth/plugin-oauth.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import crypto from 'node:crypto'; import { DatabaseService } from '../../database/database.service'; import { encrypt_api_key, decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; +import { applySettingDefaults, settingDefaults } from '../settings-defaults'; import { getAppUrl } from '../../../app-config'; import { isPrivateIp } from '../install/safe-fetch'; import { safeFetchLlm } from '../../../utils/ssrfGuard'; @@ -82,6 +83,9 @@ export class PluginOAuthService { } catch { return null; } + // A provider plugin ships its endpoints/scopes as manifest defaults; the admin types + // only the client id/secret (secrets — never defaulted). + cfg = applySettingDefaults(cfg, settingDefaults(this.db, pluginId, 'instance')); const authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim(); const tokenUrl = String(cfg.oauth_token_url ?? '').trim(); const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : ''; diff --git a/server/src/nest/plugins/plugin-runtime.service.ts b/server/src/nest/plugins/plugin-runtime.service.ts index de2dae056..5ca00c3da 100644 --- a/server/src/nest/plugins/plugin-runtime.service.ts +++ b/server/src/nest/plugins/plugin-runtime.service.ts @@ -11,6 +11,7 @@ import { PLUGIN_CHANNEL_EVENTS } from './install/manifest'; import { stripEmoji } from './text-sanitize'; import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; +import { applySettingDefaults, settingDefaults } from './settings-defaults'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; import fs from 'node:fs'; import path from 'node:path'; @@ -565,7 +566,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes const declared = parseArray(row.permissions).filter(isKnownPermission); // Mark it enabled (admin intent) so it reboots after restarts/crashes. this.db.prepare('UPDATE plugins SET granted_permissions = ?, enabled = 1 WHERE id = ?').run(JSON.stringify(declared), id); - const config = decryptConfig(parseObject(row.config)); + // Manifest defaults fill whatever the admin never set, so the child's ctx.config is + // the same effective value the settings form shows (see settings-defaults.ts). + const config = applySettingDefaults(decryptConfig(parseObject(row.config)), settingDefaults(this.db, id, 'instance')); const manifestHosts = declared.filter((p) => p.startsWith(HTTP_OUTBOUND)).map((p) => p.slice(HTTP_OUTBOUND.length)); // Union in the hosts the ADMIN added post-install. A plugin that talks to a // self-hosted service can't name the operator's hostname in its manifest, so without diff --git a/server/src/nest/plugins/plugin-user-settings.controller.ts b/server/src/nest/plugins/plugin-user-settings.controller.ts index 7fced5d18..284f0a3cd 100644 --- a/server/src/nest/plugins/plugin-user-settings.controller.ts +++ b/server/src/nest/plugins/plugin-user-settings.controller.ts @@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGu import type { Request } from 'express'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { pluginsEnabled } from './kill-switch'; -import { PluginsService } from './plugins.service'; +import { PluginsService, MissingRequiredSettingError } from './plugins.service'; import { PluginRuntimeService } from './plugin-runtime.service'; import { DatabaseService } from '../database/database.service'; import { PluginUserSettingsUpdateDto } from './plugins.dto'; @@ -81,6 +81,11 @@ export class PluginUserSettingsController { const userId = req.user?.id; if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} }; const patch = body?.config && typeof body.config === 'object' ? (body.config as Record) : {}; - return { config: this.plugins.updateUserConfig(id, userId, patch) }; + try { + return { config: this.plugins.updateUserConfig(id, userId, patch) }; + } catch (e) { + if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400); + throw e; + } } } diff --git a/server/src/nest/plugins/plugin-user-settings.service.ts b/server/src/nest/plugins/plugin-user-settings.service.ts index e46482db4..bdad7e5dc 100644 --- a/server/src/nest/plugins/plugin-user-settings.service.ts +++ b/server/src/nest/plugins/plugin-user-settings.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { DatabaseService } from '../database/database.service'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { safeParseConfig } from './plugin-config-parse'; +import { isFilled, settingDefaults } from './settings-defaults'; /** * A plugin's per-user settings, decrypted host-side. @@ -31,7 +32,7 @@ export class PluginUserSettingsService { .get(pluginId, key) as { secret: number } | undefined )?.secret === 1; const value = this.storedFor(pluginId, userId)[key]; - if (value == null) return undefined; + if (value == null) return settingDefaults(this.db, pluginId, 'user')[key]; // unset → the manifest default, if any return isSecret ? decrypt_api_key(value as string) : value; } @@ -50,9 +51,13 @@ export class PluginUserSettingsService { // Null-prototype for the same reason as safeParseConfig: never let a field key write // through to Object.prototype on the way out to the plugin. const out: Record = Object.create(null); + const defaults = settingDefaults(this.db, pluginId, 'user'); for (const field of fields) { const value = stored[field.field_key]; - if (value == null) continue; + if (value == null) { + if (field.field_key in defaults) out[field.field_key] = defaults[field.field_key]; + continue; + } out[field.field_key] = field.secret === 1 ? decrypt_api_key(value as string) : value; } return out; @@ -62,17 +67,22 @@ export class PluginUserSettingsService { * Has this user filled in every `required`, `scope:'user'` field the plugin declares? * A plugin with no required user fields is configured for everyone (an instance-wide * channel, e.g. a shared workspace webhook). + * + * Same "filled" rule as the save gate (PluginsService.assertRequiredFilled) — a + * checkbox is exempt, whitespace is empty, a manifest default counts — because this is + * what decides whether a channel dispatches to the user, and a save the form accepted + * must not leave them "not configured". */ hasRequired(pluginId: string, userId: number): boolean { const required = this.db - .prepare("SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1") + .prepare( + "SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = 'user' AND required = 1 AND input_type != 'checkbox'", + ) .all(pluginId) as Array<{ field_key: string }>; if (required.length === 0) return true; const stored = this.storedFor(pluginId, userId); - return required.every((field) => { - const value = stored[field.field_key]; - return value != null && String(value) !== ''; - }); + const defaults = settingDefaults(this.db, pluginId, 'user'); + return required.every((field) => isFilled(stored[field.field_key] ?? defaults[field.field_key])); } private storedFor(pluginId: string, userId: number): Record { diff --git a/server/src/nest/plugins/plugins.controller.ts b/server/src/nest/plugins/plugins.controller.ts index a09d6fae4..0e6e0ea70 100644 --- a/server/src/nest/plugins/plugins.controller.ts +++ b/server/src/nest/plugins/plugins.controller.ts @@ -1,7 +1,7 @@ import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Request } from 'express'; -import { PluginsService } from './plugins.service'; +import { PluginsService, MissingRequiredSettingError } from './plugins.service'; import { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service'; import { DependencyCycleError } from './dependencies'; import { PluginRegistryService, RegistryError } from './registry/registry.service'; @@ -151,8 +151,13 @@ export class PluginsController { */ @Put(':id/config') 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) }; + try { + const config = this.plugins.updateInstanceConfig(id, body || {}); + return { config, restarted: await this.runtime.respawnIfActive(id) }; + } catch (e) { + if (e instanceof MissingRequiredSettingError) throw new HttpException({ error: e.message }, 400); + throw e; + } } /** diff --git a/server/src/nest/plugins/plugins.service.ts b/server/src/nest/plugins/plugins.service.ts index 35d65b454..765cb1010 100644 --- a/server/src/nest/plugins/plugins.service.ts +++ b/server/src/nest/plugins/plugins.service.ts @@ -7,6 +7,7 @@ import { readAudit } from './host/plugin-audit'; import { keyFingerprint } from './signature-status'; import { pluginBudgetUsage } from './host/plugin-host-state'; import { safeParseConfig as safeParse } from './plugin-config-parse'; +import { isFilled, parseDefaultValue, settingDefaults } from './settings-defaults'; 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'; @@ -17,6 +18,14 @@ const SECRET_MASK = '••••••••'; export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'; +/** A save that would leave a `required` settings field empty — mapped to 400 by both controllers. */ +export class MissingRequiredSettingError extends Error { + constructor(public readonly field: string) { + super(`Missing required setting "${field}"`); + this.name = 'MissingRequiredSettingError'; + } +} + /** * Read side of the plugin system (#plugins), M0 scaffold. Lists installed * plugins from the `plugins` registry table and reports whether the runtime is @@ -229,6 +238,7 @@ export class PluginsService { config[k] = v; } } + this.assertRequiredFilled(id, 'instance', config); this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id); return maskSecrets(config, secretKeys); } @@ -246,7 +256,7 @@ export class PluginsService { 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 + `SELECT field_key AS key, label, input_type, placeholder, hint, required, secret, options, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`, ) .all(id, scope) @@ -260,6 +270,7 @@ export class PluginsService { hint: (row.hint ?? null) as string | null, required: row.required === 1, secret: row.secret === 1, + default: parseDefaultValue(row.default_value), // Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check. options: typeof row.options === 'string' && row.options @@ -309,6 +320,7 @@ export class PluginsService { config[k] = v; } } + this.assertRequiredFilled(id, 'user', config); this.db.prepare( `INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now')) ON CONFLICT(plugin_id, user_id) DO UPDATE SET config = excluded.config, updated_at = excluded.updated_at`, @@ -363,6 +375,25 @@ export class PluginsService { ); return maskSecrets(safeParse(row.config), secretKeys); } + + /** + * `required` used to be a decorative asterisk (PR-87 feedback): the form rendered it, but + * nothing refused a save. Enforced on the MERGED result so partial patches stay legal and a + * stored secret (non-empty ciphertext) counts as filled. A `checkbox` is exempt — required + * would demand `true`, which is a consent flow, not a settings field. + */ + private assertRequiredFilled(id: string, scope: 'instance' | 'user', config: Record): void { + const required = this.db + .prepare( + "SELECT field_key FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND required = 1 AND input_type != 'checkbox'", + ) + .all(id, scope) as Array<{ field_key: string }>; + const defaults = settingDefaults(this.db, id, scope); + for (const f of required) { + // The runtime resolves the default too, so it counts as filled here as well. + if (!isFilled(config[f.field_key] ?? defaults[f.field_key])) throw new MissingRequiredSettingError(f.field_key); + } + } } function safeArray(json: string): unknown[] | undefined { diff --git a/server/src/nest/plugins/runtime/plugin-sdk.ts b/server/src/nest/plugins/runtime/plugin-sdk.ts index 6e2525586..7939dd589 100644 --- a/server/src/nest/plugins/runtime/plugin-sdk.ts +++ b/server/src/nest/plugins/runtime/plugin-sdk.ts @@ -13,10 +13,13 @@ export { PLUGIN_API_VERSION } from '../protocol/envelope'; export interface PluginContext { readonly id: string; + /** `scope:'instance'` settings, secrets decrypted, frozen at activation; a field nobody + * set resolves to its manifest `default` (settings-defaults.ts). */ readonly config: Readonly>; /** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields - * (decrypted host-side). Returns undefined for an unset value or a userless context - * (job/onLoad) — fall back to `config` (the admin-owned instance settings) there. */ + * (decrypted host-side), or the field's manifest `default` when they never set it. + * Undefined for a field with neither, and in a userless context (job/onLoad) — fall + * back to `config` (the admin-owned instance settings) there. */ settings: { get(key: string): Promise; }; diff --git a/server/src/nest/plugins/settings-defaults.ts b/server/src/nest/plugins/settings-defaults.ts new file mode 100644 index 000000000..7799a724b --- /dev/null +++ b/server/src/nest/plugins/settings-defaults.ts @@ -0,0 +1,72 @@ +import type BetterSqlite3 from 'better-sqlite3'; + +/** What a settings-field `default` may be — the JSON scalars the manifest accepts. */ +export type SettingDefault = string | number | boolean; + +/** + * Manifest settings-field defaults (#plugins). + * + * A field's `default` is stored JSON-encoded in `plugin_settings_fields.default_value` + * (so string/number/boolean round-trip) and is meant to be the field's EFFECTIVE value + * wherever nothing was stored: the settings form pre-fills it, and — the part that makes + * it more than a form hint — the runtime resolves it too, so a plugin that ships a + * sensible default works before anyone opens the form. Every read that hands config to a + * plugin (the child's init envelope, `ctx.settings.get()`, the channel dispatch's readAll, + * the OAuth broker's provider config) folds these in through here, and the `required` + * check counts them as filled for the same reason: refusing a save the runtime would have + * accepted is a contradiction. + * + * Secrets never carry a default (the manifest is public; parse drops it), so a secret is + * never resolved from here even if a row somehow had one. + */ +export function parseDefaultValue(raw: unknown): SettingDefault | undefined { + if (typeof raw !== 'string' || raw === '') return undefined; + try { + const v = JSON.parse(raw) as unknown; + return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : undefined; + } catch { + return undefined; + } +} + +/** The declared defaults for one plugin and scope, keyed by field. Null-prototype so a + * field key can never resolve off Object.prototype (see plugin-config-parse.ts). */ +export function settingDefaults( + db: BetterSqlite3.Database, + pluginId: string, + scope: 'instance' | 'user', +): Record { + const rows = db + .prepare( + 'SELECT field_key, default_value FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? AND secret = 0 AND default_value IS NOT NULL', + ) + .all(pluginId, scope) as Array<{ field_key: string; default_value: string }>; + const out: Record = Object.create(null); + for (const r of rows) { + const v = parseDefaultValue(r.default_value); + if (v !== undefined) out[r.field_key] = v; + } + return out; +} + +/** `stored ?? default` per field, on a copy: a stored value (even `''`/`false`) always wins, + * only an ABSENT one (undefined/null) falls back. Same rule the settings form applies. */ +export function applySettingDefaults( + config: Record, + defaults: Record, +): Record { + const out: Record = Object.create(null); + for (const [k, v] of Object.entries(config)) out[k] = v; + for (const [k, v] of Object.entries(defaults)) if (out[k] == null) out[k] = v; + return out; +} + +/** + * The one definition of "a required setting is filled": present and not blank. Shared by + * the save gate (PluginsService.assertRequiredFilled) and the dispatch check + * (PluginUserSettingsService.hasRequired) so a save the form accepted can never leave a + * user "not configured", or the reverse. + */ +export function isFilled(value: unknown): boolean { + return value != null && String(value).trim() !== ''; +} diff --git a/server/tests/integration/plugins/boot-registry-order.test.ts b/server/tests/integration/plugins/boot-registry-order.test.ts index d14532cc9..23fbb47f8 100644 --- a/server/tests/integration/plugins/boot-registry-order.test.ts +++ b/server/tests/integration/plugins/boot-registry-order.test.ts @@ -32,7 +32,7 @@ const { testDb } = vi.hoisted(() => { trek_range TEXT DEFAULT '>=3.0.0', source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT); CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT); CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key)); diff --git a/server/tests/integration/plugins/dev-link.test.ts b/server/tests/integration/plugins/dev-link.test.ts index 473f50618..0f0a57388 100644 --- a/server/tests/integration/plugins/dev-link.test.ts +++ b/server/tests/integration/plugins/dev-link.test.ts @@ -20,7 +20,7 @@ const { testDb } = vi.hoisted(() => { source_repo TEXT, source_commit TEXT, sha256 TEXT, author_pubkey TEXT, reviewed_at TEXT, last_error TEXT, updated_at TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT); CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);`); diff --git a/server/tests/integration/plugins/plugin-runtime.test.ts b/server/tests/integration/plugins/plugin-runtime.test.ts index 0ce24c5ba..d67c8074a 100644 --- a/server/tests/integration/plugins/plugin-runtime.test.ts +++ b/server/tests/integration/plugins/plugin-runtime.test.ts @@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => { trek_range TEXT DEFAULT '>=3.0.0', source_repo TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT); CREATE TABLE settings (user_id INTEGER, key TEXT, value TEXT); CREATE TABLE plugin_entity_metadata (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, entity_type TEXT, entity_id INTEGER, key TEXT, value TEXT, updated_at TEXT); CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key)); diff --git a/server/tests/integration/plugins/registry.test.ts b/server/tests/integration/plugins/registry.test.ts index d10755a46..589fe44cc 100644 --- a/server/tests/integration/plugins/registry.test.ts +++ b/server/tests/integration/plugins/registry.test.ts @@ -24,7 +24,7 @@ const { testDb } = vi.hoisted(() => { api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, enabled INTEGER DEFAULT 0, config TEXT, source_repo TEXT, source_commit TEXT, sha256 TEXT, reviewed_at TEXT, author_pubkey TEXT, updated_at TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, update_hold INTEGER NOT NULL DEFAULT 0); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); return { testDb: db }; }); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index abda6e903..79dfa6783 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -29,7 +29,7 @@ beforeEach(() => { CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, api_version INTEGER, min_trek_version TEXT, trek_range TEXT, permissions TEXT, capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, granted_permissions TEXT, status TEXT, config TEXT, updated_at TEXT); CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, - required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, sort_order INTEGER); + required INTEGER, secret INTEGER, scope TEXT, options TEXT, oauth_config TEXT, default_value TEXT, sort_order INTEGER); CREATE TABLE plugin_actions (plugin_id TEXT, action_key TEXT, label TEXT, hint TEXT, danger INTEGER, sort_order INTEGER, PRIMARY KEY (plugin_id, action_key)); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`); diff --git a/server/tests/unit/plugins/manifest.test.ts b/server/tests/unit/plugins/manifest.test.ts index 4655ae9ae..624b22349 100644 --- a/server/tests/unit/plugins/manifest.test.ts +++ b/server/tests/unit/plugins/manifest.test.ts @@ -3,7 +3,7 @@ * unknown permissions, native modules, and http:outbound without egress. */ import { describe, it, expect } from 'vitest'; -import { parseManifest, ManifestError } from '../../../src/nest/plugins/install/manifest'; +import { parseManifest, ManifestError, SETTING_FIELD_KEYS } from '../../../src/nest/plugins/install/manifest'; const base = { id: 'flight-tracker', name: 'Flight', version: '1.2.0', type: 'widget', apiVersion: 1 }; const withApi = (apiVersion: unknown) => ({ ...base, apiVersion, trek: '>=3.2.0 <4.0.0' }); @@ -353,3 +353,17 @@ describe('settings-page actions', () => { expect(parseManifest(base).actions).toEqual([]); }); }); + +describe('SETTING_FIELD_KEYS is the attribute set parseSettings reads', () => { + // The SDK mirrors this list (plugin-sdk parity test) to warn on an attribute the host + // would silently drop — so every key listed here must actually land in the parsed field. + it('a field carrying every key round-trips each one', () => { + const field: Record = { + key: 'k', label: 'L', input_type: 'select', placeholder: 'P', hint: 'H', required: true, secret: false, + scope: 'user', options: ['a'], oauth: { initPath: '/i' }, default: 'a', + }; + expect(Object.keys(field).sort()).toEqual([...SETTING_FIELD_KEYS].sort()); + const [parsed] = parseManifest({ id: 'x-plugin', name: 'X', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0', settings: [field] }).settings; + for (const k of SETTING_FIELD_KEYS) expect(parsed[k as keyof typeof parsed], k).toBeDefined(); + }); +}); diff --git a/server/tests/unit/plugins/plugin-instance-settings.test.ts b/server/tests/unit/plugins/plugin-instance-settings.test.ts index f47f8f340..e72d165cf 100644 --- a/server/tests/unit/plugins/plugin-instance-settings.test.ts +++ b/server/tests/unit/plugins/plugin-instance-settings.test.ts @@ -6,7 +6,10 @@ * 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'; +import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; const { testDb, dbMock } = vi.hoisted(() => { const Database = require('better-sqlite3'); @@ -27,6 +30,7 @@ import type { PluginRegistryService } from '../../../src/nest/plugins/registry/r 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'; +import { discoverPlugins } from '../../../src/nest/plugins/install/discovery'; function install(id: string) { testDb @@ -51,6 +55,23 @@ const svc = () => { return new PluginsService(dbs, new AddonsService(dbs)); }; +/** + * Installs a real plugin through the manifest -> discovery pipeline (not a direct + * `declareField` row), so `default`-handling tests exercise `parseSettings`/ + * `parseSettingDefault` in manifest.ts, not just the settingsFields() read path. + */ +let codeRoot: string; +function installFixturePlugin(opts: { settings: Array> }) { + const dir = path.join(codeRoot, 'fixture-id', 'server'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + path.join(codeRoot, 'fixture-id', 'trek-plugin.json'), + JSON.stringify({ id: 'fixture-id', name: 'Fixture', version: '1.0.0', type: 'integration', trek: '>=4.0.0 <5.0.0', settings: opts.settings }), + ); + fs.writeFileSync(path.join(dir, 'index.js'), 'module.exports={}'); + discoverPlugins(testDb); +} + beforeAll(() => { createTables(testDb); runMigrations(testDb); @@ -59,6 +80,12 @@ beforeEach(() => { testDb.prepare('DELETE FROM plugins').run(); testDb.prepare('DELETE FROM plugin_settings_fields').run(); process.env.TREK_PLUGINS_ENABLED = 'true'; + codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ins-settings-')); + process.env.TREK_PLUGINS_DIR = codeRoot; +}); +afterEach(() => { + delete process.env.TREK_PLUGINS_DIR; + fs.rmSync(codeRoot, { recursive: true, force: true }); }); describe('instance settings fields', () => { @@ -91,6 +118,77 @@ describe('instance settings fields', () => { expect(plugins.find((p) => p.id === 'with-fields')).toMatchObject({ instanceSettingsCount: 2 }); expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 }); }); + + it('INS-010 — persists a settings-field default and serves it on the fields list', () => { + installFixturePlugin({ settings: [{ key: 'oauth_authorize_url', required: true, default: 'https://auth.openbnb.org/authorize' }] }); + const fields = svc().instanceSettingsFields('fixture-id'); + expect(fields[0].default).toBe('https://auth.openbnb.org/authorize'); + }); + + it('INS-013 — drops a default the field cannot take: non-boolean on a checkbox, or not one of the select options', () => { + installFixturePlugin({ + settings: [ + { key: 'on', input_type: 'checkbox', default: 'true' }, + { key: 'mode', input_type: 'select', options: ['fast', 'slow'], default: 'warp' }, + { key: 'mode_ok', input_type: 'select', options: [{ value: 'a', label: 'A' }], default: 'a' }, + { key: 'on_ok', input_type: 'checkbox', default: true }, + ], + }); + const byKey = Object.fromEntries(svc().instanceSettingsFields('fixture-id').map((f) => [f.key, f.default])); + expect(byKey).toEqual({ on: undefined, mode: undefined, mode_ok: 'a', on_ok: true }); + }); + + it('INS-011 — drops a default on a secret field at parse time', () => { + installFixturePlugin({ settings: [{ key: 'token', secret: true, default: 'leak' }] }); + expect(svc().instanceSettingsFields('fixture-id')[0].default).toBeUndefined(); + }); +}); + +describe('required settings are enforced on save', () => { + it('refuses a save that leaves a required instance field empty', () => { + installFixturePlugin({ settings: [{ key: 'client_id', required: true }] }); + expect(() => svc().updateInstanceConfig('fixture-id', { client_id: ' ' })).toThrow(/Missing required setting "client_id"/); + }); + + it('accepts a partial patch when the required field is already stored', () => { + installFixturePlugin({ settings: [{ key: 'client_id', required: true }, { key: 'note', required: false }] }); + const s = svc(); + s.updateInstanceConfig('fixture-id', { client_id: 'abc' }); + expect(() => s.updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow(); + }); + + it('accepts a user-scope partial patch when the required user field is already stored', () => { + installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }, { key: 'units', scope: 'user' }] }); + const s = svc(); + s.updateUserConfig('fixture-id', 1, { api_key: 'sk-1' }); + expect(() => s.updateUserConfig('fixture-id', 1, { units: 'metric' })).not.toThrow(); + }); + + it('refuses a user-settings save that leaves a required user field empty', () => { + installFixturePlugin({ settings: [{ key: 'api_key', scope: 'user', required: true }] }); + expect(() => svc().updateUserConfig('fixture-id', 1, { api_key: '' })).toThrow(/Missing required setting "api_key"/); + }); + + it('exempts checkbox fields from required enforcement (consent, not a settings field)', () => { + installFixturePlugin({ settings: [{ key: 'accept_terms', input_type: 'checkbox', required: true }, { key: 'note', required: false }] }); + // accept_terms is left entirely unset (never patched, nothing stored) — a + // non-checkbox required field in this state would throw. + expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow(); + }); + + it('a required field with a manifest default is satisfied by the default', () => { + installFixturePlugin({ settings: [{ key: 'region', required: true, default: 'eu' }, { key: 'note', required: false }] }); + // region is never patched and nothing is stored — the runtime will see the default, + // so refusing the save here would contradict what the child actually gets. + expect(() => svc().updateInstanceConfig('fixture-id', { note: 'hi' })).not.toThrow(); + }); + + it('a stored secret (non-empty ciphertext) counts as filled on a later partial patch', () => { + installFixturePlugin({ settings: [{ key: 'api_key', secret: true, required: true }, { key: 'note', required: false }] }); + const s = svc(); + s.updateInstanceConfig('fixture-id', { api_key: 'sk-real' }); + expect(() => s.updateInstanceConfig('fixture-id', { api_key: '••••••••', note: 'hi' })).not.toThrow(); + }); }); describe('respawn on save (runtime)', () => { @@ -163,3 +261,27 @@ describe('admin config endpoints (controller)', () => { expect(out.restarted).toBe(false); }); }); + +describe('defaults reach the child at spawn', () => { + it('INS-012 — an unset instance field is handed to the child as its manifest default; a stored value wins', async () => { + installFixturePlugin({ + settings: [ + { key: 'api_url', default: 'https://api.example' }, + { key: 'retries', input_type: 'number', default: 3 }, + { key: 'note' }, + ], + }); + const s = svc(); + s.updateInstanceConfig('fixture-id', { api_url: 'https://mine.example' }); + + const rt = createPluginRuntime(new DatabaseService(dbConn)); + const sup = (rt as unknown as { supervisor: { activate: (...a: unknown[]) => Promise } }).supervisor; + const activate = vi.spyOn(sup, 'activate').mockResolvedValue(undefined); + + await rt.activate('fixture-id'); + + expect(activate).toHaveBeenCalledTimes(1); + const config = activate.mock.calls[0][2] as Record; + expect(config).toEqual({ api_url: 'https://mine.example', retries: 3 }); // note: no default, no value → absent + }); +}); diff --git a/server/tests/unit/plugins/plugin-oauth.service.test.ts b/server/tests/unit/plugins/plugin-oauth.service.test.ts index ad0decd2e..e298be06c 100644 --- a/server/tests/unit/plugins/plugin-oauth.service.test.ts +++ b/server/tests/unit/plugins/plugin-oauth.service.test.ts @@ -44,6 +44,7 @@ function freshDb(cfg: Record = CFG) { const d = new Database(':memory:'); d.exec(` CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT, status TEXT); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, default_value TEXT); CREATE TABLE plugin_oauth_tokens (plugin_id TEXT, user_id INTEGER, access_token TEXT, refresh_token TEXT, expires_at INTEGER, scope TEXT, updated_at TEXT, PRIMARY KEY (plugin_id, user_id)); CREATE TABLE plugin_oauth_state (state TEXT PRIMARY KEY, plugin_id TEXT, user_id INTEGER, verifier TEXT, created_at INTEGER); `); @@ -63,6 +64,26 @@ describe('PluginOAuthService', () => { expect(new PluginOAuthService(new DatabaseService(dbConn)).providerConfig('p')).toBeNull(); }); + it('providerConfig falls back to the manifest defaults for the endpoints the admin left unset', () => { + // A provider plugin ships its authorize/token URLs as defaults; the admin only ever + // types the client id/secret. Those two are secrets and can never carry a default. + const { oauth_authorize_url: _a, oauth_token_url: _t, oauth_scopes: _s, ...stored } = CFG; + getDb.current = freshDb(stored); + const d = getDb.current as import('better-sqlite3').Database; + const ins = d.prepare("INSERT INTO plugin_settings_fields (plugin_id, field_key, scope, secret, default_value) VALUES ('p', ?, 'instance', 0, ?)"); + ins.run('oauth_authorize_url', JSON.stringify('https://provider.example/authorize')); + ins.run('oauth_token_url', JSON.stringify('https://provider.example/token')); + ins.run('oauth_scopes', JSON.stringify('read')); + svc = new PluginOAuthService(new DatabaseService(dbConn)); + expect(svc.providerConfig('p')).toEqual({ + authorizeUrl: 'https://provider.example/authorize', + tokenUrl: 'https://provider.example/token', + scopes: 'read', + clientId: 'client-123', + clientSecret: 'secret-abc', + }); + }); + it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => { const url = new URL(svc.startConnect('p', 42, NOW)); expect(url.origin + url.pathname).toBe('https://provider.example/authorize'); diff --git a/server/tests/unit/plugins/plugin-user-settings.test.ts b/server/tests/unit/plugins/plugin-user-settings.test.ts index 084a73a86..51e5976fe 100644 --- a/server/tests/unit/plugins/plugin-user-settings.test.ts +++ b/server/tests/unit/plugins/plugin-user-settings.test.ts @@ -28,7 +28,7 @@ const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbC function freshDb() { const d = new Database(':memory:'); d.exec(` - CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, sort_order INTEGER); + CREATE TABLE plugin_settings_fields (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, field_key TEXT, label TEXT, input_type TEXT, placeholder TEXT, hint TEXT, required INTEGER, secret INTEGER, scope TEXT, options TEXT, default_value TEXT, sort_order INTEGER); CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, config TEXT, updated_at TEXT, PRIMARY KEY (plugin_id, user_id)); `); // p: a user-scope api key (secret) + a user-scope pref (not secret) + an INSTANCE field. @@ -72,7 +72,9 @@ describe('per-user plugin settings', () => { }); it('ignores keys that are not declared user-scope fields', () => { - svc.updateUserConfig('p', 42, { adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record); + // apiKey is required — filled here so the save isn't refused; the assertion is + // about adminOnly/bogus being dropped, not about required enforcement. + svc.updateUserConfig('p', 42, { apiKey: 'sk-123', adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record); const cfg = svc.getUserConfig('p', 42); expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here expect(cfg.bogus).toBeUndefined(); @@ -80,8 +82,80 @@ describe('per-user plugin settings', () => { }); it('is per-user — one user cannot see another\'s value', () => { - svc.updateUserConfig('p', 42, { units: 'metric' }); + // apiKey is required — filled here so the save isn't refused; the point of this + // test is that user 99 sees none of user 42's values. + svc.updateUserConfig('p', 42, { apiKey: 'sk-123', units: 'metric' }); expect(svc.getUserConfig('p', 99).units).toBeUndefined(); expect(userSettings().readOne('p', 99, 'apiKey')).toBeUndefined(); }); }); + +describe('manifest defaults reach the runtime reads', () => { + // A `default` is not only a form pre-fill: with nothing stored, the child must see it + // through ctx.settings.get() / the channel dispatch's readAll — otherwise a plugin + // that ships a sensible default serves nobody until every user opens the form. + let svc: PluginsService; + beforeEach(() => { + getDb.current = freshDb(); + const dbs = new DatabaseService(dbConn); + svc = new PluginsService(dbs, new AddonsService(dbs)); + const ins = (getDb.current as import('better-sqlite3').Database).prepare( + 'INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order, default_value) VALUES (?,?,?,?,?,?,?,?)', + ); + ins.run('p', 'region', 'select', 0, 0, 'user', 3, JSON.stringify('eu')); + ins.run('p', 'retries', 'number', 0, 0, 'user', 4, JSON.stringify(3)); + ins.run('p', 'endpoint', 'text', 1, 0, 'user', 5, JSON.stringify('https://api.example')); + }); + + it('readOne falls back to the declared default when nothing is stored', () => { + expect(userSettings().readOne('p', 42, 'region')).toBe('eu'); + expect(userSettings().readOne('p', 42, 'retries')).toBe(3); // JSON round-trip keeps the type + expect(userSettings().readOne('p', 42, 'apiKey')).toBeUndefined(); // no default declared + }); + + it('a stored value wins over the default', () => { + svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' }); + expect(userSettings().readOne('p', 42, 'region')).toBe('us'); + expect(userSettings().readOne('p', 99, 'region')).toBe('eu'); // another user still gets the default + }); + + it('readAll folds defaults in for the fields the user left unset', () => { + svc.updateUserConfig('p', 42, { apiKey: 'sk-123', region: 'us' }); + expect(userSettings().readAll('p', 42)).toMatchObject({ apiKey: 'sk-123', region: 'us', retries: 3, endpoint: 'https://api.example' }); + }); + + it('hasRequired treats a required field with a default as filled', () => { + // apiKey is required with no default → not configured until stored. + expect(userSettings().hasRequired('p', 42)).toBe(false); + svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); + // endpoint is required too, but its default satisfies it. + expect(userSettings().hasRequired('p', 42)).toBe(true); + }); +}); + +describe('hasRequired applies the same "filled" rule as the save gate', () => { + // notifications.service reads hasRequired() to decide who a channel dispatches to; if + // it disagreed with assertRequiredFilled() a save the form accepted could still leave + // the user "not configured" (or the reverse). + let svc: PluginsService; + beforeEach(() => { + getDb.current = freshDb(); + const dbs = new DatabaseService(dbConn); + svc = new PluginsService(dbs, new AddonsService(dbs)); + (getDb.current as import('better-sqlite3').Database) + .prepare('INSERT INTO plugin_settings_fields (plugin_id, field_key, input_type, required, secret, scope, sort_order) VALUES (?,?,?,?,?,?,?)') + .run('p', 'consent', 'checkbox', 1, 0, 'user', 9); + }); + + it('exempts a required checkbox (consent, not a settings field)', () => { + svc.updateUserConfig('p', 42, { apiKey: 'sk-123' }); // consent never set + expect(userSettings().hasRequired('p', 42)).toBe(true); + }); + + it('treats a whitespace-only value as empty', () => { + (getDb.current as import('better-sqlite3').Database) + .prepare("INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES ('p', 42, ?, '')") + .run(JSON.stringify({ apiKey: ' ', consent: true })); + expect(userSettings().hasRequired('p', 42)).toBe(false); + }); +}); diff --git a/server/tests/unit/plugins/plugins-service.test.ts b/server/tests/unit/plugins/plugins-service.test.ts index 0f5a1b22f..86951ff2a 100644 --- a/server/tests/unit/plugins/plugins-service.test.ts +++ b/server/tests/unit/plugins/plugins-service.test.ts @@ -12,7 +12,7 @@ const { testDb } = vi.hoisted(() => { status TEXT, enabled INTEGER DEFAULT 0, last_error TEXT, reviewed_at TEXT, source_repo TEXT, config TEXT DEFAULT '{}', permissions TEXT DEFAULT '[]', granted_permissions TEXT DEFAULT '[]', capabilities TEXT DEFAULT '{}', dependencies TEXT DEFAULT '{}', operator_egress INTEGER DEFAULT 0, updated_at TEXT, author_pubkey TEXT, update_block_code TEXT, update_block_detail TEXT, update_block_version TEXT, trek_range TEXT, sort_order INTEGER DEFAULT 0, update_hold INTEGER NOT NULL DEFAULT 0); - CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER); + CREATE TABLE plugin_settings_fields (plugin_id TEXT, field_key TEXT, scope TEXT, secret INTEGER, required INTEGER DEFAULT 0, input_type TEXT DEFAULT 'text', default_value TEXT); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT DEFAULT '2026-01-01');`); return { testDb: db }; }); diff --git a/shared/src/i18n/ar/admin.ts b/shared/src/i18n/ar/admin.ts index c218af3e3..d9c6fbd5b 100644 --- a/shared/src/i18n/ar/admin.ts +++ b/shared/src/i18n/ar/admin.ts @@ -454,6 +454,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم', 'admin.plugins.settingsSaved': 'تم حفظ الإعدادات', 'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة', + 'admin.plugins.requiredMissing': '"{field}" مطلوب', 'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك', 'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية', 'admin.plugins.cap.readsCosts': 'يقرأ مصروفاتك', diff --git a/shared/src/i18n/ar/settings.ts b/shared/src/i18n/ar/settings.ts index 9fd9cc1d5..3fc02634d 100644 --- a/shared/src/i18n/ar/settings.ts +++ b/shared/src/i18n/ar/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'إعداداتك الشخصية للإضافات التي تستخدمها (مفاتيح API، التفضيلات).', 'settings.plugins.empty': 'لا توجد إضافات نشطة.', 'settings.plugins.saved': 'تم حفظ الإعدادات', + 'settings.plugins.requiredMissing': '"{field}" مطلوب', 'settings.tabs.account': 'الحساب', 'settings.tabs.about': 'حول', 'settings.map': 'الخريطة', diff --git a/shared/src/i18n/br/admin.ts b/shared/src/i18n/br/admin.ts index 0a5f3f0ea..75481a3d7 100644 --- a/shared/src/i18n/br/admin.ts +++ b/shared/src/i18n/br/admin.ts @@ -443,6 +443,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Configurações da instância', 'admin.plugins.settingsSaved': 'Configurações salvas', 'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado', + 'admin.plugins.requiredMissing': '"{field}" é obrigatório', '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/br/settings.ts b/shared/src/i18n/br/settings.ts index 21da50045..95f7aa613 100644 --- a/shared/src/i18n/br/settings.ts +++ b/shared/src/i18n/br/settings.ts @@ -17,6 +17,7 @@ const settings: TranslationStrings = { 'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).', 'settings.plugins.empty': 'Nenhum plugin ativo.', 'settings.plugins.saved': 'Configurações salvas', + 'settings.plugins.requiredMissing': '"{field}" é obrigatório', 'settings.tabs.account': 'Conta', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Sobre', diff --git a/shared/src/i18n/ca/admin.ts b/shared/src/i18n/ca/admin.ts index b6a32016c..71dbd1327 100644 --- a/shared/src/i18n/ca/admin.ts +++ b/shared/src/i18n/ca/admin.ts @@ -577,6 +577,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Configuració de la instància', 'admin.plugins.settingsSaved': 'Configuració desada', 'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat', + 'admin.plugins.requiredMissing': '"{field}" és obligatori', '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/ca/settings.ts b/shared/src/i18n/ca/settings.ts index 07f2c6739..e5dca8f5b 100644 --- a/shared/src/i18n/ca/settings.ts +++ b/shared/src/i18n/ca/settings.ts @@ -512,6 +512,7 @@ const settings: TranslationStrings = { 'La teva configuració personal per als connectors que fas servir (claus API, preferències).', 'settings.plugins.empty': 'No hi ha cap connector actiu.', 'settings.plugins.saved': 'Configuració desada', + 'settings.plugins.requiredMissing': '"{field}" és obligatori', 'settings.pluginActivity.title': 'Activitat dels connectors', 'settings.pluginActivity.description': 'Cada acció que un connector ha fet en nom teu, les més recents primer.', 'settings.pluginActivity.empty': 'Encara no hi ha activitat de connectors.', diff --git a/shared/src/i18n/cs/admin.ts b/shared/src/i18n/cs/admin.ts index 5428ec72b..7815ea3a1 100644 --- a/shared/src/i18n/cs/admin.ts +++ b/shared/src/i18n/cs/admin.ts @@ -441,6 +441,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Nastavení instance', 'admin.plugins.settingsSaved': 'Nastavení uloženo', 'admin.plugins.settingsSavedRestarted': 'Nastavení uloženo — plugin restartován', + 'admin.plugins.requiredMissing': '„{field}" je povinné', '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/cs/settings.ts b/shared/src/i18n/cs/settings.ts index 4ddd20976..c6745f6dc 100644 --- a/shared/src/i18n/cs/settings.ts +++ b/shared/src/i18n/cs/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Tvá osobní nastavení pro doplňky, které používáš (klíče API, předvolby).', 'settings.plugins.empty': 'Nejsou aktivní žádné doplňky.', 'settings.plugins.saved': 'Nastavení uloženo', + 'settings.plugins.requiredMissing': '„{field}" je povinné', 'settings.tabs.account': 'Účet', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'O aplikaci', diff --git a/shared/src/i18n/de/admin.ts b/shared/src/i18n/de/admin.ts index 0d49bdfc9..c14d05002 100644 --- a/shared/src/i18n/de/admin.ts +++ b/shared/src/i18n/de/admin.ts @@ -450,6 +450,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instanz-Einstellungen', 'admin.plugins.settingsSaved': 'Einstellungen gespeichert', 'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet', + 'admin.plugins.requiredMissing': '"{field}" ist erforderlich', '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/de/settings.ts b/shared/src/i18n/de/settings.ts index 92ed08456..14c031a88 100644 --- a/shared/src/i18n/de/settings.ts +++ b/shared/src/i18n/de/settings.ts @@ -17,6 +17,7 @@ const settings: TranslationStrings = { 'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).', 'settings.plugins.empty': 'Es sind keine Plugins aktiv.', 'settings.plugins.saved': 'Einstellungen gespeichert', + 'settings.plugins.requiredMissing': '"{field}" ist erforderlich', 'settings.tabs.account': 'Konto', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Über', diff --git a/shared/src/i18n/en/admin.ts b/shared/src/i18n/en/admin.ts index 18c2552c8..199469261 100644 --- a/shared/src/i18n/en/admin.ts +++ b/shared/src/i18n/en/admin.ts @@ -488,6 +488,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instance settings', 'admin.plugins.settingsSaved': 'Settings saved', 'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted', + 'admin.plugins.requiredMissing': '"{field}" is required', '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/en/settings.ts b/shared/src/i18n/en/settings.ts index b61998a80..8ab846faf 100644 --- a/shared/src/i18n/en/settings.ts +++ b/shared/src/i18n/en/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Your personal settings for the plugins you use (API keys, preferences).', 'settings.plugins.empty': 'No plugins are active.', 'settings.plugins.saved': 'Settings saved', + 'settings.plugins.requiredMissing': '"{field}" is required', 'settings.pluginActivity.title': 'Plugin activity', 'settings.pluginActivity.description': 'Every action a plugin took on your behalf, newest first.', 'settings.pluginActivity.empty': 'No plugin activity yet.', diff --git a/shared/src/i18n/es/admin.ts b/shared/src/i18n/es/admin.ts index a18f1adc0..56428a83a 100644 --- a/shared/src/i18n/es/admin.ts +++ b/shared/src/i18n/es/admin.ts @@ -452,6 +452,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ajustes de la instancia', 'admin.plugins.settingsSaved': 'Ajustes guardados', 'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado', + 'admin.plugins.requiredMissing': '"{field}" es obligatorio', '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/es/settings.ts b/shared/src/i18n/es/settings.ts index 75e98434e..6b5e10799 100644 --- a/shared/src/i18n/es/settings.ts +++ b/shared/src/i18n/es/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Tus ajustes personales para los complementos que usas (claves de API, preferencias).', 'settings.plugins.empty': 'No hay complementos activos.', 'settings.plugins.saved': 'Ajustes guardados', + 'settings.plugins.requiredMissing': '"{field}" es obligatorio', 'settings.tabs.account': 'Cuenta', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Acerca de', diff --git a/shared/src/i18n/fr/admin.ts b/shared/src/i18n/fr/admin.ts index 2c83f5839..f2cc94c5f 100644 --- a/shared/src/i18n/fr/admin.ts +++ b/shared/src/i18n/fr/admin.ts @@ -455,6 +455,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Paramètres de l’instance', 'admin.plugins.settingsSaved': 'Paramètres enregistrés', 'admin.plugins.settingsSavedRestarted': 'Paramètres enregistrés — plugin redémarré', + 'admin.plugins.requiredMissing': '« {field} » est obligatoire', '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/fr/settings.ts b/shared/src/i18n/fr/settings.ts index 127f24de1..f9a8c8371 100644 --- a/shared/src/i18n/fr/settings.ts +++ b/shared/src/i18n/fr/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Vos paramètres personnels pour les modules que vous utilisez (clés API, préférences).', 'settings.plugins.empty': 'Aucun module actif.', 'settings.plugins.saved': 'Paramètres enregistrés', + 'settings.plugins.requiredMissing': '« {field} » est obligatoire', 'settings.tabs.account': 'Compte', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'À propos', diff --git a/shared/src/i18n/gr/admin.ts b/shared/src/i18n/gr/admin.ts index fa7852da9..4ac8cb8de 100644 --- a/shared/src/i18n/gr/admin.ts +++ b/shared/src/i18n/gr/admin.ts @@ -503,6 +503,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης', 'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν', 'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε', + 'admin.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό', 'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας', 'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ', 'admin.plugins.cap.readsCosts': 'Διαβάζει τα έξοδά σας', diff --git a/shared/src/i18n/gr/settings.ts b/shared/src/i18n/gr/settings.ts index 9c5d0c509..e096edef3 100644 --- a/shared/src/i18n/gr/settings.ts +++ b/shared/src/i18n/gr/settings.ts @@ -17,6 +17,7 @@ const settings: TranslationStrings = { 'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).', 'settings.plugins.empty': 'Δεν υπάρχουν ενεργά πρόσθετα.', 'settings.plugins.saved': 'Οι ρυθμίσεις αποθηκεύτηκαν', + 'settings.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό', 'settings.tabs.account': 'Λογαριασμός', 'settings.tabs.offline': 'Εκτός σύνδεσης', 'settings.tabs.about': 'Σχετικά', diff --git a/shared/src/i18n/hu/admin.ts b/shared/src/i18n/hu/admin.ts index 3d1b4d79e..6c19187e3 100644 --- a/shared/src/i18n/hu/admin.ts +++ b/shared/src/i18n/hu/admin.ts @@ -455,6 +455,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Példányszintű beállítások', 'admin.plugins.settingsSaved': 'Beállítások elmentve', 'admin.plugins.settingsSavedRestarted': 'Beállítások elmentve — plugin újraindítva', + 'admin.plugins.requiredMissing': '"{field}" kötelező', '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/hu/settings.ts b/shared/src/i18n/hu/settings.ts index c3aef7af2..e8a57cdab 100644 --- a/shared/src/i18n/hu/settings.ts +++ b/shared/src/i18n/hu/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Személyes beállításaid az általad használt bővítményekhez (API-kulcsok, beállítások).', 'settings.plugins.empty': 'Nincsenek aktív bővítmények.', 'settings.plugins.saved': 'Beállítások mentve', + 'settings.plugins.requiredMissing': '"{field}" kötelező', 'settings.tabs.account': 'Fiók', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Névjegy', diff --git a/shared/src/i18n/id/admin.ts b/shared/src/i18n/id/admin.ts index 52305f51b..0e8dd2f67 100644 --- a/shared/src/i18n/id/admin.ts +++ b/shared/src/i18n/id/admin.ts @@ -497,6 +497,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Pengaturan instans', 'admin.plugins.settingsSaved': 'Pengaturan disimpan', 'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang', + 'admin.plugins.requiredMissing': '"{field}" wajib diisi', '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/id/settings.ts b/shared/src/i18n/id/settings.ts index 525ba6bdc..7bc1b941c 100644 --- a/shared/src/i18n/id/settings.ts +++ b/shared/src/i18n/id/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Pengaturan pribadimu untuk plugin yang kamu gunakan (kunci API, preferensi).', 'settings.plugins.empty': 'Tidak ada plugin yang aktif.', 'settings.plugins.saved': 'Pengaturan disimpan', + 'settings.plugins.requiredMissing': '"{field}" wajib diisi', 'settings.tabs.account': 'Akun', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Tentang', diff --git a/shared/src/i18n/it/admin.ts b/shared/src/i18n/it/admin.ts index 3f54591a7..b08426d3a 100644 --- a/shared/src/i18n/it/admin.ts +++ b/shared/src/i18n/it/admin.ts @@ -450,6 +450,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Impostazioni dell’istanza', 'admin.plugins.settingsSaved': 'Impostazioni salvate', 'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato', + 'admin.plugins.requiredMissing': '"{field}" è obbligatorio', '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/it/settings.ts b/shared/src/i18n/it/settings.ts index d2168937a..f315b7b83 100644 --- a/shared/src/i18n/it/settings.ts +++ b/shared/src/i18n/it/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Le tue impostazioni personali per i plugin che usi (chiavi API, preferenze).', 'settings.plugins.empty': 'Nessun plugin attivo.', 'settings.plugins.saved': 'Impostazioni salvate', + 'settings.plugins.requiredMissing': '"{field}" è obbligatorio', 'settings.tabs.account': 'Account', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Informazioni', diff --git a/shared/src/i18n/ja/admin.ts b/shared/src/i18n/ja/admin.ts index 47da2b58f..1fdc218a8 100644 --- a/shared/src/i18n/ja/admin.ts +++ b/shared/src/i18n/ja/admin.ts @@ -462,6 +462,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'インスタンス設定', 'admin.plugins.settingsSaved': '設定を保存しました', 'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました', + 'admin.plugins.requiredMissing': '「{field}」は必須です', 'admin.plugins.cap.readsTrips': '旅行を読み取り', 'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り', 'admin.plugins.cap.readsCosts': '支出を読み取り', diff --git a/shared/src/i18n/ja/settings.ts b/shared/src/i18n/ja/settings.ts index f96bff798..22ba43862 100644 --- a/shared/src/i18n/ja/settings.ts +++ b/shared/src/i18n/ja/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': '使用しているプラグインの個人設定(API キー、環境設定)。', 'settings.plugins.empty': '有効なプラグインはありません。', 'settings.plugins.saved': '設定を保存しました', + 'settings.plugins.requiredMissing': '「{field}」は必須です', 'settings.tabs.account': 'アカウント', 'settings.tabs.offline': 'オフライン', 'settings.tabs.about': '情報', diff --git a/shared/src/i18n/ko/admin.ts b/shared/src/i18n/ko/admin.ts index 19726ddb7..038a9715e 100644 --- a/shared/src/i18n/ko/admin.ts +++ b/shared/src/i18n/ko/admin.ts @@ -462,6 +462,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '인스턴스 설정', 'admin.plugins.settingsSaved': '설정이 저장되었습니다', 'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다', + 'admin.plugins.requiredMissing': '"{field}"은(는) 필수입니다', 'admin.plugins.cap.readsTrips': '여행 읽기', 'admin.plugins.cap.readsUsers': '기본 프로필 읽기', 'admin.plugins.cap.readsCosts': '지출 읽기', diff --git a/shared/src/i18n/ko/settings.ts b/shared/src/i18n/ko/settings.ts index e24839746..712aab462 100644 --- a/shared/src/i18n/ko/settings.ts +++ b/shared/src/i18n/ko/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': '사용 중인 플러그인의 개인 설정(API 키, 환경설정).', 'settings.plugins.empty': '활성화된 플러그인이 없습니다.', 'settings.plugins.saved': '설정이 저장되었습니다', + 'settings.plugins.requiredMissing': '"{field}"은(는) 필수입니다', 'settings.tabs.account': '계정', 'settings.tabs.offline': '오프라인', 'settings.tabs.about': '정보', diff --git a/shared/src/i18n/nl/admin.ts b/shared/src/i18n/nl/admin.ts index 40000590b..a04d55b9f 100644 --- a/shared/src/i18n/nl/admin.ts +++ b/shared/src/i18n/nl/admin.ts @@ -449,6 +449,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instantie-instellingen', 'admin.plugins.settingsSaved': 'Instellingen opgeslagen', 'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart', + 'admin.plugins.requiredMissing': '"{field}" is vereist', '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/nl/settings.ts b/shared/src/i18n/nl/settings.ts index ac3bc928e..5cb97fd4e 100644 --- a/shared/src/i18n/nl/settings.ts +++ b/shared/src/i18n/nl/settings.ts @@ -17,6 +17,7 @@ const settings: TranslationStrings = { 'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).', 'settings.plugins.empty': 'Er zijn geen plug-ins actief.', 'settings.plugins.saved': 'Instellingen opgeslagen', + 'settings.plugins.requiredMissing': '"{field}" is vereist', 'settings.tabs.account': 'Account', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Over', diff --git a/shared/src/i18n/pl/admin.ts b/shared/src/i18n/pl/admin.ts index 2648f4cda..f24405f45 100644 --- a/shared/src/i18n/pl/admin.ts +++ b/shared/src/i18n/pl/admin.ts @@ -447,6 +447,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Ustawienia instancji', 'admin.plugins.settingsSaved': 'Ustawienia zapisane', 'admin.plugins.settingsSavedRestarted': 'Ustawienia zapisane — wtyczka została uruchomiona ponownie', + 'admin.plugins.requiredMissing': '"{field}" jest wymagane', '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/pl/settings.ts b/shared/src/i18n/pl/settings.ts index 09596a9e1..6975221db 100644 --- a/shared/src/i18n/pl/settings.ts +++ b/shared/src/i18n/pl/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Twoje osobiste ustawienia używanych wtyczek (klucze API, preferencje).', 'settings.plugins.empty': 'Brak aktywnych wtyczek.', 'settings.plugins.saved': 'Zapisano ustawienia', + 'settings.plugins.requiredMissing': '"{field}" jest wymagane', 'settings.tabs.account': 'Konto', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'O aplikacji', diff --git a/shared/src/i18n/ru/admin.ts b/shared/src/i18n/ru/admin.ts index 67b6672a3..8e58cafe8 100644 --- a/shared/src/i18n/ru/admin.ts +++ b/shared/src/i18n/ru/admin.ts @@ -451,6 +451,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Настройки экземпляра', 'admin.plugins.settingsSaved': 'Настройки сохранены', 'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен', + 'admin.plugins.requiredMissing': '«{field}» обязательно', 'admin.plugins.cap.readsTrips': 'Читает ваши поездки', 'admin.plugins.cap.readsUsers': 'Читает основные профили', 'admin.plugins.cap.readsCosts': 'Читает ваши расходы', diff --git a/shared/src/i18n/ru/settings.ts b/shared/src/i18n/ru/settings.ts index f15d261fd..b39cd4338 100644 --- a/shared/src/i18n/ru/settings.ts +++ b/shared/src/i18n/ru/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Ваши личные настройки для используемых плагинов (ключи API, предпочтения).', 'settings.plugins.empty': 'Нет активных плагинов.', 'settings.plugins.saved': 'Настройки сохранены', + 'settings.plugins.requiredMissing': '«{field}» обязательно', 'settings.tabs.account': 'Аккаунт', 'settings.tabs.offline': 'Офлайн-режим', 'settings.tabs.about': 'О приложении', diff --git a/shared/src/i18n/sv/admin.ts b/shared/src/i18n/sv/admin.ts index da07c3be8..1c1d12377 100644 --- a/shared/src/i18n/sv/admin.ts +++ b/shared/src/i18n/sv/admin.ts @@ -508,6 +508,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Instansinställningar', 'admin.plugins.settingsSaved': 'Inställningarna har sparats', 'admin.plugins.settingsSavedRestarted': 'Inställningarna har sparats — pluginet har startats om', + 'admin.plugins.requiredMissing': '"{field}" krävs', '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/sv/settings.ts b/shared/src/i18n/sv/settings.ts index 4c3ad2164..f442dafb6 100644 --- a/shared/src/i18n/sv/settings.ts +++ b/shared/src/i18n/sv/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Dina personliga inställningar för de tillägg du använder (API-nycklar, inställningar).', 'settings.plugins.empty': 'Inga tillägg är aktiva.', 'settings.plugins.saved': 'Inställningar sparade', + 'settings.plugins.requiredMissing': '"{field}" krävs', 'settings.tabs.account': 'Konto', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': 'Om', diff --git a/shared/src/i18n/tr/admin.ts b/shared/src/i18n/tr/admin.ts index c9dd1331a..2364743ba 100644 --- a/shared/src/i18n/tr/admin.ts +++ b/shared/src/i18n/tr/admin.ts @@ -494,6 +494,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Örnek ayarları', 'admin.plugins.settingsSaved': 'Ayarlar kaydedildi', 'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı', + 'admin.plugins.requiredMissing': '"{field}" gerekli', '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/tr/settings.ts b/shared/src/i18n/tr/settings.ts index b6b45f7f5..fc01041b0 100644 --- a/shared/src/i18n/tr/settings.ts +++ b/shared/src/i18n/tr/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Kullandığın eklentiler için kişisel ayarların (API anahtarları, tercihler).', 'settings.plugins.empty': 'Etkin eklenti yok.', 'settings.plugins.saved': 'Ayarlar kaydedildi', + 'settings.plugins.requiredMissing': '"{field}" gerekli', 'settings.tabs.account': 'Hesap', 'settings.tabs.offline': 'Çevrimdışı', 'settings.tabs.about': 'Hakkında', diff --git a/shared/src/i18n/uk/admin.ts b/shared/src/i18n/uk/admin.ts index 85e1cba87..e77cab8ca 100644 --- a/shared/src/i18n/uk/admin.ts +++ b/shared/src/i18n/uk/admin.ts @@ -448,6 +448,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Налаштування інстансу', 'admin.plugins.settingsSaved': 'Налаштування збережено', 'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено', + 'admin.plugins.requiredMissing': '«{field}» обов’язкове', 'admin.plugins.cap.readsTrips': 'Читає ваші подорожі', 'admin.plugins.cap.readsUsers': 'Читає базові профілі', 'admin.plugins.cap.readsCosts': 'Читає ваші витрати', diff --git a/shared/src/i18n/uk/settings.ts b/shared/src/i18n/uk/settings.ts index 322b93300..3e7f923bb 100644 --- a/shared/src/i18n/uk/settings.ts +++ b/shared/src/i18n/uk/settings.ts @@ -17,6 +17,7 @@ const settings: TranslationStrings = { 'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).', 'settings.plugins.empty': 'Немає активних плагінів.', 'settings.plugins.saved': 'Налаштування збережено', + 'settings.plugins.requiredMissing': '«{field}» обов’язкове', 'settings.tabs.account': 'Обліковий запис', 'settings.tabs.offline': 'Офлайн', 'settings.tabs.about': 'Про застосунок', diff --git a/shared/src/i18n/vi/admin.ts b/shared/src/i18n/vi/admin.ts index 909912990..30789b200 100644 --- a/shared/src/i18n/vi/admin.ts +++ b/shared/src/i18n/vi/admin.ts @@ -450,6 +450,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': 'Cài đặt toàn hệ thống', 'admin.plugins.settingsSaved': 'Đã lưu cài đặt', 'admin.plugins.settingsSavedRestarted': 'Đã lưu cài đặt — đã khởi động lại plugin', + 'admin.plugins.requiredMissing': '"{field}" là bắt buộc', 'admin.plugins.cap.readsTrips': 'Đọc chuyến đi của bạn', 'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản', 'admin.plugins.cap.readsCosts': 'Đọc chi phí của bạn', diff --git a/shared/src/i18n/vi/settings.ts b/shared/src/i18n/vi/settings.ts index 43e7b2278..7b84a2cea 100644 --- a/shared/src/i18n/vi/settings.ts +++ b/shared/src/i18n/vi/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': 'Cài đặt cá nhân của bạn cho các plugin bạn dùng (khóa API, tùy chọn).', 'settings.plugins.empty': 'Không có plugin nào đang hoạt động.', 'settings.plugins.saved': 'Đã lưu cài đặt', + 'settings.plugins.requiredMissing': '"{field}" là bắt buộc', 'settings.tabs.account': 'Tài khoản', 'settings.tabs.offline': 'Ngoại tuyến', 'settings.tabs.about': 'Về', diff --git a/shared/src/i18n/zh-TW/admin.ts b/shared/src/i18n/zh-TW/admin.ts index 31f736680..226ddbf41 100644 --- a/shared/src/i18n/zh-TW/admin.ts +++ b/shared/src/i18n/zh-TW/admin.ts @@ -444,6 +444,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '執行個體設定', 'admin.plugins.settingsSaved': '設定已儲存', 'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動', + 'admin.plugins.requiredMissing': '「{field}」為必填項', 'admin.plugins.cap.readsTrips': '讀取你的行程', 'admin.plugins.cap.readsUsers': '讀取基本個人資料', 'admin.plugins.cap.readsCosts': '讀取你的支出', diff --git a/shared/src/i18n/zh-TW/settings.ts b/shared/src/i18n/zh-TW/settings.ts index 54b3a002e..493666d42 100644 --- a/shared/src/i18n/zh-TW/settings.ts +++ b/shared/src/i18n/zh-TW/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': '你所用外掛的個人設定(API 金鑰、偏好)。', 'settings.plugins.empty': '沒有啟用中的外掛。', 'settings.plugins.saved': '設定已儲存', + 'settings.plugins.requiredMissing': '「{field}」為必填項', 'settings.tabs.account': '帳戶', 'settings.tabs.offline': 'Offline', 'settings.tabs.about': '關於', diff --git a/shared/src/i18n/zh/admin.ts b/shared/src/i18n/zh/admin.ts index b566ce5f2..4f4e203d2 100644 --- a/shared/src/i18n/zh/admin.ts +++ b/shared/src/i18n/zh/admin.ts @@ -404,6 +404,7 @@ const admin: TranslationStrings = { 'admin.plugins.instanceSettings': '实例设置', 'admin.plugins.settingsSaved': '设置已保存', 'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启', + 'admin.plugins.requiredMissing': '「{field}」为必填项', 'admin.plugins.cap.readsTrips': '读取你的旅行', 'admin.plugins.cap.readsUsers': '读取基本个人资料', 'admin.plugins.cap.readsCosts': '读取你的支出', diff --git a/shared/src/i18n/zh/settings.ts b/shared/src/i18n/zh/settings.ts index a2ef5f312..8bbeb4c2e 100644 --- a/shared/src/i18n/zh/settings.ts +++ b/shared/src/i18n/zh/settings.ts @@ -16,6 +16,7 @@ const settings: TranslationStrings = { 'settings.plugins.subtitle': '你所用插件的个人设置(API 密钥、偏好)。', 'settings.plugins.empty': '没有已启用的插件。', 'settings.plugins.saved': '设置已保存', + 'settings.plugins.requiredMissing': '「{field}」为必填项', 'settings.tabs.account': '账户', 'settings.tabs.offline': '离线', 'settings.tabs.about': '关于', diff --git a/shared/src/plugins/plugins.schema.spec.ts b/shared/src/plugins/plugins.schema.spec.ts index b3bfd1774..e123070e6 100644 --- a/shared/src/plugins/plugins.schema.spec.ts +++ b/shared/src/plugins/plugins.schema.spec.ts @@ -45,6 +45,14 @@ describe('pluginSettingsFieldSchema', () => { it('requires key — a field without one cannot be rendered or saved', () => { expect(pluginSettingsFieldSchema.safeParse({ label: 'orphan' }).success).toBe(false); }); + + it('settings field carries an optional default (string | number | boolean)', () => { + expect(pluginSettingsFieldSchema.parse({ key: 'k', default: 'https://x' }).default).toBe('https://x'); + expect(pluginSettingsFieldSchema.parse({ key: 'k', default: 3 }).default).toBe(3); + expect(pluginSettingsFieldSchema.parse({ key: 'k', default: true }).default).toBe(true); + expect(pluginSettingsFieldSchema.parse({ key: 'k' }).default).toBeUndefined(); + expect(pluginSettingsFieldSchema.safeParse({ key: 'k', default: { o: 1 } }).success).toBe(false); + }); }); describe('instance-config wire shapes', () => { diff --git a/shared/src/plugins/plugins.schema.ts b/shared/src/plugins/plugins.schema.ts index 79c9710c5..ff6bcbb45 100644 --- a/shared/src/plugins/plugins.schema.ts +++ b/shared/src/plugins/plugins.schema.ts @@ -24,6 +24,9 @@ export const pluginSettingsFieldSchema = z.object({ hint: z.string().nullish(), required: z.boolean().optional(), secret: z.boolean().optional(), + /** The field's effective value when nothing is stored: the form pre-fills it and the + * runtime resolves it, so the form shows what the plugin gets. Never on a secret field. */ + default: z.union([z.string(), z.number(), z.boolean()]).nullish(), options: z.array(z.object({ value: z.string(), label: z.string() })).optional(), }); export type PluginSettingsField = z.infer; diff --git a/wiki/Plugin-Development.md b/wiki/Plugin-Development.md index 0714a5c4c..50da22e08 100644 --- a/wiki/Plugin-Development.md +++ b/wiki/Plugin-Development.md @@ -271,7 +271,7 @@ parser would ignore one if you added it. | `ctx.ai` | `complete(prompt, system?)` → `{ text }`; `extract(text, jsonSchema, prompt?)` → `{ results }` — the admin/user-configured provider; host holds the key; output is DATA (no auto-writes) | `ai:invoke` | | `ctx.oauth` | `getAccessToken()` → a **short-lived access token** for the acting user of a third-party service the host connected on their behalf (Settings → Plugins → Connect); `null` if not connected / userless. Host holds the refresh token + client secret | `oauth:client` | | `ctx.scheduler` | `at(whenMs, name, payload?)` / `in(ms, name, payload?)` / `every(ms, name, payload?)` / `cancel(name)` — **persistent, userless** timers that survive restarts and fire your `scheduled(input, ctx)` handler. `set` is an upsert by `name`; caps: ≤100 tasks, 8 KB payload, recurring interval ≥ 60 s, ≤ ~1 year out. Same risk class as `jobs` (no acting user → trip reads refused) | `jobs:run` | -| `ctx.settings` | `get(key)` — the **acting user's** own value for one of your `scope:'user'` settings fields (decrypted host-side). Returns `undefined` for an unset value or a userless context (job/onLoad) — fall back to `ctx.config` there. Users fill these in under **Settings → Plugins**; secrets are stored encrypted and never echoed back | none (your own settings) | +| `ctx.settings` | `get(key)` — the **acting user's** own value for one of your `scope:'user'` settings fields (decrypted host-side), or the field's manifest `default` when they never set it. Returns `undefined` for a field with neither, and in a userless context (job/onLoad) — fall back to `ctx.config` there. Users fill these in under **Settings → Plugins**; secrets are stored encrypted and never echoed back | none (your own settings) | | `ctx.daynotes` | `list(tripId, dayId)` — a day's notes (membership-checked) | `db:read:daynotes` | | `ctx.daynotes` (write) | `create(tripId, dayId, {text, time?, icon?, sort_order?})` / `update(tripId, dayId, noteId, fields)` / `delete(tripId, dayId, noteId)` — broadcasts `dayNote:*` | `db:write:daynotes` | | `ctx.packing` (write) | `create(tripId, {name, category?, checked?, is_private?, visibility?, recipient_ids?})` / `update(tripId, itemId, fields)` / `delete(tripId, itemId)` — broadcasts `packing:*`, private items (#858) stay owner-scoped | `db:write:packing` | @@ -292,7 +292,7 @@ parser would ignore one if you added it. | `ctx.ws.broadcastToUser(userId, event, data)` | broadcast to one user | `ws:broadcast:user` | | `ctx.plugins.call(id, fn, args?)` | call a function another plugin **exposes** and get its result — `id` must be a declared, satisfied `pluginDependency` that lists `fn` in its `capabilities.provides` | a plugin dependency (no permission) | | `ctx.events.emit(name, payload?)` | publish an event to dependents that subscribed — `name` must be in your `capabilities.emits` | — (no permission) | -| `ctx.config` | your resolved settings (secrets delivered decrypted) | — | +| `ctx.config` | your `scope:'instance'` settings (secrets delivered decrypted, a field nobody set resolves to its manifest `default`), frozen at activation | — | | `ctx.log` | `info` / `warn` / `error` → your error log | — | | `ctx.id` | your plugin id (string) | — | @@ -708,6 +708,26 @@ fall back to `ctx.config` there). `secret: true` fields are stored encrypted and delivered decrypted through whichever of the two applies (server-side only) — never to the iframe. +Two attributes do more than decorate the form: + +- **`default`** is the field's value wherever nobody set one. The form pre-fills it + **and the runtime resolves it** — `ctx.config.api_url` and + `ctx.settings.get('region')` return the default until someone saves something + else — so a plugin that ships sensible defaults works before anyone opens the + form. A default satisfies `required`. It is refused on a `secret` (the manifest + is public), must be a boolean on a `checkbox`, and must be one of the `options` + when those are declared; the host drops a default that breaks these rules at + install, `trek-plugin validate` errors on it first. +- **`required`** is enforced on both ends: the form refuses Save while the field is + blank (naming it), and the host answers `400 { error: 'Missing required setting + ""' }` if a save reaches it anyway. A `checkbox` is exempt. A required + `scope:'user'` field also decides whether a notification channel dispatches to + that user at all. + +Any attribute outside `key, label, input_type, placeholder, hint, required, secret, +scope, options, oauth, default` is silently dropped at install; `trek-plugin +validate` warns on one (`manifest.settings-known-keys`). + ### A custom settings page (`capabilities.settingsUi`) When declared fields aren't enough — a picker with previews, anything visual —