feat(plugins): enforce host-rendered settings

This commit is contained in:
tianma-if committed 2026-09-08 19:37:39 +08:00
1 parent 72d655a109
commit eeec5c9d5f
17 files changed
+128 -29

No files matched your search

@@ -23,6 +23,7 @@ export default {
const disposePanel = context.ui.panels.register({
id: "recent-notes",
title: "Recent notes",
purpose: "dashboard",
mount(container) {
const heading = document.createElement("h2");
heading.textContent = "Recent notes inspector";
@@ -2,8 +2,9 @@
"type": "plugin",
"id": "org.edgeever.examples.recent-notes",
"name": "Recent Notes Example",
"version": "1.1.0",
"apiVersion": "1",
"version": "2.0.0",
"apiVersion": "2",
"settingsUi": "host",
"description": "Demonstrates note queries, editor selections, commands, notices, and a custom panel.",
"author": "EdgeEver",
"entry": "./main.js",
@@ -6,7 +6,8 @@ const manifest = {
id: "org.edgeever.github-test",
name: "GitHub Test",
version: "1.2.3",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["ui:notices"],
};
@@ -48,6 +48,7 @@ export default {
const disposePanel = context.ui.panels.register({
id: "capability-panel",
title: "Capability panel",
purpose: "workflow",
presentation: "fullscreen",
mount(container, panelContext) {
container.panelState = panelContext.state;
@@ -53,6 +53,7 @@ export default {
const disposePanel = context.ui.panels.register({
id: "fixture",
title: "Fixture panel",
purpose: "workflow",
mount(container) {
container.mountedByFixture = true;
return () => { container.mountedByFixture = false; };
+39 -9
View File
@@ -65,6 +65,28 @@ beforeEach(() => {
});
describe("EdgeEverPluginHost", () => {
test("rejects custom settings panels for API v2 plugins", async () => {
const host = new EdgeEverPluginHost({ repository, scope: "settings-policy" });
const id = "org.edgeever.settings-policy";
host.installManifest({
type: "plugin",
id,
name: "Settings policy",
version: "1.0.0",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-settings-policy.fixture.mjs", import.meta.url).href,
permissions: ["ui:panels"],
}, "https://example.org/manifest.json");
await expect(host.setEnabled(id, true)).rejects.toThrow("custom settings panels are not allowed");
expect(host.getSnapshot().extensions.find((extension) => extension.manifest.id === id)).toMatchObject({
enabled: false,
error: expect.stringContaining("custom settings panels are not allowed"),
});
await host.dispose();
});
test('enabled plugins can use host capabilities without manifest permission declarations', async () => {
const calls = [];
const directCalls = [];
@@ -74,7 +96,7 @@ describe("EdgeEverPluginHost", () => {
publicNetworkAdapter: { fetchPublic: async input => { calls.push(input); return { url: input.url, status: 429, statusText: 'Too Many Requests', headers: {}, body: new TextEncoder().encode('limited').buffer }; } },
});
const install = async (id, permissions) => {
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, ...(permissions ? { permissions } : {}) }, 'https://example.org/manifest.json');
host.installManifest({ type: 'plugin', id, name: id, version: '1.0.0', apiVersion: '2', settingsUi: 'host', entry: new URL('./plugin-capabilities.fixture.mjs', import.meta.url).href, ...(permissions ? { permissions } : {}) }, 'https://example.org/manifest.json');
await host.setEnabled(id, true);
};
await install('org.test.trusted');
@@ -112,7 +134,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.schedule-test",
name: "Schedule Test",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-schedules.fixture.mjs", import.meta.url).href,
permissions: ["ui:commands", "schedules"],
}, "https://example.com/schedule-plugin/manifest.json");
@@ -185,7 +208,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.test-plugin",
name: "Test plugin",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
settings: {
@@ -244,7 +268,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.marketplace-test",
name: "Marketplace Test",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
};
@@ -313,7 +338,8 @@ describe("EdgeEverPluginHost", () => {
id: pluginId,
name: "Rollback Test",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: ["notes:write", "ui:commands", "ui:notices", "ui:panels", "editor:read", "editor:write", "secrets", "storage"],
}, "https://plugins.example/v1/manifest.json");
@@ -324,7 +350,8 @@ describe("EdgeEverPluginHost", () => {
id: pluginId,
name: "Rollback Test",
version: "2.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
};
@@ -471,7 +498,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.capabilities",
name: "Capabilities",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry,
permissions: [],
settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] },
@@ -629,7 +657,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.events",
name: "Events",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
permissions: ["notes:read", "templates:read", "resources:read"],
settings: { fields: [{ key: "mode", type: "select", label: "Mode", default: "daily", options: [
@@ -645,7 +674,8 @@ describe("EdgeEverPluginHost", () => {
id: "org.edgeever.events-other",
name: "Other Events",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href,
permissions: [],
settings: { fields: [{ key: "mode", type: "text", label: "Mode" }] },
+8
View File
@@ -1,4 +1,5 @@
import {
PLUGIN_API_VERSION,
parseExtensionManifest,
type MarketplaceEntry,
type EdgeEverPlugin,
@@ -1420,6 +1421,10 @@ export class EdgeEverPluginHost {
assertPermission(manifest, "ui:panels");
if (!/^[a-z0-9][a-z0-9._-]*$/i.test(panel.id)) throw new Error("Plugin panel id is invalid.");
if (!panel.title.trim()) throw new Error("Plugin panel title is required.");
const allowedPurposes = new Set(["workflow", "dashboard", "preview", "onboarding"]);
if (manifest.apiVersion === PLUGIN_API_VERSION && !allowedPurposes.has(panel.purpose)) {
throw new Error("Plugin API v2 panels require a supported business purpose; custom settings panels are not allowed.");
}
const key = `${manifest.id}:${panel.id}`;
if (this.panels.has(key)) throw new Error(`Plugin panel already exists: ${panel.id}`);
this.panels.set(key, {
@@ -1455,6 +1460,9 @@ export class EdgeEverPluginHost {
) {
if (manifest.id !== entry.id) throw new Error("Marketplace plugin id does not match the downloaded manifest.");
if (manifest.version !== entry.verification.version) throw new Error("Downloaded version does not match the marketplace verified version.");
if (manifest.type === "plugin" && manifest.apiVersion !== PLUGIN_API_VERSION) {
throw new Error(`Marketplace plugins must use plugin API v${PLUGIN_API_VERSION}.`);
}
for (const [name, expected] of Object.entries(entry.verification.checksums ?? {})) {
const actual = actualChecksums[name as keyof CachedPluginPackage["checksums"]];
if (!actual || actual.toLocaleLowerCase() !== expected) throw new Error(`${name} does not match the marketplace verified checksum.`);
@@ -1,7 +1,7 @@
export default {
async activate(context) {
await context.notes.query();
const panel = context.ui.panels.register({ id: 'delayed', title: 'Delayed', mount() {} });
const panel = context.ui.panels.register({ id: 'delayed', title: 'Delayed', purpose: 'workflow', mount() {} });
const command = context.commands.register({ id: 'open', title: 'Open', run() {} });
return () => { command(); panel(); };
},
@@ -19,7 +19,7 @@ function setup() {
const gate = new Promise(resolve => { release = resolve; });
const host = new EdgeEverPluginHost({ scope: crypto.randomUUID(), packageStorage: { get: async () => null }, repository: { listMemos: async () => { calls++; entered(); await gate; return { memos: [], totalCount: 0, nextCursor: null }; } } });
const id = `org.edgeever.lifecycle-${crypto.randomUUID()}`;
host.installManifest({ type: 'plugin', id, name: 'Delayed activation', version: '1.0.0', apiVersion: '1', entry: new URL('./plugin-lifecycle.fixture.mjs', import.meta.url).href, permissions: ['notes:read', 'ui:commands', 'ui:panels'] }, 'https://example.test/manifest.json');
host.installManifest({ type: 'plugin', id, name: 'Delayed activation', version: '1.0.0', apiVersion: '2', settingsUi: 'host', entry: new URL('./plugin-lifecycle.fixture.mjs', import.meta.url).href, permissions: ['notes:read', 'ui:commands', 'ui:panels'] }, 'https://example.test/manifest.json');
return { host, id, started, release, calls: () => calls };
}
test('concurrent enable requests share a single asynchronous activation', async () => {
@@ -0,0 +1,10 @@
export default {
activate(context) {
context.ui.panels.register({
id: "custom-settings",
title: "Custom settings",
purpose: "settings",
mount() {},
});
},
};
@@ -6,7 +6,8 @@ const pluginManifest = {
id: "org.edgeever.update-test",
name: "Update Test",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["ui:notices"],
};
+6 -2
View File
@@ -20,7 +20,8 @@ Plugins never receive EdgeEver's repository, IndexedDB database, Cloudflare bind
"id": "com.example.recent-notes",
"name": "Recent Notes",
"version": "1.0.0",
"apiVersion": "1",
"apiVersion": "2",
"settingsUi": "host",
"description": "Adds a command for recent notes.",
"entry": "./main.js",
"platforms": ["web", "desktop"],
@@ -281,7 +282,7 @@ context.events.on("template.updated", ({ template }) => console.log(template.nam
Plugins can declare settings that EdgeEver renders consistently on a dedicated Plugin settings page within plugin details. Installed plugin cards and the plugin toolbar menu link directly to this page. Plugins without settings fields have no settings entry, while disabled plugins remain configurable. Settings are stored on the current device only. Put defaults and credentials in settings, and use plugin commands or functional panels for actual operations; ordinary configuration does not need a separate custom panel. Supported field types are `text`, `secret`, `number`, `boolean`, and `select`:
The settings Schema is deliberately declarative. EdgeEver owns field layout, controls, spacing, validation, responsive behavior, accessibility, save states, and secret presentation. Presentation properties such as HTML, components, CSS classes, inline styles, colors, typography, or custom setting-page navigation are ignored. A plugin decides what can be configured, not how the settings page looks. Use commands or a clearly named functional panel for complex workflows such as authorization, connectivity tests, migrations, and index rebuilding; do not recreate ordinary settings in a custom panel.
Plugin API v2 requires `settingsUi: "host"`. The settings Schema is deliberately declarative: EdgeEver owns field layout, controls, spacing, validation, responsive behavior, accessibility, save states, and secret presentation. Presentation properties such as HTML, components, CSS classes, inline styles, colors, typography, or custom setting-page navigation are ignored. A plugin decides what can be configured, not how the settings page looks. Custom settings pages are rejected by the host. Use commands or a clearly named functional panel for workflows such as authorization, connectivity tests, migrations, and index rebuilding; do not recreate ordinary settings in a custom panel.
```json
{
@@ -415,6 +416,7 @@ Plugins can register framework-independent DOM panels. Users open them from the
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
purpose: "dashboard",
presentation: "fullscreen",
mount(container, { state, requestClose }) {
const heading = document.createElement("h2");
@@ -432,6 +434,8 @@ context.ui.panels.register({
await context.ui.panels.open("dashboard", { state: { resourceId } });
```
Every API v2 panel must declare one business purpose: `workflow`, `dashboard`, `preview`, or `onboarding`. A panel is not an alternative settings surface. Persistent booleans, text, numbers, secrets, and fixed-option selections belong in the Manifest settings Schema. Workspace-backed choices that are meaningful only while performing an operation may remain workflow controls until the host settings Schema supports them.
`presentation` accepts `dialog` (the default) or `fullscreen`. `panels.open()` can only open a panel registered by the calling plugin; its optional JSON state is limited to 64 KiB and is delivered through the mount context. `beforeClose()` may return `true` to close, `false` to stay open, or confirmation copy for a host-rendered dialog. The mount context's `requestClose()` follows the same guard.
## Desktop plugin entry
+6 -2
View File
@@ -20,7 +20,8 @@ EdgeEver P0 扩展 API 支持受信任的客户端插件和无代码主题包。
"id": "com.example.recent-notes",
"name": "Recent Notes",
"version": "1.0.0",
"apiVersion": "1",
"apiVersion": "2",
"settingsUi": "host",
"description": "Adds a command for recent notes.",
"entry": "./main.js",
"platforms": ["web", "desktop"],
@@ -281,7 +282,7 @@ context.events.on("template.updated", ({ template }) => console.log(template.nam
插件可以在 Manifest 中声明设置,由 EdgeEver 在插件详情的独立「插件设置」页面统一渲染。已安装插件卡片和插件工具菜单均可直达该页面;未声明配置项的插件不显示设置入口,停用的插件仍可配置。设置仅保存在当前设备。默认行为和凭据应放在设置中,实际操作使用插件命令或功能面板,无需为普通配置另建面板。目前支持 `text`、`secret`、`number`、`boolean` 和 `select`:
设置 Schema 有意保持为声明式结构。字段布局、控件、间距、校验、响应式行为、无障碍、保存状态和密钥呈现均由 EdgeEver 管理;Manifest 中的 HTML、组件、CSS class、内联样式、颜色、字体以及自定义设置页导航等展示属性会被忽略。插件决定“配置什么”,而不是“设置页长什么样”。授权、连通性测试、数据迁移、索引重建等复杂流程应使用命令或命名清晰的功能面板,不要在自定义面板中重复实现普通设置。
插件 API v2 强制要求 `settingsUi: "host"`。设置 Schema 有意保持为声明式结构:字段布局、控件、间距、校验、响应式行为、无障碍、保存状态和密钥呈现均由 EdgeEver 管理;Manifest 中的 HTML、组件、CSS class、内联样式、颜色、字体以及自定义设置页导航等展示属性会被忽略。插件决定“配置什么”,而不是“设置页长什么样”。宿主会拒绝自定义设置页。授权、连通性测试、数据迁移、索引重建等流程应使用命令或命名清晰的功能面板,不要在自定义面板中重复实现普通设置。
```json
{
@@ -415,6 +416,7 @@ await context.ui.openNote(noteId, { search: "- [ ] 发布版本" });
context.ui.panels.register({
id: "dashboard",
title: "Dashboard",
purpose: "dashboard",
presentation: "fullscreen",
mount(container, { state, requestClose }) {
const heading = document.createElement("h2");
@@ -432,6 +434,8 @@ context.ui.panels.register({
await context.ui.panels.open("dashboard", { state: { resourceId } });
```
每个 API v2 面板必须声明一种业务用途:`workflow`、`dashboard`、`preview` 或 `onboarding`。面板不能作为另一套设置入口。持久化的布尔值、文本、数字、密钥和固定选项必须放进 Manifest 设置 Schema。只有在执行某项操作时才有意义的工作区动态选项,可在宿主设置 Schema 尚不支持时保留为工作流控件。
`presentation` 可以使用 `dialog`(默认)或 `fullscreen`。`panels.open()` 只能打开调用插件自己注册的面板;可选 JSON 状态上限为 64 KiB,并通过挂载上下文传入。`beforeClose()` 可以返回 `true` 关闭、返回 `false` 保持打开,或返回由宿主显示确认框所需的文案。挂载上下文中的 `requestClose()` 同样会经过这项保护。
## 桌面端插件入口
+6 -1
View File
@@ -1,4 +1,4 @@
export declare const PLUGIN_API_VERSION: "1";
export declare const PLUGIN_API_VERSION: "2";
export declare const THEME_API_VERSION: "1";
export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "network:public", "ai:generate", "storage", "secrets", "schedules", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels", "ui:embeds"];
export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number];
@@ -9,6 +9,8 @@ export interface PluginManifest {
name: string;
version: string;
apiVersion: typeof PLUGIN_API_VERSION;
/** Plugins must delegate ordinary persistent configuration to EdgeEver. */
settingsUi: "host";
description?: string;
author?: string;
entry: string;
@@ -301,6 +303,7 @@ export type PluginJsonValue = null | boolean | number | string | PluginJsonValue
[key: string]: PluginJsonValue;
};
export type PluginPanelPresentation = "dialog" | "fullscreen";
export type PluginPanelPurpose = "workflow" | "dashboard" | "preview" | "onboarding";
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
@@ -334,6 +337,8 @@ export interface PluginEmbedRenderer {
export interface PluginPanel {
id: string;
title: string;
/** Business purpose of this panel. Custom settings pages are intentionally unsupported. */
purpose: PluginPanelPurpose;
presentation?: PluginPanelPresentation;
mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise<void | (() => void)>;
beforeClose?(): PluginPanelCloseDecision | Promise<PluginPanelCloseDecision>;
+4 -1
View File
@@ -1,5 +1,5 @@
// src/index.ts
var PLUGIN_API_VERSION = "1";
var PLUGIN_API_VERSION = "2";
var THEME_API_VERSION = "1";
var PLUGIN_PERMISSIONS = [
"notes:read",
@@ -197,6 +197,9 @@ var parseExtensionManifest = (value) => {
if (value.type === "plugin") {
if (value.apiVersion !== PLUGIN_API_VERSION)
throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
if (value.settingsUi !== "host") {
throw new Error('Plugin API v2 requires settingsUi to be "host".');
}
if (typeof value.entry !== "string" || !value.entry.trim())
throw new Error("Plugin entry is required.");
if (value.permissions !== undefined && !Array.isArray(value.permissions))
+28 -7
View File
@@ -8,7 +8,8 @@ describe("extension manifests", () => {
id: "org.edgeever.example",
name: "Example",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"],
})).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "schedules", "ui:commands", "ui:navigation", "ui:embeds"] });
@@ -20,12 +21,27 @@ describe("extension manifests", () => {
id: "org.edgeever.bad",
name: "Bad",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["database:raw"],
})).toThrow("Unsupported plugin permission");
});
test("requires API v2 plugins to use host-rendered settings", () => {
const base = {
type: "plugin",
id: "org.edgeever.policy",
name: "Policy",
version: "1.0.0",
entry: "./main.js",
permissions: [],
};
expect(() => parseExtensionManifest({ ...base, apiVersion: "1", settingsUi: "host" })).toThrow("Unsupported plugin API version");
expect(() => parseExtensionManifest({ ...base, apiVersion: "2" })).toThrow('settingsUi to be "host"');
expect(() => parseExtensionManifest({ ...base, apiVersion: "2", settingsUi: "custom" })).toThrow('settingsUi to be "host"');
});
test("rejects unknown theme tokens", () => {
expect(() => parseExtensionManifest({
type: "theme",
@@ -56,7 +72,8 @@ describe("extension manifests", () => {
id: "org.edgeever.network",
name: "Network",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["network"],
})).toMatchObject({ permissions: ["network"] });
@@ -68,7 +85,8 @@ describe("extension manifests", () => {
id: "org.edgeever.trusted",
name: "Trusted",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
})).toMatchObject({ permissions: [] });
});
@@ -79,7 +97,8 @@ describe("extension manifests", () => {
id: "org.edgeever.public-network",
name: "Public network",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: ["network", "network:public"],
})).toMatchObject({ permissions: ["network", "network:public"] });
@@ -91,7 +110,8 @@ describe("extension manifests", () => {
id: "org.edgeever.settings",
name: "Settings",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
settings: {
@@ -127,7 +147,8 @@ describe("extension manifests", () => {
id: "org.edgeever.settings-invalid",
name: "Settings",
version: "1.0.0",
apiVersion: "1",
apiVersion: "2",
settingsUi: "host",
entry: "./main.js",
permissions: [],
};
+9 -1
View File
@@ -1,4 +1,4 @@
export const PLUGIN_API_VERSION = "1" as const;
export const PLUGIN_API_VERSION = "2" as const;
export const THEME_API_VERSION = "1" as const;
export const PLUGIN_PERMISSIONS = [
@@ -35,6 +35,8 @@ export interface PluginManifest {
name: string;
version: string;
apiVersion: typeof PLUGIN_API_VERSION;
/** Plugins must delegate ordinary persistent configuration to EdgeEver. */
settingsUi: "host";
description?: string;
author?: string;
entry: string;
@@ -330,6 +332,7 @@ export interface PluginOpenNoteOptions {
export type PluginJsonValue = null | boolean | number | string | PluginJsonValue[] | { [key: string]: PluginJsonValue };
export type PluginPanelPresentation = "dialog" | "fullscreen";
export type PluginPanelPurpose = "workflow" | "dashboard" | "preview" | "onboarding";
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
@@ -370,6 +373,8 @@ export interface PluginEmbedRenderer {
export interface PluginPanel {
id: string;
title: string;
/** Business purpose of this panel. Custom settings pages are intentionally unsupported. */
purpose: PluginPanelPurpose;
presentation?: PluginPanelPresentation;
mount(container: HTMLElement, context: PluginPanelMountContext): void | (() => void) | Promise<void | (() => void)>;
beforeClose?(): PluginPanelCloseDecision | Promise<PluginPanelCloseDecision>;
@@ -613,6 +618,9 @@ export const parseExtensionManifest = (value: unknown): ExtensionManifest => {
if (value.type === "plugin") {
if (value.apiVersion !== PLUGIN_API_VERSION) throw new Error(`Unsupported plugin API version: ${String(value.apiVersion)}`);
if (value.settingsUi !== "host") {
throw new Error('Plugin API v2 requires settingsUi to be "host".');
}
if (typeof value.entry !== "string" || !value.entry.trim()) throw new Error("Plugin entry is required.");
if (value.permissions !== undefined && !Array.isArray(value.permissions)) throw new Error("Plugin permissions must be an array.");
const allowedPermissions = new Set<string>(PLUGIN_PERMISSIONS);