Fix/sdk default (#2199)

* feat(plugin-sdk): settings-field default + unknown-attribute warning

* feat(shared): default on the plugin settings-field contract

* feat(server): persist and serve plugin settings-field defaults

* feat(client): pre-fill plugin settings defaults in the admin and user forms

* feat(server): enforce required plugin settings on both save paths

* feat(client): block plugin-settings save on an empty required field

* feat(server): resolve plugin settings defaults at runtime

A settings-field `default` was stored and served to the form but never
reached the plugin: the child's init envelope, `ctx.settings.get()`, the
channel dispatch's readAll and the OAuth provider config all read the
stored blob only. Fold `stored ?? default` in on every path that hands
config to a plugin (settings-defaults.ts), and let a default satisfy
`required` on both save gates — refusing a save the runtime would have
accepted was a contradiction.

Also: one `isFilled` rule shared by the save gate and `hasRequired`
(checkbox exempt, whitespace empty); the install parser drops a
non-boolean checkbox default or a select default outside its options,
matching what the SDK already refuses; export SETTING_FIELD_KEYS so the
SDK's mirror can be parity-tested.

* feat(client): share the plugin settings form rules across all three forms

The mobile user settings screen was a third consumer of the fields/config
contract that never got `default` pre-fill or the required check, and
the seed/patch loops lived in five hand copies. Extract
seedSettingsValues / findMissingRequired / settingsPatch into one module
and use it from the admin instance form, the desktop user tab and the
mobile screen. The user forms now surface a 4xx `{ error }` from the
server (the required-field refusal) instead of the generic toast.

* feat(plugin-sdk): seed dev fixtures from manifest defaults

`trek-plugin dev` handed the plugin only the fixture values, so a plugin
relying on its manifest defaults read undefined in dev and only in dev.
Seed ctx.config / ctx.settings from the declared defaults (a fixture
value still wins) via a new exported settingDefaults(manifest, scope).
SETTING_FIELD_KEYS is now parity-tested against the host's list.

* docs(plugins): document settings defaults as runtime fallbacks

A `default` is the field's effective value wherever nobody set one, not
just a form pre-fill; `required` is enforced on both ends; unknown
attributes are dropped at install and warned on by validate. Add a
Settings section with the attribute table to the SDK README and update
the ctx type comments, the shared schema, and the wiki guide.

* add missing exports
This commit is contained in:
jubnl
2026-09-02 18:42:16 +02:00
committed by GitHub
parent 17668e5ff2
commit 1e6ab069cd
86 changed files with 1022 additions and 110 deletions
@@ -2297,4 +2297,50 @@ describe('AdminPluginsPanel — instance settings', () => {
expect(screen.queryByDisplayValue('https://gotify.mydomain.com')).not.toBeInTheDocument() 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)
})
}) })
@@ -2,9 +2,9 @@ import { useState } from 'react'
import { adminApi, type PluginUserSettingField } from '../../api/client' import { adminApi, type PluginUserSettingField } from '../../api/client'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' 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. */ /** 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 { export interface InstanceSettingsForm {
id: string id: string
@@ -31,12 +31,7 @@ export function useInstanceSettings() {
setError('') setError('')
adminApi.pluginConfig(id) adminApi.pluginConfig(id)
.then(d => { .then(d => {
const values: Record<string, string | boolean> = {} setForm({ id, fields: d.fields, values: seedSettingsValues(d.fields, d.config) })
for (const f of d.fields) {
const v = d.config[f.key]
values[f.key] = f.input_type === 'checkbox' ? v === true : (v == null ? '' : String(v))
}
setForm({ id, fields: d.fields, values })
}) })
.catch(() => toast.error(t('common.error'))) .catch(() => toast.error(t('common.error')))
} }
@@ -48,15 +43,14 @@ export function useInstanceSettings() {
const save = async () => { const save = async () => {
if (!form) return 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('') setSaving(true); setError('')
try { try {
const patch: Record<string, unknown> = {} const d = await adminApi.pluginSaveConfig(form.id, settingsPatch(form.fields, form.values))
for (const f of form.fields) {
const v = form.values[f.key]
if (f.secret && v === SECRET_MASK) continue
patch[f.key] = v
}
const d = await adminApi.pluginSaveConfig(form.id, patch)
toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved')) toast.success(t(d.restarted ? 'admin.plugins.settingsSavedRestarted' : 'admin.plugins.settingsSaved'))
setForm(null) setForm(null)
} catch (e) { } catch (e) {
@@ -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<PluginUserSettingField> & { 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 });
});
});
@@ -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<string, string | boolean>;
/**
* 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<string, unknown>): 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<string, unknown> {
const patch: Record<string, unknown> = {};
for (const f of fields) {
const v = values[f.key];
if (f.secret && v === SECRET_MASK) continue;
patch[f.key] = v;
}
return patch;
}
@@ -448,4 +448,67 @@ describe('PluginSettingsTab', () => {
expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument(); expect(await screen.findByDisplayValue('Trips')).toBeInTheDocument();
expect(within(cardFor('Notes')).getByText('Folder')).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(<PluginSettingsTab />);
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(<PluginSettingsTab />);
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(<><ToastContainer /><PluginSettingsTab /></>);
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(<><ToastContainer /><PluginSettingsTab /></>);
await screen.findByText('City');
await user.click(screen.getByRole('button', { name: 'Save' }));
expect(await screen.findByText('Missing required setting "city"')).toBeInTheDocument();
});
}); });
@@ -7,6 +7,7 @@ import { usePluginStore } from '../../store/pluginStore'
import { useToast } from '../shared/Toast' import { useToast } from '../shared/Toast'
import { useTranslation } from '../../i18n' import { useTranslation } from '../../i18n'
import PluginActivityPanel from './PluginActivityPanel' import PluginActivityPanel from './PluginActivityPanel'
import { seedSettingsValues, findMissingRequired, settingsPatch } from '../Plugins/settingsForm'
/** Host-brokered OAuth: a Connect/Disconnect control. The host runs the whole flow + /** 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. */ * 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 * 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 if (!alive) return
setFields(r.fields) setFields(r.fields)
setActions(r.actions ?? []) setActions(r.actions ?? [])
const init: Record<string, string | boolean> = {} setValues(seedSettingsValues(r.fields, r.config))
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)
}) })
.catch(() => { if (alive) setFields([]) }) .catch(() => { if (alive) setFields([]) })
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) }) 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 save = async () => {
const missing = findMissingRequired(fields, values)
if (missing) {
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
return
}
setSaving(true) setSaving(true)
try { try {
// Skip an untouched secret (still shows the mask) so we never overwrite it with the mask. const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
const patch: Record<string, unknown> = {} setValues(seedSettingsValues(fields, r.config))
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<string, string | boolean> = {}
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)
toast.success(t('settings.plugins.saved')) toast.success(t('settings.plugins.saved'))
} catch { } catch (e) {
toast.error(t('common.error')) // 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 { } finally {
setSaving(false) setSaving(false)
} }
@@ -17,6 +17,7 @@ import { MSetCard, MSetEyebrow, MSetSelectRow, MSetInput, MSetButton, MSetHint }
import MToggle from '../../components/MToggle' import MToggle from '../../components/MToggle'
import MConfirmSheet from './MConfirmSheet' import MConfirmSheet from './MConfirmSheet'
import MSetPickerSheet from './MSetPickerSheet' 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 + /** 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. */ * 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 * 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 if (!alive) return
setFields(r.fields) setFields(r.fields)
setActions(r.actions ?? []) setActions(r.actions ?? [])
const init: Record<string, string | boolean> = {} setValues(seedSettingsValues(r.fields, r.config))
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)
}) })
.catch(() => { if (alive) setFields([]) }) .catch(() => { if (alive) setFields([]) })
pluginsApi.oauthStatus(id).then(s => { if (alive) setOauth(s) }).catch(() => { if (alive) setOauth(null) }) 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 save = async () => {
const missing = findMissingRequired(fields, values)
if (missing) {
toast.error(t('settings.plugins.requiredMissing', { field: missing.label || missing.key }))
return
}
setSaving(true) setSaving(true)
try { try {
// Skip an untouched secret (still shows the mask) so we never overwrite it with the mask. const r = await pluginsApi.saveUserSettings(id, settingsPatch(fields, values))
const patch: Record<string, unknown> = {} setValues(seedSettingsValues(fields, r.config))
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<string, string | boolean> = {}
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)
toast.success(t('settings.plugins.saved')) toast.success(t('settings.plugins.saved'))
} catch { } catch (e) {
toast.error(t('common.error')) // 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 { } finally {
setSaving(false) setSaving(false)
} }
+34
View File
@@ -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 TREK, and it grants exactly the permissions your `trek-plugin.json` declares — an
ungranted call throws `PERMISSION_DENIED`. 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 "<key>"' }` 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 ## Test without a running TREK
```js ```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`). - `definePlugin(def)` + all the plugin types (`PluginContext`, `PluginRoute`, `PluginJob`, `PhotoProvider`, `CalendarSource`).
- `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest. - `PLUGIN_API_VERSION` — embed as `apiVersion` in your manifest.
- `validateManifest(json)` — the manifest rules the server loader uses. - `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`). - `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 `<!-- trek:ui -->` marker instead. - `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 `<!-- trek:ui -->` marker instead.
+29 -1
View File
@@ -11,7 +11,7 @@
*/ */
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import path from 'node:path'; 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 // 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. // only list that counts — and permissions.ts for why dev's egress guard uses the same one.
import { grantedHosts } from '../../permissions.js'; 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<string, unknown> => !!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 = { const manifestNoEmoji: OfflineCheck = {
id: 'manifest.no-emoji', id: 'manifest.no-emoji',
stage: 'manifest', stage: 'manifest',
@@ -592,6 +619,7 @@ export const OFFLINE_CHECKS: OfflineCheck[] = [
manifestTrekRange, manifestTrekRange,
manifestIdMatchesDir, manifestIdMatchesDir,
manifestAddonsKnown, manifestAddonsKnown,
manifestSettingsKnownKeys,
manifestNoEmoji, manifestNoEmoji,
codeServerEntry, codeServerEntry,
codeEgressReachable, codeEgressReachable,
+13 -1
View File
@@ -18,6 +18,7 @@
* Dependency-free: node:http + node:sqlite (when present) + built-ins. * Dependency-free: node:http + node:sqlite (when present) + built-ins.
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import { settingDefaults } from '../manifest.js';
import path from 'node:path'; import path from 'node:path';
import http from 'node:http'; import http from 'node:http';
import { createRequire } from 'node:module'; 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). */ /** 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 { function watchTree(root: string, onChange: () => void): void {
const skip = new Set(['node_modules', '.git', '.trek-dev']); const skip = new Set(['node_modules', '.git', '.trek-dev']);
const watch = (d: string) => { const watch = (d: string) => {
@@ -310,7 +319,10 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise
const manifest = readJsonFile<Record<string, unknown>>(manifestPath); const manifest = readJsonFile<Record<string, unknown>>(manifestPath);
const id = String(manifest.id); const id = String(manifest.id);
const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []); 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 { db, note: dbNote, close: closeDb } = createDevDb(path.join(abs, '.trek-dev', 'db.sqlite'));
const broadcasts: unknown[] = []; const broadcasts: unknown[] = [];
installSdkInjection(); installSdkInjection();
+8 -2
View File
@@ -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. */ * A legitimate plugin never hits the generous burst. See README § Runtime limits. */
export interface PluginContext { export interface PluginContext {
readonly id: string; 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<Record<string, unknown>>; readonly config: Readonly<Record<string, unknown>>;
/** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields /** 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) * (decrypted host-side), or the field's manifest `default` when they never set it.
* — fall back to `config` (the admin-owned instance settings) there. */ * Undefined for a field with neither, and in a userless context (job/onLoad) — fall
* back to `config` (the admin-owned instance settings) there. */
settings: { settings: {
get(key: string): Promise<unknown>; get(key: string): Promise<unknown>;
}; };
@@ -849,6 +853,8 @@ export function definePlugin(def: PluginDefinition): PluginDefinition {
export { export {
validateManifest, validateManifest,
settingDefaults,
SETTING_FIELD_KEYS,
CHANNEL_EVENTS, CHANNEL_EVENTS,
type PluginManifest, type PluginManifest,
type NormalizedManifest, type NormalizedManifest,
+50
View File
@@ -21,6 +21,39 @@ export interface ManifestSettingField {
scope?: 'instance' | 'user'; scope?: 'instance' | 'user';
options?: Array<string | number | { value: string | number; label?: string }>; options?: Array<string | number | { value: string | number; label?: string }>;
oauth?: { initPath?: string; callbackPath?: string }; 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<string, string | number | boolean> {
const settings = (manifest as { settings?: unknown } | null)?.settings;
const out: Record<string, string | number | boolean> = {};
if (!Array.isArray(settings)) return out;
for (const s of settings as unknown[]) {
if (!s || typeof s !== 'object') continue;
const f = s as Record<string, unknown>;
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 { export interface ManifestAction {
key: string; 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. // Settings-page action buttons ("Test connection"). Keys share the settings-key rules.
+14
View File
@@ -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', () => { describe('README helpers mirror the registry exactly', () => {
it('strips headings, tables, links, images and code fences before measuring prose', () => { 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'; 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';
+51 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 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 }] }); 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); 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({});
});
});
@@ -15,6 +15,7 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { HOOK_PERMISSION } from '../src/permissions.js'; 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'; 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)); 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);
});
});
+9
View File
@@ -1151,3 +1151,12 @@ describe('validateManifest: the trek range', () => {
expect(isUnboundedRange('>=3.2.0 <4.0.0')).toBe(false); 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' });
});
});
+14 -2
View File
@@ -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 * 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 * linked trips happen to contain. Owners who want them back have one
* switch in Journey Settings. * 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 }>; 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'); 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) { if (currentVersion < migrations.length) {
+3 -2
View File
@@ -88,8 +88,8 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
// Refresh the settings-field descriptors from the manifest. // Refresh the settings-field descriptors from the manifest.
db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id); db.prepare('DELETE FROM plugin_settings_fields WHERE plugin_id = ?').run(m.id);
const insert = db.prepare( 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) `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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
); );
m.settings.forEach((f, i) => { m.settings.forEach((f, i) => {
insert.run( insert.run(
@@ -104,6 +104,7 @@ function upsert(db: BetterSqlite3.Database, m: PluginManifest): void {
f.scope ?? 'instance', f.scope ?? 'instance',
f.options ? JSON.stringify(f.options) : null, f.options ? JSON.stringify(f.options) : null,
f.oauth ? JSON.stringify(f.oauth) : null, f.oauth ? JSON.stringify(f.oauth) : null,
f.default === undefined ? null : JSON.stringify(f.default),
i, i,
); );
}); });
+50 -12
View File
@@ -26,6 +26,7 @@ export interface ManifestSettingField {
hint?: string; hint?: string;
required?: boolean; required?: boolean;
secret?: boolean; secret?: boolean;
default?: string | number | boolean;
scope?: 'instance' | 'user'; scope?: 'instance' | 'user';
options?: Array<{ value: string; label: string }>; options?: Array<{ value: string; label: string }>;
oauth?: { initPath?: string; callbackPath?: 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 SETTING_KEY_RE = /^[a-zA-Z][a-zA-Z0-9_.-]{0,63}$/;
const RESERVED_SETTING_KEYS = new Set(['constructor', 'prototype', '__proto__']); 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[] { function parseSettings(raw: unknown): ManifestSettingField[] {
if (!Array.isArray(raw)) return []; if (!Array.isArray(raw)) return [];
return raw return raw
.filter((s): s is Record<string, unknown> => !!s && typeof s === 'object') .filter((s): s is Record<string, unknown> => !!s && typeof s === 'object')
.map((s): ManifestSettingField => ({ .map((s): ManifestSettingField => {
key: assertSettingKey(String(s.key ?? '')), const input_type = optStr(s.input_type) ?? 'text';
label: optStr(s.label), const secret = !!s.secret;
input_type: optStr(s.input_type) ?? 'text', const options = parseSettingOptions(s.options);
placeholder: optStr(s.placeholder), return {
hint: optStr(s.hint), key: assertSettingKey(String(s.key ?? '')),
required: !!s.required, label: optStr(s.label),
secret: !!s.secret, input_type,
scope: s.scope === 'user' ? 'user' : 'instance', placeholder: optStr(s.placeholder),
options: parseSettingOptions(s.options), hint: optStr(s.hint),
oauth: s.oauth && typeof s.oauth === 'object' ? (s.oauth as { initPath?: string; callbackPath?: string }) : undefined, 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); .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 — * 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 * the obvious thing to write, and what a manifest was silently allowed to carry — rendered
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { DatabaseService } from '../../database/database.service'; import { DatabaseService } from '../../database/database.service';
import { encrypt_api_key, decrypt_api_key } from '../../common/crypto/apiKeyCrypto'; import { encrypt_api_key, decrypt_api_key } from '../../common/crypto/apiKeyCrypto';
import { applySettingDefaults, settingDefaults } from '../settings-defaults';
import { getAppUrl } from '../../../app-config'; import { getAppUrl } from '../../../app-config';
import { isPrivateIp } from '../install/safe-fetch'; import { isPrivateIp } from '../install/safe-fetch';
import { safeFetchLlm } from '../../../utils/ssrfGuard'; import { safeFetchLlm } from '../../../utils/ssrfGuard';
@@ -82,6 +83,9 @@ export class PluginOAuthService {
} catch { } catch {
return null; 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 authorizeUrl = String(cfg.oauth_authorize_url ?? '').trim();
const tokenUrl = String(cfg.oauth_token_url ?? '').trim(); const tokenUrl = String(cfg.oauth_token_url ?? '').trim();
const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : ''; const clientId = cfg.oauth_client_id ? String(decrypt_api_key(cfg.oauth_client_id)) : '';
@@ -11,6 +11,7 @@ import { PLUGIN_CHANNEL_EVENTS } from './install/manifest';
import { stripEmoji } from './text-sanitize'; import { stripEmoji } from './text-sanitize';
import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup'; import { applyStagedPluginTrees, setStagedRestoreApplier } from './plugin-backup';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { applySettingDefaults, settingDefaults } from './settings-defaults';
import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor'; import { PluginSupervisor, type PluginRouteInfo } from './supervisor/plugin-supervisor';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -565,7 +566,9 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
const declared = parseArray(row.permissions).filter(isKnownPermission); const declared = parseArray(row.permissions).filter(isKnownPermission);
// Mark it enabled (admin intent) so it reboots after restarts/crashes. // 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); 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)); 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 // 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 // self-hosted service can't name the operator's hostname in its manifest, so without
@@ -2,7 +2,7 @@ import { Body, Controller, Get, HttpCode, HttpException, Param, Post, Req, UseGu
import type { Request } from 'express'; import type { Request } from 'express';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { pluginsEnabled } from './kill-switch'; import { pluginsEnabled } from './kill-switch';
import { PluginsService } from './plugins.service'; import { PluginsService, MissingRequiredSettingError } from './plugins.service';
import { PluginRuntimeService } from './plugin-runtime.service'; import { PluginRuntimeService } from './plugin-runtime.service';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { PluginUserSettingsUpdateDto } from './plugins.dto'; import { PluginUserSettingsUpdateDto } from './plugins.dto';
@@ -81,6 +81,11 @@ export class PluginUserSettingsController {
const userId = req.user?.id; const userId = req.user?.id;
if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} }; if (!pluginsEnabled() || userId == null || !this.activeWithUserFields(id)) return { config: {} };
const patch = body?.config && typeof body.config === 'object' ? (body.config as Record<string, unknown>) : {}; const patch = body?.config && typeof body.config === 'object' ? (body.config as Record<string, unknown>) : {};
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;
}
} }
} }
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DatabaseService } from '../database/database.service'; import { DatabaseService } from '../database/database.service';
import { decrypt_api_key } from '../common/crypto/apiKeyCrypto'; import { decrypt_api_key } from '../common/crypto/apiKeyCrypto';
import { safeParseConfig } from './plugin-config-parse'; import { safeParseConfig } from './plugin-config-parse';
import { isFilled, settingDefaults } from './settings-defaults';
/** /**
* A plugin's per-user settings, decrypted host-side. * A plugin's per-user settings, decrypted host-side.
@@ -31,7 +32,7 @@ export class PluginUserSettingsService {
.get(pluginId, key) as { secret: number } | undefined .get(pluginId, key) as { secret: number } | undefined
)?.secret === 1; )?.secret === 1;
const value = this.storedFor(pluginId, userId)[key]; 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; 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 // 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. // through to Object.prototype on the way out to the plugin.
const out: Record<string, unknown> = Object.create(null); const out: Record<string, unknown> = Object.create(null);
const defaults = settingDefaults(this.db, pluginId, 'user');
for (const field of fields) { for (const field of fields) {
const value = stored[field.field_key]; 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; out[field.field_key] = field.secret === 1 ? decrypt_api_key(value as string) : value;
} }
return out; return out;
@@ -62,17 +67,22 @@ export class PluginUserSettingsService {
* Has this user filled in every `required`, `scope:'user'` field the plugin declares? * 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 * A plugin with no required user fields is configured for everyone (an instance-wide
* channel, e.g. a shared workspace webhook). * 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 { hasRequired(pluginId: string, userId: number): boolean {
const required = this.db 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 }>; .all(pluginId) as Array<{ field_key: string }>;
if (required.length === 0) return true; if (required.length === 0) return true;
const stored = this.storedFor(pluginId, userId); const stored = this.storedFor(pluginId, userId);
return required.every((field) => { const defaults = settingDefaults(this.db, pluginId, 'user');
const value = stored[field.field_key]; return required.every((field) => isFilled(stored[field.field_key] ?? defaults[field.field_key]));
return value != null && String(value) !== '';
});
} }
private storedFor(pluginId: string, userId: number): Record<string, unknown> { private storedFor(pluginId: string, userId: number): Record<string, unknown> {
@@ -1,7 +1,7 @@
import { Body, Controller, Delete, Get, HttpCode, HttpException, Param, Post, Put, Query, Req, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; 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 { FileInterceptor } from '@nestjs/platform-express';
import type { Request } from '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 { PluginRuntimeService, PluginConsentRequired, PluginDependencyError } from './plugin-runtime.service';
import { DependencyCycleError } from './dependencies'; import { DependencyCycleError } from './dependencies';
import { PluginRegistryService, RegistryError } from './registry/registry.service'; import { PluginRegistryService, RegistryError } from './registry/registry.service';
@@ -151,8 +151,13 @@ export class PluginsController {
*/ */
@Put(':id/config') @Put(':id/config')
async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise<PluginInstanceConfigUpdated> { async updateConfig(@Param('id') id: string, @Body() body: PluginConfigDto): Promise<PluginInstanceConfigUpdated> {
const config = this.plugins.updateInstanceConfig(id, body || {}); try {
return { config, restarted: await this.runtime.respawnIfActive(id) }; 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;
}
} }
/** /**
+32 -1
View File
@@ -7,6 +7,7 @@ import { readAudit } from './host/plugin-audit';
import { keyFingerprint } from './signature-status'; import { keyFingerprint } from './signature-status';
import { pluginBudgetUsage } from './host/plugin-host-state'; import { pluginBudgetUsage } from './host/plugin-host-state';
import { safeParseConfig as safeParse } from './plugin-config-parse'; import { safeParseConfig as safeParse } from './plugin-config-parse';
import { isFilled, parseDefaultValue, settingDefaults } from './settings-defaults';
import { AddonsService } from '../addons/addons.service'; import { AddonsService } from '../addons/addons.service';
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies'; import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
import { hostSatisfies, hostVersion } from './install/host-compat'; import { hostSatisfies, hostVersion } from './install/host-compat';
@@ -17,6 +18,14 @@ const SECRET_MASK = '••••••••';
export type PluginDependencyStatus = 'ok' | 'addonDisabled' | 'missingPlugin' | 'hostIncompatible'; 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 * Read side of the plugin system (#plugins), M0 scaffold. Lists installed
* plugins from the `plugins` registry table and reports whether the runtime is * plugins from the `plugins` registry table and reports whether the runtime is
@@ -229,6 +238,7 @@ export class PluginsService {
config[k] = v; 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); this.db.prepare('UPDATE plugins SET config = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(JSON.stringify(config), id);
return maskSecrets(config, secretKeys); return maskSecrets(config, secretKeys);
} }
@@ -246,7 +256,7 @@ export class PluginsService {
private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] { private settingsFields(id: string, scope: 'user' | 'instance'): PluginSettingsField[] {
return this.db return this.db
.prepare( .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`, FROM plugin_settings_fields WHERE plugin_id = ? AND scope = ? ORDER BY sort_order, id`,
) )
.all(id, scope) .all(id, scope)
@@ -260,6 +270,7 @@ export class PluginsService {
hint: (row.hint ?? null) as string | null, hint: (row.hint ?? null) as string | null,
required: row.required === 1, required: row.required === 1,
secret: row.secret === 1, secret: row.secret === 1,
default: parseDefaultValue(row.default_value),
// Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check. // Stored as manifest-validated JSON ({value,label} pairs) — parse, don't re-check.
options: options:
typeof row.options === 'string' && row.options typeof row.options === 'string' && row.options
@@ -309,6 +320,7 @@ export class PluginsService {
config[k] = v; config[k] = v;
} }
} }
this.assertRequiredFilled(id, 'user', config);
this.db.prepare( this.db.prepare(
`INSERT INTO plugin_user_config (plugin_id, user_id, config, updated_at) VALUES (?, ?, ?, datetime('now')) `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`, 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); 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<string, unknown>): 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 { function safeArray(json: string): unknown[] | undefined {
@@ -13,10 +13,13 @@ export { PLUGIN_API_VERSION } from '../protocol/envelope';
export interface PluginContext { export interface PluginContext {
readonly id: string; 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<Record<string, unknown>>; readonly config: Readonly<Record<string, unknown>>;
/** The ACTING USER's own value for one of this plugin's `scope:'user'` settings fields /** 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 * (decrypted host-side), or the field's manifest `default` when they never set it.
* (job/onLoad) — fall back to `config` (the admin-owned instance settings) there. */ * Undefined for a field with neither, and in a userless context (job/onLoad) — fall
* back to `config` (the admin-owned instance settings) there. */
settings: { settings: {
get(key: string): Promise<unknown>; get(key: string): Promise<unknown>;
}; };
@@ -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<string, SettingDefault> {
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<string, SettingDefault> = 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<string, unknown>,
defaults: Record<string, SettingDefault>,
): Record<string, unknown> {
const out: Record<string, unknown> = 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() !== '';
}
@@ -32,7 +32,7 @@ const { testDb } = vi.hoisted(() => {
trek_range TEXT DEFAULT '>=3.0.0', 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); 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_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 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_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)); CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
@@ -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, 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); 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_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 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_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);`); CREATE TABLE addons (id TEXT PRIMARY KEY, enabled INTEGER DEFAULT 0);`);
@@ -21,7 +21,7 @@ const { testDb } = vi.hoisted(() => {
trek_range TEXT DEFAULT '>=3.0.0', 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); 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_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 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_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)); CREATE TABLE plugin_user_config (plugin_id TEXT, user_id INTEGER, field_key TEXT, value TEXT, PRIMARY KEY (plugin_id, user_id, field_key));
@@ -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, 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, 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); 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);`); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
return { testDb: db }; return { testDb: db };
}); });
+1 -1
View File
@@ -29,7 +29,7 @@ beforeEach(() => {
CREATE TABLE plugins (id TEXT PRIMARY KEY, name TEXT, description TEXT, type TEXT, icon TEXT, version TEXT, 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); 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, 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, 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)); 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);`); CREATE TABLE plugin_error_log (id INTEGER PRIMARY KEY AUTOINCREMENT, plugin_id TEXT, level TEXT, message TEXT, ts TEXT);`);
+15 -1
View File
@@ -3,7 +3,7 @@
* unknown permissions, native modules, and http:outbound without egress. * unknown permissions, native modules, and http:outbound without egress.
*/ */
import { describe, it, expect } from 'vitest'; 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 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' }); 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([]); 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<string, unknown> = {
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();
});
});
@@ -6,7 +6,10 @@
* saving through the controller re-spawns an ACTIVE plugin (config is handed to * 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. * 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 { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3'); 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 type { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
import { AddonsService } from '../../../src/nest/addons/addons.service'; import { AddonsService } from '../../../src/nest/addons/addons.service';
import { createPluginRuntime } from '../../helpers/plugin-host'; import { createPluginRuntime } from '../../helpers/plugin-host';
import { discoverPlugins } from '../../../src/nest/plugins/install/discovery';
function install(id: string) { function install(id: string) {
testDb testDb
@@ -51,6 +55,23 @@ const svc = () => {
return new PluginsService(dbs, new AddonsService(dbs)); 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<Record<string, unknown>> }) {
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(() => { beforeAll(() => {
createTables(testDb); createTables(testDb);
runMigrations(testDb); runMigrations(testDb);
@@ -59,6 +80,12 @@ beforeEach(() => {
testDb.prepare('DELETE FROM plugins').run(); testDb.prepare('DELETE FROM plugins').run();
testDb.prepare('DELETE FROM plugin_settings_fields').run(); testDb.prepare('DELETE FROM plugin_settings_fields').run();
process.env.TREK_PLUGINS_ENABLED = 'true'; 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', () => { 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 === 'with-fields')).toMatchObject({ instanceSettingsCount: 2 });
expect(plugins.find((p) => p.id === 'plain')).toMatchObject({ instanceSettingsCount: 0 }); 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)', () => { describe('respawn on save (runtime)', () => {
@@ -163,3 +261,27 @@ describe('admin config endpoints (controller)', () => {
expect(out.restarted).toBe(false); 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<void> } }).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<string, unknown>;
expect(config).toEqual({ api_url: 'https://mine.example', retries: 3 }); // note: no default, no value → absent
});
});
@@ -44,6 +44,7 @@ function freshDb(cfg: Record<string, unknown> = CFG) {
const d = new Database(':memory:'); const d = new Database(':memory:');
d.exec(` d.exec(`
CREATE TABLE plugins (id TEXT PRIMARY KEY, config TEXT, status TEXT); 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_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); 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(); 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', () => { it('startConnect builds a PKCE authorize URL + persists a single fresh state per user', () => {
const url = new URL(svc.startConnect('p', 42, NOW)); const url = new URL(svc.startConnect('p', 42, NOW));
expect(url.origin + url.pathname).toBe('https://provider.example/authorize'); expect(url.origin + url.pathname).toBe('https://provider.example/authorize');
@@ -28,7 +28,7 @@ const userSettings = () => new PluginUserSettingsService(new DatabaseService(dbC
function freshDb() { function freshDb() {
const d = new Database(':memory:'); const d = new Database(':memory:');
d.exec(` 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)); 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. // 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', () => { it('ignores keys that are not declared user-scope fields', () => {
svc.updateUserConfig('p', 42, { adminOnly: 'nope', bogus: 'x', units: 'metric' } as Record<string, unknown>); // 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<string, unknown>);
const cfg = svc.getUserConfig('p', 42); const cfg = svc.getUserConfig('p', 42);
expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here expect(cfg.adminOnly).toBeUndefined(); // instance field — not accepted here
expect(cfg.bogus).toBeUndefined(); expect(cfg.bogus).toBeUndefined();
@@ -80,8 +82,80 @@ describe('per-user plugin settings', () => {
}); });
it('is per-user — one user cannot see another\'s value', () => { 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(svc.getUserConfig('p', 99).units).toBeUndefined();
expect(userSettings().readOne('p', 99, 'apiKey')).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);
});
});
@@ -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, 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, 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); 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');`); 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 }; return { testDb: db };
}); });
+1
View File
@@ -454,6 +454,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم', 'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم',
'admin.plugins.settingsSaved': 'تم حفظ الإعدادات', 'admin.plugins.settingsSaved': 'تم حفظ الإعدادات',
'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة', 'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة',
'admin.plugins.requiredMissing': '"{field}" مطلوب',
'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك', 'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك',
'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية', 'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية',
'admin.plugins.cap.readsCosts': 'يقرأ مصروفاتك', 'admin.plugins.cap.readsCosts': 'يقرأ مصروفاتك',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'إعداداتك الشخصية للإضافات التي تستخدمها (مفاتيح API، التفضيلات).', 'settings.plugins.subtitle': 'إعداداتك الشخصية للإضافات التي تستخدمها (مفاتيح API، التفضيلات).',
'settings.plugins.empty': 'لا توجد إضافات نشطة.', 'settings.plugins.empty': 'لا توجد إضافات نشطة.',
'settings.plugins.saved': 'تم حفظ الإعدادات', 'settings.plugins.saved': 'تم حفظ الإعدادات',
'settings.plugins.requiredMissing': '"{field}" مطلوب',
'settings.tabs.account': 'الحساب', 'settings.tabs.account': 'الحساب',
'settings.tabs.about': 'حول', 'settings.tabs.about': 'حول',
'settings.map': 'الخريطة', 'settings.map': 'الخريطة',
+1
View File
@@ -443,6 +443,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Configurações da instância', 'admin.plugins.instanceSettings': 'Configurações da instância',
'admin.plugins.settingsSaved': 'Configurações salvas', 'admin.plugins.settingsSaved': 'Configurações salvas',
'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado', 'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado',
'admin.plugins.requiredMissing': '"{field}" é obrigatório',
'admin.plugins.cap.readsTrips': 'Lê suas viagens', 'admin.plugins.cap.readsTrips': 'Lê suas viagens',
'admin.plugins.cap.readsUsers': 'Lê perfis básicos', 'admin.plugins.cap.readsUsers': 'Lê perfis básicos',
'admin.plugins.cap.readsCosts': 'Lê suas despesas', 'admin.plugins.cap.readsCosts': 'Lê suas despesas',
+1
View File
@@ -17,6 +17,7 @@ const settings: TranslationStrings = {
'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).', 'Suas configurações pessoais para os plugins que você usa (chaves de API, preferências).',
'settings.plugins.empty': 'Nenhum plugin ativo.', 'settings.plugins.empty': 'Nenhum plugin ativo.',
'settings.plugins.saved': 'Configurações salvas', 'settings.plugins.saved': 'Configurações salvas',
'settings.plugins.requiredMissing': '"{field}" é obrigatório',
'settings.tabs.account': 'Conta', 'settings.tabs.account': 'Conta',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Sobre', 'settings.tabs.about': 'Sobre',
+1
View File
@@ -577,6 +577,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Configuració de la instància', 'admin.plugins.instanceSettings': 'Configuració de la instància',
'admin.plugins.settingsSaved': 'Configuració desada', 'admin.plugins.settingsSaved': 'Configuració desada',
'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat', 'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat',
'admin.plugins.requiredMissing': '"{field}" és obligatori',
'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges', 'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges',
'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics', 'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics',
'admin.plugins.cap.readsCosts': 'Llegeix les teves despeses', 'admin.plugins.cap.readsCosts': 'Llegeix les teves despeses',
+1
View File
@@ -512,6 +512,7 @@ const settings: TranslationStrings = {
'La teva configuració personal per als connectors que fas servir (claus API, preferències).', '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.empty': 'No hi ha cap connector actiu.',
'settings.plugins.saved': 'Configuració desada', 'settings.plugins.saved': 'Configuració desada',
'settings.plugins.requiredMissing': '"{field}" és obligatori',
'settings.pluginActivity.title': 'Activitat dels connectors', '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.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.', 'settings.pluginActivity.empty': 'Encara no hi ha activitat de connectors.',
+1
View File
@@ -441,6 +441,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Nastavení instance', 'admin.plugins.instanceSettings': 'Nastavení instance',
'admin.plugins.settingsSaved': 'Nastavení uloženo', 'admin.plugins.settingsSaved': 'Nastavení uloženo',
'admin.plugins.settingsSavedRestarted': 'Nastavení uloženo — plugin restartován', '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.readsTrips': 'Čte vaše cesty',
'admin.plugins.cap.readsUsers': 'Čte základní profily', 'admin.plugins.cap.readsUsers': 'Čte základní profily',
'admin.plugins.cap.readsCosts': 'Čte vaše výdaje', 'admin.plugins.cap.readsCosts': 'Čte vaše výdaje',
+1
View File
@@ -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.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.empty': 'Nejsou aktivní žádné doplňky.',
'settings.plugins.saved': 'Nastavení uloženo', 'settings.plugins.saved': 'Nastavení uloženo',
'settings.plugins.requiredMissing': '„{field}" je povinné',
'settings.tabs.account': 'Účet', 'settings.tabs.account': 'Účet',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'O aplikaci', 'settings.tabs.about': 'O aplikaci',
+1
View File
@@ -450,6 +450,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instanz-Einstellungen', 'admin.plugins.instanceSettings': 'Instanz-Einstellungen',
'admin.plugins.settingsSaved': 'Einstellungen gespeichert', 'admin.plugins.settingsSaved': 'Einstellungen gespeichert',
'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet', 'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet',
'admin.plugins.requiredMissing': '"{field}" ist erforderlich',
'admin.plugins.cap.readsTrips': 'Liest deine Reisen', 'admin.plugins.cap.readsTrips': 'Liest deine Reisen',
'admin.plugins.cap.readsUsers': 'Liest Basis-Profile', 'admin.plugins.cap.readsUsers': 'Liest Basis-Profile',
'admin.plugins.cap.readsCosts': 'Liest deine Ausgaben', 'admin.plugins.cap.readsCosts': 'Liest deine Ausgaben',
+1
View File
@@ -17,6 +17,7 @@ const settings: TranslationStrings = {
'Deine persönlichen Einstellungen für die von dir genutzten Plugins (API-Schlüssel, Präferenzen).', '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.empty': 'Es sind keine Plugins aktiv.',
'settings.plugins.saved': 'Einstellungen gespeichert', 'settings.plugins.saved': 'Einstellungen gespeichert',
'settings.plugins.requiredMissing': '"{field}" ist erforderlich',
'settings.tabs.account': 'Konto', 'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Über', 'settings.tabs.about': 'Über',
+1
View File
@@ -488,6 +488,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instance settings', 'admin.plugins.instanceSettings': 'Instance settings',
'admin.plugins.settingsSaved': 'Settings saved', 'admin.plugins.settingsSaved': 'Settings saved',
'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted', 'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted',
'admin.plugins.requiredMissing': '"{field}" is required',
'admin.plugins.cap.readsTrips': 'Reads your trips', 'admin.plugins.cap.readsTrips': 'Reads your trips',
'admin.plugins.cap.readsUsers': 'Reads basic profiles', 'admin.plugins.cap.readsUsers': 'Reads basic profiles',
'admin.plugins.cap.readsCosts': 'Reads your costs', 'admin.plugins.cap.readsCosts': 'Reads your costs',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Your personal settings for the plugins you use (API keys, preferences).', 'settings.plugins.subtitle': 'Your personal settings for the plugins you use (API keys, preferences).',
'settings.plugins.empty': 'No plugins are active.', 'settings.plugins.empty': 'No plugins are active.',
'settings.plugins.saved': 'Settings saved', 'settings.plugins.saved': 'Settings saved',
'settings.plugins.requiredMissing': '"{field}" is required',
'settings.pluginActivity.title': 'Plugin activity', 'settings.pluginActivity.title': 'Plugin activity',
'settings.pluginActivity.description': 'Every action a plugin took on your behalf, newest first.', 'settings.pluginActivity.description': 'Every action a plugin took on your behalf, newest first.',
'settings.pluginActivity.empty': 'No plugin activity yet.', 'settings.pluginActivity.empty': 'No plugin activity yet.',
+1
View File
@@ -452,6 +452,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ajustes de la instancia', 'admin.plugins.instanceSettings': 'Ajustes de la instancia',
'admin.plugins.settingsSaved': 'Ajustes guardados', 'admin.plugins.settingsSaved': 'Ajustes guardados',
'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado', 'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado',
'admin.plugins.requiredMissing': '"{field}" es obligatorio',
'admin.plugins.cap.readsTrips': 'Lee tus viajes', 'admin.plugins.cap.readsTrips': 'Lee tus viajes',
'admin.plugins.cap.readsUsers': 'Lee perfiles básicos', 'admin.plugins.cap.readsUsers': 'Lee perfiles básicos',
'admin.plugins.cap.readsCosts': 'Lee tus gastos', 'admin.plugins.cap.readsCosts': 'Lee tus gastos',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Tus ajustes personales para los complementos que usas (claves de API, preferencias).', 'settings.plugins.subtitle': 'Tus ajustes personales para los complementos que usas (claves de API, preferencias).',
'settings.plugins.empty': 'No hay complementos activos.', 'settings.plugins.empty': 'No hay complementos activos.',
'settings.plugins.saved': 'Ajustes guardados', 'settings.plugins.saved': 'Ajustes guardados',
'settings.plugins.requiredMissing': '"{field}" es obligatorio',
'settings.tabs.account': 'Cuenta', 'settings.tabs.account': 'Cuenta',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Acerca de', 'settings.tabs.about': 'Acerca de',
+1
View File
@@ -455,6 +455,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Paramètres de l’instance', 'admin.plugins.instanceSettings': 'Paramètres de l’instance',
'admin.plugins.settingsSaved': 'Paramètres enregistrés', 'admin.plugins.settingsSaved': 'Paramètres enregistrés',
'admin.plugins.settingsSavedRestarted': 'Paramètres enregistrés — plugin redémarré', '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.readsTrips': 'Lit vos voyages',
'admin.plugins.cap.readsUsers': 'Lit les profils de base', 'admin.plugins.cap.readsUsers': 'Lit les profils de base',
'admin.plugins.cap.readsCosts': 'Lit vos dépenses', 'admin.plugins.cap.readsCosts': 'Lit vos dépenses',
+1
View File
@@ -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.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.empty': 'Aucun module actif.',
'settings.plugins.saved': 'Paramètres enregistrés', 'settings.plugins.saved': 'Paramètres enregistrés',
'settings.plugins.requiredMissing': '« {field} » est obligatoire',
'settings.tabs.account': 'Compte', 'settings.tabs.account': 'Compte',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'À propos', 'settings.tabs.about': 'À propos',
+1
View File
@@ -503,6 +503,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης', 'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης',
'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν', 'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν',
'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε', 'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε',
'admin.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό',
'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας', 'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας',
'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ', 'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ',
'admin.plugins.cap.readsCosts': 'Διαβάζει τα έξοδά σας', 'admin.plugins.cap.readsCosts': 'Διαβάζει τα έξοδά σας',
+1
View File
@@ -17,6 +17,7 @@ const settings: TranslationStrings = {
'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).', 'Οι προσωπικές σας ρυθμίσεις για τα πρόσθετα που χρησιμοποιείτε (κλειδιά API, προτιμήσεις).',
'settings.plugins.empty': 'Δεν υπάρχουν ενεργά πρόσθετα.', 'settings.plugins.empty': 'Δεν υπάρχουν ενεργά πρόσθετα.',
'settings.plugins.saved': 'Οι ρυθμίσεις αποθηκεύτηκαν', 'settings.plugins.saved': 'Οι ρυθμίσεις αποθηκεύτηκαν',
'settings.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό',
'settings.tabs.account': 'Λογαριασμός', 'settings.tabs.account': 'Λογαριασμός',
'settings.tabs.offline': 'Εκτός σύνδεσης', 'settings.tabs.offline': 'Εκτός σύνδεσης',
'settings.tabs.about': 'Σχετικά', 'settings.tabs.about': 'Σχετικά',
+1
View File
@@ -455,6 +455,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Példányszintű beállítások', 'admin.plugins.instanceSettings': 'Példányszintű beállítások',
'admin.plugins.settingsSaved': 'Beállítások elmentve', 'admin.plugins.settingsSaved': 'Beállítások elmentve',
'admin.plugins.settingsSavedRestarted': 'Beállítások elmentve — plugin újraindítva', '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.readsTrips': 'Olvassa az utazásaidat',
'admin.plugins.cap.readsUsers': 'Olvassa az alapprofilokat', 'admin.plugins.cap.readsUsers': 'Olvassa az alapprofilokat',
'admin.plugins.cap.readsCosts': 'Olvassa a költségeidet', 'admin.plugins.cap.readsCosts': 'Olvassa a költségeidet',
+1
View File
@@ -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.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.empty': 'Nincsenek aktív bővítmények.',
'settings.plugins.saved': 'Beállítások mentve', 'settings.plugins.saved': 'Beállítások mentve',
'settings.plugins.requiredMissing': '"{field}" kötelező',
'settings.tabs.account': 'Fiók', 'settings.tabs.account': 'Fiók',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Névjegy', 'settings.tabs.about': 'Névjegy',
+1
View File
@@ -497,6 +497,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Pengaturan instans', 'admin.plugins.instanceSettings': 'Pengaturan instans',
'admin.plugins.settingsSaved': 'Pengaturan disimpan', 'admin.plugins.settingsSaved': 'Pengaturan disimpan',
'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang', 'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang',
'admin.plugins.requiredMissing': '"{field}" wajib diisi',
'admin.plugins.cap.readsTrips': 'Membaca perjalananmu', 'admin.plugins.cap.readsTrips': 'Membaca perjalananmu',
'admin.plugins.cap.readsUsers': 'Membaca profil dasar', 'admin.plugins.cap.readsUsers': 'Membaca profil dasar',
'admin.plugins.cap.readsCosts': 'Membaca pengeluaranmu', 'admin.plugins.cap.readsCosts': 'Membaca pengeluaranmu',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Pengaturan pribadimu untuk plugin yang kamu gunakan (kunci API, preferensi).', 'settings.plugins.subtitle': 'Pengaturan pribadimu untuk plugin yang kamu gunakan (kunci API, preferensi).',
'settings.plugins.empty': 'Tidak ada plugin yang aktif.', 'settings.plugins.empty': 'Tidak ada plugin yang aktif.',
'settings.plugins.saved': 'Pengaturan disimpan', 'settings.plugins.saved': 'Pengaturan disimpan',
'settings.plugins.requiredMissing': '"{field}" wajib diisi',
'settings.tabs.account': 'Akun', 'settings.tabs.account': 'Akun',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Tentang', 'settings.tabs.about': 'Tentang',
+1
View File
@@ -450,6 +450,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Impostazioni dell’istanza', 'admin.plugins.instanceSettings': 'Impostazioni dell’istanza',
'admin.plugins.settingsSaved': 'Impostazioni salvate', 'admin.plugins.settingsSaved': 'Impostazioni salvate',
'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato', 'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato',
'admin.plugins.requiredMissing': '"{field}" è obbligatorio',
'admin.plugins.cap.readsTrips': 'Legge i tuoi viaggi', 'admin.plugins.cap.readsTrips': 'Legge i tuoi viaggi',
'admin.plugins.cap.readsUsers': 'Legge i profili di base', 'admin.plugins.cap.readsUsers': 'Legge i profili di base',
'admin.plugins.cap.readsCosts': 'Legge le tue spese', 'admin.plugins.cap.readsCosts': 'Legge le tue spese',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Le tue impostazioni personali per i plugin che usi (chiavi API, preferenze).', 'settings.plugins.subtitle': 'Le tue impostazioni personali per i plugin che usi (chiavi API, preferenze).',
'settings.plugins.empty': 'Nessun plugin attivo.', 'settings.plugins.empty': 'Nessun plugin attivo.',
'settings.plugins.saved': 'Impostazioni salvate', 'settings.plugins.saved': 'Impostazioni salvate',
'settings.plugins.requiredMissing': '"{field}" è obbligatorio',
'settings.tabs.account': 'Account', 'settings.tabs.account': 'Account',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Informazioni', 'settings.tabs.about': 'Informazioni',
+1
View File
@@ -462,6 +462,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'インスタンス設定', 'admin.plugins.instanceSettings': 'インスタンス設定',
'admin.plugins.settingsSaved': '設定を保存しました', 'admin.plugins.settingsSaved': '設定を保存しました',
'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました', 'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました',
'admin.plugins.requiredMissing': '「{field}」は必須です',
'admin.plugins.cap.readsTrips': '旅行を読み取り', 'admin.plugins.cap.readsTrips': '旅行を読み取り',
'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り', 'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り',
'admin.plugins.cap.readsCosts': '支出を読み取り', 'admin.plugins.cap.readsCosts': '支出を読み取り',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': '使用しているプラグインの個人設定(API キー、環境設定)。', 'settings.plugins.subtitle': '使用しているプラグインの個人設定(API キー、環境設定)。',
'settings.plugins.empty': '有効なプラグインはありません。', 'settings.plugins.empty': '有効なプラグインはありません。',
'settings.plugins.saved': '設定を保存しました', 'settings.plugins.saved': '設定を保存しました',
'settings.plugins.requiredMissing': '「{field}」は必須です',
'settings.tabs.account': 'アカウント', 'settings.tabs.account': 'アカウント',
'settings.tabs.offline': 'オフライン', 'settings.tabs.offline': 'オフライン',
'settings.tabs.about': '情報', 'settings.tabs.about': '情報',
+1
View File
@@ -462,6 +462,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '인스턴스 설정', 'admin.plugins.instanceSettings': '인스턴스 설정',
'admin.plugins.settingsSaved': '설정이 저장되었습니다', 'admin.plugins.settingsSaved': '설정이 저장되었습니다',
'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다', 'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다',
'admin.plugins.requiredMissing': '"{field}"은(는) 필수입니다',
'admin.plugins.cap.readsTrips': '여행 읽기', 'admin.plugins.cap.readsTrips': '여행 읽기',
'admin.plugins.cap.readsUsers': '기본 프로필 읽기', 'admin.plugins.cap.readsUsers': '기본 프로필 읽기',
'admin.plugins.cap.readsCosts': '지출 읽기', 'admin.plugins.cap.readsCosts': '지출 읽기',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': '사용 중인 플러그인의 개인 설정(API 키, 환경설정).', 'settings.plugins.subtitle': '사용 중인 플러그인의 개인 설정(API 키, 환경설정).',
'settings.plugins.empty': '활성화된 플러그인이 없습니다.', 'settings.plugins.empty': '활성화된 플러그인이 없습니다.',
'settings.plugins.saved': '설정이 저장되었습니다', 'settings.plugins.saved': '설정이 저장되었습니다',
'settings.plugins.requiredMissing': '"{field}"은(는) 필수입니다',
'settings.tabs.account': '계정', 'settings.tabs.account': '계정',
'settings.tabs.offline': '오프라인', 'settings.tabs.offline': '오프라인',
'settings.tabs.about': '정보', 'settings.tabs.about': '정보',
+1
View File
@@ -449,6 +449,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instantie-instellingen', 'admin.plugins.instanceSettings': 'Instantie-instellingen',
'admin.plugins.settingsSaved': 'Instellingen opgeslagen', 'admin.plugins.settingsSaved': 'Instellingen opgeslagen',
'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart', 'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart',
'admin.plugins.requiredMissing': '"{field}" is vereist',
'admin.plugins.cap.readsTrips': 'Leest je reizen', 'admin.plugins.cap.readsTrips': 'Leest je reizen',
'admin.plugins.cap.readsUsers': 'Leest basisprofielen', 'admin.plugins.cap.readsUsers': 'Leest basisprofielen',
'admin.plugins.cap.readsCosts': 'Leest je uitgaven', 'admin.plugins.cap.readsCosts': 'Leest je uitgaven',
+1
View File
@@ -17,6 +17,7 @@ const settings: TranslationStrings = {
'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).', 'Je persoonlijke instellingen voor de plug-ins die je gebruikt (API-sleutels, voorkeuren).',
'settings.plugins.empty': 'Er zijn geen plug-ins actief.', 'settings.plugins.empty': 'Er zijn geen plug-ins actief.',
'settings.plugins.saved': 'Instellingen opgeslagen', 'settings.plugins.saved': 'Instellingen opgeslagen',
'settings.plugins.requiredMissing': '"{field}" is vereist',
'settings.tabs.account': 'Account', 'settings.tabs.account': 'Account',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Over', 'settings.tabs.about': 'Over',
+1
View File
@@ -447,6 +447,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ustawienia instancji', 'admin.plugins.instanceSettings': 'Ustawienia instancji',
'admin.plugins.settingsSaved': 'Ustawienia zapisane', 'admin.plugins.settingsSaved': 'Ustawienia zapisane',
'admin.plugins.settingsSavedRestarted': 'Ustawienia zapisane — wtyczka została uruchomiona ponownie', '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.readsTrips': 'Odczytuje Twoje podróże',
'admin.plugins.cap.readsUsers': 'Odczytuje podstawowe profile', 'admin.plugins.cap.readsUsers': 'Odczytuje podstawowe profile',
'admin.plugins.cap.readsCosts': 'Odczytuje Twoje wydatki', 'admin.plugins.cap.readsCosts': 'Odczytuje Twoje wydatki',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Twoje osobiste ustawienia używanych wtyczek (klucze API, preferencje).', 'settings.plugins.subtitle': 'Twoje osobiste ustawienia używanych wtyczek (klucze API, preferencje).',
'settings.plugins.empty': 'Brak aktywnych wtyczek.', 'settings.plugins.empty': 'Brak aktywnych wtyczek.',
'settings.plugins.saved': 'Zapisano ustawienia', 'settings.plugins.saved': 'Zapisano ustawienia',
'settings.plugins.requiredMissing': '"{field}" jest wymagane',
'settings.tabs.account': 'Konto', 'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'O aplikacji', 'settings.tabs.about': 'O aplikacji',
+1
View File
@@ -451,6 +451,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Настройки экземпляра', 'admin.plugins.instanceSettings': 'Настройки экземпляра',
'admin.plugins.settingsSaved': 'Настройки сохранены', 'admin.plugins.settingsSaved': 'Настройки сохранены',
'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен', 'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен',
'admin.plugins.requiredMissing': '«{field}» обязательно',
'admin.plugins.cap.readsTrips': 'Читает ваши поездки', 'admin.plugins.cap.readsTrips': 'Читает ваши поездки',
'admin.plugins.cap.readsUsers': 'Читает основные профили', 'admin.plugins.cap.readsUsers': 'Читает основные профили',
'admin.plugins.cap.readsCosts': 'Читает ваши расходы', 'admin.plugins.cap.readsCosts': 'Читает ваши расходы',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Ваши личные настройки для используемых плагинов (ключи API, предпочтения).', 'settings.plugins.subtitle': 'Ваши личные настройки для используемых плагинов (ключи API, предпочтения).',
'settings.plugins.empty': 'Нет активных плагинов.', 'settings.plugins.empty': 'Нет активных плагинов.',
'settings.plugins.saved': 'Настройки сохранены', 'settings.plugins.saved': 'Настройки сохранены',
'settings.plugins.requiredMissing': '«{field}» обязательно',
'settings.tabs.account': 'Аккаунт', 'settings.tabs.account': 'Аккаунт',
'settings.tabs.offline': 'Офлайн-режим', 'settings.tabs.offline': 'Офлайн-режим',
'settings.tabs.about': 'О приложении', 'settings.tabs.about': 'О приложении',
+1
View File
@@ -508,6 +508,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instansinställningar', 'admin.plugins.instanceSettings': 'Instansinställningar',
'admin.plugins.settingsSaved': 'Inställningarna har sparats', 'admin.plugins.settingsSaved': 'Inställningarna har sparats',
'admin.plugins.settingsSavedRestarted': 'Inställningarna har sparats — pluginet har startats om', '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.readsTrips': 'Läser dina resor',
'admin.plugins.cap.readsUsers': 'Läser grundläggande profiler', 'admin.plugins.cap.readsUsers': 'Läser grundläggande profiler',
'admin.plugins.cap.readsCosts': 'Läser dina kostnader', 'admin.plugins.cap.readsCosts': 'Läser dina kostnader',
+1
View File
@@ -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.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.empty': 'Inga tillägg är aktiva.',
'settings.plugins.saved': 'Inställningar sparade', 'settings.plugins.saved': 'Inställningar sparade',
'settings.plugins.requiredMissing': '"{field}" krävs',
'settings.tabs.account': 'Konto', 'settings.tabs.account': 'Konto',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': 'Om', 'settings.tabs.about': 'Om',
+1
View File
@@ -494,6 +494,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Örnek ayarları', 'admin.plugins.instanceSettings': 'Örnek ayarları',
'admin.plugins.settingsSaved': 'Ayarlar kaydedildi', 'admin.plugins.settingsSaved': 'Ayarlar kaydedildi',
'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı', 'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı',
'admin.plugins.requiredMissing': '"{field}" gerekli',
'admin.plugins.cap.readsTrips': 'Seyahatlerinizi okur', 'admin.plugins.cap.readsTrips': 'Seyahatlerinizi okur',
'admin.plugins.cap.readsUsers': 'Temel profilleri okur', 'admin.plugins.cap.readsUsers': 'Temel profilleri okur',
'admin.plugins.cap.readsCosts': 'Harcamalarınızı okur', 'admin.plugins.cap.readsCosts': 'Harcamalarınızı okur',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': 'Kullandığın eklentiler için kişisel ayarların (API anahtarları, tercihler).', 'settings.plugins.subtitle': 'Kullandığın eklentiler için kişisel ayarların (API anahtarları, tercihler).',
'settings.plugins.empty': 'Etkin eklenti yok.', 'settings.plugins.empty': 'Etkin eklenti yok.',
'settings.plugins.saved': 'Ayarlar kaydedildi', 'settings.plugins.saved': 'Ayarlar kaydedildi',
'settings.plugins.requiredMissing': '"{field}" gerekli',
'settings.tabs.account': 'Hesap', 'settings.tabs.account': 'Hesap',
'settings.tabs.offline': 'Çevrimdışı', 'settings.tabs.offline': 'Çevrimdışı',
'settings.tabs.about': 'Hakkında', 'settings.tabs.about': 'Hakkında',
+1
View File
@@ -448,6 +448,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Налаштування інстансу', 'admin.plugins.instanceSettings': 'Налаштування інстансу',
'admin.plugins.settingsSaved': 'Налаштування збережено', 'admin.plugins.settingsSaved': 'Налаштування збережено',
'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено', 'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено',
'admin.plugins.requiredMissing': '«{field}» обов’язкове',
'admin.plugins.cap.readsTrips': 'Читає ваші подорожі', 'admin.plugins.cap.readsTrips': 'Читає ваші подорожі',
'admin.plugins.cap.readsUsers': 'Читає базові профілі', 'admin.plugins.cap.readsUsers': 'Читає базові профілі',
'admin.plugins.cap.readsCosts': 'Читає ваші витрати', 'admin.plugins.cap.readsCosts': 'Читає ваші витрати',
+1
View File
@@ -17,6 +17,7 @@ const settings: TranslationStrings = {
'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).', 'Ваші особисті налаштування для плагінів, які ви використовуєте (ключі API, уподобання).',
'settings.plugins.empty': 'Немає активних плагінів.', 'settings.plugins.empty': 'Немає активних плагінів.',
'settings.plugins.saved': 'Налаштування збережено', 'settings.plugins.saved': 'Налаштування збережено',
'settings.plugins.requiredMissing': '«{field}» обов’язкове',
'settings.tabs.account': 'Обліковий запис', 'settings.tabs.account': 'Обліковий запис',
'settings.tabs.offline': 'Офлайн', 'settings.tabs.offline': 'Офлайн',
'settings.tabs.about': 'Про застосунок', 'settings.tabs.about': 'Про застосунок',
+1
View File
@@ -450,6 +450,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Cài đặt toàn hệ thống', 'admin.plugins.instanceSettings': 'Cài đặt toàn hệ thống',
'admin.plugins.settingsSaved': 'Đã lưu cài đặt', 'admin.plugins.settingsSaved': 'Đã lưu cài đặt',
'admin.plugins.settingsSavedRestarted': 'Đã lưu cài đặt — đã khởi động lại plugin', '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.readsTrips': 'Đọc chuyến đi của bạn',
'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản', 'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản',
'admin.plugins.cap.readsCosts': 'Đọc chi phí của bạn', 'admin.plugins.cap.readsCosts': 'Đọc chi phí của bạn',
+1
View File
@@ -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.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.empty': 'Không có plugin nào đang hoạt động.',
'settings.plugins.saved': 'Đã lưu cài đặt', '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.account': 'Tài khoản',
'settings.tabs.offline': 'Ngoại tuyến', 'settings.tabs.offline': 'Ngoại tuyến',
'settings.tabs.about': 'Về', 'settings.tabs.about': 'Về',
+1
View File
@@ -444,6 +444,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '執行個體設定', 'admin.plugins.instanceSettings': '執行個體設定',
'admin.plugins.settingsSaved': '設定已儲存', 'admin.plugins.settingsSaved': '設定已儲存',
'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動', 'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動',
'admin.plugins.requiredMissing': '「{field}」為必填項',
'admin.plugins.cap.readsTrips': '讀取你的行程', 'admin.plugins.cap.readsTrips': '讀取你的行程',
'admin.plugins.cap.readsUsers': '讀取基本個人資料', 'admin.plugins.cap.readsUsers': '讀取基本個人資料',
'admin.plugins.cap.readsCosts': '讀取你的支出', 'admin.plugins.cap.readsCosts': '讀取你的支出',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': '你所用外掛的個人設定(API 金鑰、偏好)。', 'settings.plugins.subtitle': '你所用外掛的個人設定(API 金鑰、偏好)。',
'settings.plugins.empty': '沒有啟用中的外掛。', 'settings.plugins.empty': '沒有啟用中的外掛。',
'settings.plugins.saved': '設定已儲存', 'settings.plugins.saved': '設定已儲存',
'settings.plugins.requiredMissing': '「{field}」為必填項',
'settings.tabs.account': '帳戶', 'settings.tabs.account': '帳戶',
'settings.tabs.offline': 'Offline', 'settings.tabs.offline': 'Offline',
'settings.tabs.about': '關於', 'settings.tabs.about': '關於',
+1
View File
@@ -404,6 +404,7 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '实例设置', 'admin.plugins.instanceSettings': '实例设置',
'admin.plugins.settingsSaved': '设置已保存', 'admin.plugins.settingsSaved': '设置已保存',
'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启', 'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启',
'admin.plugins.requiredMissing': '「{field}」为必填项',
'admin.plugins.cap.readsTrips': '读取你的旅行', 'admin.plugins.cap.readsTrips': '读取你的旅行',
'admin.plugins.cap.readsUsers': '读取基本个人资料', 'admin.plugins.cap.readsUsers': '读取基本个人资料',
'admin.plugins.cap.readsCosts': '读取你的支出', 'admin.plugins.cap.readsCosts': '读取你的支出',
+1
View File
@@ -16,6 +16,7 @@ const settings: TranslationStrings = {
'settings.plugins.subtitle': '你所用插件的个人设置(API 密钥、偏好)。', 'settings.plugins.subtitle': '你所用插件的个人设置(API 密钥、偏好)。',
'settings.plugins.empty': '没有已启用的插件。', 'settings.plugins.empty': '没有已启用的插件。',
'settings.plugins.saved': '设置已保存', 'settings.plugins.saved': '设置已保存',
'settings.plugins.requiredMissing': '「{field}」为必填项',
'settings.tabs.account': '账户', 'settings.tabs.account': '账户',
'settings.tabs.offline': '离线', 'settings.tabs.offline': '离线',
'settings.tabs.about': '关于', 'settings.tabs.about': '关于',
@@ -45,6 +45,14 @@ describe('pluginSettingsFieldSchema', () => {
it('requires key — a field without one cannot be rendered or saved', () => { it('requires key — a field without one cannot be rendered or saved', () => {
expect(pluginSettingsFieldSchema.safeParse({ label: 'orphan' }).success).toBe(false); 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', () => { describe('instance-config wire shapes', () => {
+3
View File
@@ -24,6 +24,9 @@ export const pluginSettingsFieldSchema = z.object({
hint: z.string().nullish(), hint: z.string().nullish(),
required: z.boolean().optional(), required: z.boolean().optional(),
secret: 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(), options: z.array(z.object({ value: z.string(), label: z.string() })).optional(),
}); });
export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>; export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>;
+22 -2
View File
@@ -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.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.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.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` | `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.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` | | `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.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.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.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.log` | `info` / `warn` / `error` → your error log | — |
| `ctx.id` | your plugin id (string) | — | | `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) — delivered decrypted through whichever of the two applies (server-side only) —
never to the iframe. 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
"<key>"' }` 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`) ### A custom settings page (`capabilities.settingsUi`)
When declared fields aren't enough — a picker with previews, anything visual — When declared fields aren't enough — a picker with previews, anything visual —