Feat/plugin instance actions (#2209)

* feat(shared): scope plugin action descriptors

* feat(plugin-sdk): instance-scoped settings actions

* feat(plugins): run instance-scoped actions from the admin settings dialog

* feat(admin): instance-scoped plugin actions in the settings dialog

* fix(plugins): surface instance actions without instance fields and harden the action gate

* docs(plugin-sdk): note that hosts before 4.2.0 ignore action scope

* fix(admin): open the dialog for instance actions, explain inactive 404s, guard the save window

* docs(wiki): plugin pages for instance actions and the admin panel changes
This commit is contained in:
jubnl
2026-09-02 23:06:03 +02:00
committed by GitHub
parent b1ce7c303d
commit b82962f95b
59 changed files with 1133 additions and 128 deletions
+3
View File
@@ -454,6 +454,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'إعدادات مستوى الخادم',
'admin.plugins.settingsSaved': 'تم حفظ الإعدادات',
'admin.plugins.settingsSavedRestarted': 'تم حفظ الإعدادات — تمت إعادة تشغيل الإضافة',
'admin.plugins.actions': 'الإجراءات',
'admin.plugins.actions.confirm': 'تنفيذ هذا الإجراء؟',
'admin.plugins.actions.inactive': 'فعّل الإضافة لتشغيل إجراءاتها',
'admin.plugins.requiredMissing': '"{field}" مطلوب',
'admin.plugins.cap.readsTrips': 'يقرأ رحلاتك',
'admin.plugins.cap.readsUsers': 'يقرأ الملفات الشخصية الأساسية',
+3
View File
@@ -443,6 +443,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Configurações da instância',
'admin.plugins.settingsSaved': 'Configurações salvas',
'admin.plugins.settingsSavedRestarted': 'Configurações salvas — plugin reiniciado',
'admin.plugins.actions': 'Ações',
'admin.plugins.actions.confirm': 'Executar esta ação?',
'admin.plugins.actions.inactive': 'Ative o plugin para executar suas ações',
'admin.plugins.requiredMissing': '"{field}" é obrigatório',
'admin.plugins.cap.readsTrips': 'Lê suas viagens',
'admin.plugins.cap.readsUsers': 'Lê perfis básicos',
+3
View File
@@ -577,6 +577,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Configuració de la instància',
'admin.plugins.settingsSaved': 'Configuració desada',
'admin.plugins.settingsSavedRestarted': 'Configuració desada — connector reiniciat',
'admin.plugins.actions': 'Accions',
'admin.plugins.actions.confirm': 'Vols executar aquesta acció?',
'admin.plugins.actions.inactive': 'Activa el connector per executar les seves accions',
'admin.plugins.requiredMissing': '"{field}" és obligatori',
'admin.plugins.cap.readsTrips': 'Llegeix els teus viatges',
'admin.plugins.cap.readsUsers': 'Llegeix els perfils bàsics',
+3
View File
@@ -441,6 +441,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Nastavení instance',
'admin.plugins.settingsSaved': 'Nastavení uloženo',
'admin.plugins.settingsSavedRestarted': 'Nastavení uloženo — plugin restartován',
'admin.plugins.actions': 'Akce',
'admin.plugins.actions.confirm': 'Spustit tuto akci?',
'admin.plugins.actions.inactive': 'Aktivujte plugin, abyste mohli spouštět jeho akce',
'admin.plugins.requiredMissing': '„{field}" je povinné',
'admin.plugins.cap.readsTrips': 'Čte vaše cesty',
'admin.plugins.cap.readsUsers': 'Čte základní profily',
+3
View File
@@ -450,6 +450,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instanz-Einstellungen',
'admin.plugins.settingsSaved': 'Einstellungen gespeichert',
'admin.plugins.settingsSavedRestarted': 'Einstellungen gespeichert — Plugin neu gestartet',
'admin.plugins.actions': 'Aktionen',
'admin.plugins.actions.confirm': 'Diese Aktion ausführen?',
'admin.plugins.actions.inactive': 'Aktiviere das Plugin, um seine Aktionen auszuführen',
'admin.plugins.requiredMissing': '"{field}" ist erforderlich',
'admin.plugins.cap.readsTrips': 'Liest deine Reisen',
'admin.plugins.cap.readsUsers': 'Liest Basis-Profile',
+3
View File
@@ -488,6 +488,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instance settings',
'admin.plugins.settingsSaved': 'Settings saved',
'admin.plugins.settingsSavedRestarted': 'Settings saved — plugin restarted',
'admin.plugins.actions': 'Actions',
'admin.plugins.actions.confirm': 'Run this action?',
'admin.plugins.actions.inactive': 'Activate the plugin to run its actions',
'admin.plugins.requiredMissing': '"{field}" is required',
'admin.plugins.cap.readsTrips': 'Reads your trips',
'admin.plugins.cap.readsUsers': 'Reads basic profiles',
+3
View File
@@ -452,6 +452,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ajustes de la instancia',
'admin.plugins.settingsSaved': 'Ajustes guardados',
'admin.plugins.settingsSavedRestarted': 'Ajustes guardados — plugin reiniciado',
'admin.plugins.actions': 'Acciones',
'admin.plugins.actions.confirm': '¿Ejecutar esta acción?',
'admin.plugins.actions.inactive': 'Activa el plugin para ejecutar sus acciones',
'admin.plugins.requiredMissing': '"{field}" es obligatorio',
'admin.plugins.cap.readsTrips': 'Lee tus viajes',
'admin.plugins.cap.readsUsers': 'Lee perfiles básicos',
+3
View File
@@ -455,6 +455,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Paramètres de l’instance',
'admin.plugins.settingsSaved': 'Paramètres enregistrés',
'admin.plugins.settingsSavedRestarted': 'Paramètres enregistrés — plugin redémarré',
'admin.plugins.actions': 'Actions',
'admin.plugins.actions.confirm': 'Exécuter cette action ?',
'admin.plugins.actions.inactive': 'Activez le plugin pour exécuter ses actions',
'admin.plugins.requiredMissing': '« {field} » est obligatoire',
'admin.plugins.cap.readsTrips': 'Lit vos voyages',
'admin.plugins.cap.readsUsers': 'Lit les profils de base',
+3
View File
@@ -503,6 +503,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ρυθμίσεις εγκατάστασης',
'admin.plugins.settingsSaved': 'Οι ρυθμίσεις αποθηκεύτηκαν',
'admin.plugins.settingsSavedRestarted': 'Οι ρυθμίσεις αποθηκεύτηκαν — το plugin επανεκκινήθηκε',
'admin.plugins.actions': 'Ενέργειες',
'admin.plugins.actions.confirm': 'Εκτέλεση αυτής της ενέργειας;',
'admin.plugins.actions.inactive': 'Ενεργοποιήστε το plugin για να εκτελέσετε τις ενέργειές του',
'admin.plugins.requiredMissing': '"{field}" είναι υποχρεωτικό',
'admin.plugins.cap.readsTrips': 'Διαβάζει τα ταξίδια σας',
'admin.plugins.cap.readsUsers': 'Διαβάζει βασικά προφίλ',
+3
View File
@@ -455,6 +455,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Példányszintű beállítások',
'admin.plugins.settingsSaved': 'Beállítások elmentve',
'admin.plugins.settingsSavedRestarted': 'Beállítások elmentve — plugin újraindítva',
'admin.plugins.actions': 'Műveletek',
'admin.plugins.actions.confirm': 'Végrehajtod ezt a műveletet?',
'admin.plugins.actions.inactive': 'Aktiváld a plugint a műveletei futtatásához',
'admin.plugins.requiredMissing': '"{field}" kötelező',
'admin.plugins.cap.readsTrips': 'Olvassa az utazásaidat',
'admin.plugins.cap.readsUsers': 'Olvassa az alapprofilokat',
+3
View File
@@ -497,6 +497,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Pengaturan instans',
'admin.plugins.settingsSaved': 'Pengaturan disimpan',
'admin.plugins.settingsSavedRestarted': 'Pengaturan disimpan — plugin dimulai ulang',
'admin.plugins.actions': 'Tindakan',
'admin.plugins.actions.confirm': 'Jalankan tindakan ini?',
'admin.plugins.actions.inactive': 'Aktifkan plugin untuk menjalankan tindakannya',
'admin.plugins.requiredMissing': '"{field}" wajib diisi',
'admin.plugins.cap.readsTrips': 'Membaca perjalananmu',
'admin.plugins.cap.readsUsers': 'Membaca profil dasar',
+3
View File
@@ -450,6 +450,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Impostazioni dell’istanza',
'admin.plugins.settingsSaved': 'Impostazioni salvate',
'admin.plugins.settingsSavedRestarted': 'Impostazioni salvate — plugin riavviato',
'admin.plugins.actions': 'Azioni',
'admin.plugins.actions.confirm': 'Eseguire questa azione?',
'admin.plugins.actions.inactive': 'Attiva il plugin per eseguire le sue azioni',
'admin.plugins.requiredMissing': '"{field}" è obbligatorio',
'admin.plugins.cap.readsTrips': 'Legge i tuoi viaggi',
'admin.plugins.cap.readsUsers': 'Legge i profili di base',
+3
View File
@@ -462,6 +462,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'インスタンス設定',
'admin.plugins.settingsSaved': '設定を保存しました',
'admin.plugins.settingsSavedRestarted': '設定を保存しました — プラグインを再起動しました',
'admin.plugins.actions': 'アクション',
'admin.plugins.actions.confirm': 'このアクションを実行しますか?',
'admin.plugins.actions.inactive': 'アクションを実行するにはプラグインを有効化してください',
'admin.plugins.requiredMissing': '「{field}」は必須です',
'admin.plugins.cap.readsTrips': '旅行を読み取り',
'admin.plugins.cap.readsUsers': '基本プロフィールを読み取り',
+3
View File
@@ -462,6 +462,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '인스턴스 설정',
'admin.plugins.settingsSaved': '설정이 저장되었습니다',
'admin.plugins.settingsSavedRestarted': '설정이 저장되었습니다 — 플러그인이 다시 시작되었습니다',
'admin.plugins.actions': '작업',
'admin.plugins.actions.confirm': '이 작업을 실행할까요?',
'admin.plugins.actions.inactive': '작업을 실행하려면 플러그인을 활성화하세요',
'admin.plugins.requiredMissing': '"{field}"은(는) 필수입니다',
'admin.plugins.cap.readsTrips': '여행 읽기',
'admin.plugins.cap.readsUsers': '기본 프로필 읽기',
+3
View File
@@ -449,6 +449,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instantie-instellingen',
'admin.plugins.settingsSaved': 'Instellingen opgeslagen',
'admin.plugins.settingsSavedRestarted': 'Instellingen opgeslagen — plugin opnieuw gestart',
'admin.plugins.actions': 'Acties',
'admin.plugins.actions.confirm': 'Deze actie uitvoeren?',
'admin.plugins.actions.inactive': 'Activeer de plugin om de acties uit te voeren',
'admin.plugins.requiredMissing': '"{field}" is vereist',
'admin.plugins.cap.readsTrips': 'Leest je reizen',
'admin.plugins.cap.readsUsers': 'Leest basisprofielen',
+3
View File
@@ -447,6 +447,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Ustawienia instancji',
'admin.plugins.settingsSaved': 'Ustawienia zapisane',
'admin.plugins.settingsSavedRestarted': 'Ustawienia zapisane — wtyczka została uruchomiona ponownie',
'admin.plugins.actions': 'Akcje',
'admin.plugins.actions.confirm': 'Wykonać tę akcję?',
'admin.plugins.actions.inactive': 'Aktywuj wtyczkę, aby uruchomić jej akcje',
'admin.plugins.requiredMissing': '"{field}" jest wymagane',
'admin.plugins.cap.readsTrips': 'Odczytuje Twoje podróże',
'admin.plugins.cap.readsUsers': 'Odczytuje podstawowe profile',
+3
View File
@@ -451,6 +451,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Настройки экземпляра',
'admin.plugins.settingsSaved': 'Настройки сохранены',
'admin.plugins.settingsSavedRestarted': 'Настройки сохранены — плагин перезапущен',
'admin.plugins.actions': 'Действия',
'admin.plugins.actions.confirm': 'Выполнить это действие?',
'admin.plugins.actions.inactive': 'Активируйте плагин, чтобы выполнять его действия',
'admin.plugins.requiredMissing': '«{field}» обязательно',
'admin.plugins.cap.readsTrips': 'Читает ваши поездки',
'admin.plugins.cap.readsUsers': 'Читает основные профили',
+3
View File
@@ -508,6 +508,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Instansinställningar',
'admin.plugins.settingsSaved': 'Inställningarna har sparats',
'admin.plugins.settingsSavedRestarted': 'Inställningarna har sparats — pluginet har startats om',
'admin.plugins.actions': 'Åtgärder',
'admin.plugins.actions.confirm': 'Kör den här åtgärden?',
'admin.plugins.actions.inactive': 'Aktivera pluginet för att köra dess åtgärder',
'admin.plugins.requiredMissing': '"{field}" krävs',
'admin.plugins.cap.readsTrips': 'Läser dina resor',
'admin.plugins.cap.readsUsers': 'Läser grundläggande profiler',
+3
View File
@@ -494,6 +494,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Örnek ayarları',
'admin.plugins.settingsSaved': 'Ayarlar kaydedildi',
'admin.plugins.settingsSavedRestarted': 'Ayarlar kaydedildi — eklenti yeniden başlatıldı',
'admin.plugins.actions': 'Eylemler',
'admin.plugins.actions.confirm': 'Bu eylem çalıştırılsın mı?',
'admin.plugins.actions.inactive': 'Eylemlerini çalıştırmak için eklentiyi etkinleştirin',
'admin.plugins.requiredMissing': '"{field}" gerekli',
'admin.plugins.cap.readsTrips': 'Seyahatlerinizi okur',
'admin.plugins.cap.readsUsers': 'Temel profilleri okur',
+3
View File
@@ -448,6 +448,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Налаштування інстансу',
'admin.plugins.settingsSaved': 'Налаштування збережено',
'admin.plugins.settingsSavedRestarted': 'Налаштування збережено — плагін перезапущено',
'admin.plugins.actions': 'Дії',
'admin.plugins.actions.confirm': 'Виконати цю дію?',
'admin.plugins.actions.inactive': 'Активуйте плагін, щоб виконувати його дії',
'admin.plugins.requiredMissing': '«{field}» обов’язкове',
'admin.plugins.cap.readsTrips': 'Читає ваші подорожі',
'admin.plugins.cap.readsUsers': 'Читає базові профілі',
+3
View File
@@ -450,6 +450,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': 'Cài đặt toàn hệ thống',
'admin.plugins.settingsSaved': 'Đã lưu cài đặt',
'admin.plugins.settingsSavedRestarted': 'Đã lưu cài đặt — đã khởi động lại plugin',
'admin.plugins.actions': 'Hành động',
'admin.plugins.actions.confirm': 'Chạy hành động này?',
'admin.plugins.actions.inactive': 'Kích hoạt plugin để chạy các hành động của nó',
'admin.plugins.requiredMissing': '"{field}" là bắt buộc',
'admin.plugins.cap.readsTrips': 'Đọc chuyến đi của bạn',
'admin.plugins.cap.readsUsers': 'Đọc hồ sơ cơ bản',
+3
View File
@@ -444,6 +444,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '執行個體設定',
'admin.plugins.settingsSaved': '設定已儲存',
'admin.plugins.settingsSavedRestarted': '設定已儲存 — 外掛已重新啟動',
'admin.plugins.actions': '操作',
'admin.plugins.actions.confirm': '執行此操作?',
'admin.plugins.actions.inactive': '啟用外掛以執行其操作',
'admin.plugins.requiredMissing': '「{field}」為必填項',
'admin.plugins.cap.readsTrips': '讀取你的行程',
'admin.plugins.cap.readsUsers': '讀取基本個人資料',
+3
View File
@@ -404,6 +404,9 @@ const admin: TranslationStrings = {
'admin.plugins.instanceSettings': '实例设置',
'admin.plugins.settingsSaved': '设置已保存',
'admin.plugins.settingsSavedRestarted': '设置已保存 — 插件已重启',
'admin.plugins.actions': '操作',
'admin.plugins.actions.confirm': '执行此操作?',
'admin.plugins.actions.inactive': '激活插件以运行其操作',
'admin.plugins.requiredMissing': '「{field}」为必填项',
'admin.plugins.cap.readsTrips': '读取你的旅行',
'admin.plugins.cap.readsUsers': '读取基本个人资料',
+33
View File
@@ -12,6 +12,8 @@ import {
pluginEgressHostsRequestSchema,
pluginUserSettingsUpdateRequestSchema,
pluginRouteRequestSchema,
pluginActionDescriptorSchema,
pluginActionResultSchema,
} from './plugins.schema';
import { describe, expect, it } from 'vitest';
@@ -70,6 +72,7 @@ describe('instance-config wire shapes', () => {
},
],
config: { apiUrl: 'https://x.example', apiKey: '••••••••' },
actions: [],
};
expect(pluginInstanceConfigResponseSchema.parse(res).fields).toHaveLength(1);
});
@@ -139,3 +142,33 @@ describe('admin body contracts stay loose by doctrine', () => {
expect(pluginRouteRequestSchema.safeParse({ anything: [1, 2, 3] }).success).toBe(true);
});
});
describe('plugin action contracts', () => {
it('SHARED-PLUG-ACT-001: a descriptor carries key/label/danger/scope and an optional hint', () => {
expect(
pluginActionDescriptorSchema.parse({ key: 'purge', label: 'Purge cache', danger: true, scope: 'instance' }),
).toEqual({ key: 'purge', label: 'Purge cache', danger: true, scope: 'instance' });
expect(pluginActionDescriptorSchema.parse({ key: 'a', label: 'A', hint: 'h', danger: false, scope: 'user' }).hint).toBe('h');
});
it('SHARED-PLUG-ACT-002: refuses a scope outside user|instance and a missing scope', () => {
expect(pluginActionDescriptorSchema.safeParse({ key: 'a', label: 'A', danger: false, scope: 'global' }).success).toBe(false);
expect(pluginActionDescriptorSchema.safeParse({ key: 'a', label: 'A', danger: false }).success).toBe(false);
});
it('SHARED-PLUG-ACT-003: a result is ok plus an optional message', () => {
expect(pluginActionResultSchema.parse({ ok: true })).toEqual({ ok: true });
expect(pluginActionResultSchema.parse({ ok: false, message: 'nope' })).toEqual({ ok: false, message: 'nope' });
expect(pluginActionResultSchema.safeParse({ message: 'x' }).success).toBe(false);
});
it('SHARED-PLUG-ACT-004: the instance config response now lists instance actions', () => {
const r = pluginInstanceConfigResponseSchema.parse({
fields: [],
config: {},
actions: [{ key: 'ping', label: 'Ping', danger: false, scope: 'instance' }],
});
expect(r.actions).toHaveLength(1);
expect(pluginInstanceConfigResponseSchema.safeParse({ fields: [], config: {} }).success).toBe(false);
});
});
+27 -1
View File
@@ -31,11 +31,37 @@ export const pluginSettingsFieldSchema = z.object({
});
export type PluginSettingsField = z.infer<typeof pluginSettingsFieldSchema>;
/**
* A settings-form action button a plugin declared in its manifest (`actions`). Which
* form renders it is the `scope`, exactly like a settings field: `'user'` on the user
* Settings tab (runs as the clicking user), `'instance'` in the admin instance-settings
* dialog (runs as the clicking admin). One action lives in exactly one form.
*/
export const pluginActionScopeSchema = z.enum(['user', 'instance']);
export type PluginActionScope = z.infer<typeof pluginActionScopeSchema>;
export const pluginActionDescriptorSchema = z.object({
key: z.string(),
label: z.string(),
hint: z.string().optional(),
danger: z.boolean(),
scope: pluginActionScopeSchema,
});
export type PluginActionDescriptor = z.infer<typeof pluginActionDescriptorSchema>;
/** What an action answers, after the host bounded the plugin-supplied message. */
export const pluginActionResultSchema = z.object({
ok: z.boolean(),
message: z.string().optional(),
});
export type PluginActionResult = z.infer<typeof pluginActionResultSchema>;
/** GET /api/admin/plugins/:id/config — the declared `scope:'instance'` fields
* plus the stored values, secrets masked. */
* plus the stored values, secrets masked, plus the `scope:'instance'` actions. */
export const pluginInstanceConfigResponseSchema = z.object({
fields: z.array(pluginSettingsFieldSchema),
config: z.record(z.string(), z.unknown()),
actions: z.array(pluginActionDescriptorSchema),
});
export type PluginInstanceConfigResponse = z.infer<typeof pluginInstanceConfigResponseSchema>;