mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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\n\n| a | b |\n|---|---|\n\n```js\nconst x = 1;\n```\n\n[link](u)\n';
|
const md = '# Title\n\n\n\n| a | b |\n|---|---|\n\n```js\nconst x = 1;\n```\n\n[link](u)\n';
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);`);
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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': 'يقرأ مصروفاتك',
|
||||||
|
|||||||
@@ -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': 'الخريطة',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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.',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Διαβάζει τα έξοδά σας',
|
||||||
|
|||||||
@@ -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': 'Σχετικά',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': '支出を読み取り',
|
||||||
|
|||||||
@@ -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': '情報',
|
||||||
|
|||||||
@@ -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': '지출 읽기',
|
||||||
|
|||||||
@@ -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': '정보',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Читает ваши расходы',
|
||||||
|
|||||||
@@ -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': 'О приложении',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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': 'Читає ваші витрати',
|
||||||
|
|||||||
@@ -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': 'Про застосунок',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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ề',
|
||||||
|
|||||||
@@ -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': '讀取你的支出',
|
||||||
|
|||||||
@@ -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': '關於',
|
||||||
|
|||||||
@@ -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': '读取你的支出',
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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 —
|
||||||
|
|||||||
Reference in New Issue
Block a user