设置

本页内容

沙箱插件通过 ctx.settings 存储特定于站点的配置。Block Kit 管理页面会加载当前值、接受更改、校验这些更改,并通过同一个按插件划分作用域的存储将其写入。声明为 type: "secret" 的字段在 EmDash 将其写入数据库之前会先被加密。

读取与写入设置

每个钩子和路由都会在 ctx 上收到如下设置接口:

interface SettingsAccess {
	get<T>(key: string): Promise<T | null>;
	getVersioned<T>(key: string): Promise<{ value: T; revision: string } | null>;
	compareAndSet(key: string, expectedRevision: string | null, value: unknown):
		Promise<{ applied: true; revision: string } | { applied: false }>;
	compareAndDelete(key: string, expectedRevision: string): Promise<{ applied: boolean }>;
	set(key: string, value: unknown): Promise<void>;
	delete(key: string): Promise<boolean>;
	list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
}

设置按插件划分命名空间。两个插件可以使用相同的键,而不会互相读取或覆盖对方的值。

当并发请求可能修改同一个键时,请使用条件写入来拒绝基于过期修订的更新。原生插件和沙箱插件都可以使用相同的方法。

请将 ctx.kv 单独用于内部状态和缓存值:

API用途示例
ctx.settings用户可配置的值apiKey
带 state: 前缀的 ctx.kv持久的内部状态state:lastSync
带 cache: 前缀的 ctx.kv可复用的计算数据或远程数据cache:feed

以下调用涵盖了 KV 操作:

const enabled = await ctx.settings.get<boolean>("enabled");
await ctx.kv.set("state:lastSync", new Date().toISOString());
const deleted = await ctx.kv.delete("cache:feed");
const allSettings = await ctx.settings.list();

键不存在时,get 返回 null。list 返回的键不带 EmDash 内部的插件命名空间前缀。

现有插件可以继续读取 ctx.kv.get("settings:<key>")。完整的 settings: KV 别名在整个 1.x 期间仍受支持。如果在后续的主版本中移除它,将提供弃用期和迁移指南。新代码应使用 ctx.settings。

添加设置页面

在 emdash-plugin.jsonc 中声明该页面,使其出现在插件的管理导航中:

"admin": {
	"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
	"settingsSchema": {
		"apiKey": { "type": "secret", "label": "API key" },
		"enabled": { "type": "boolean", "label": "Enabled", "default": true },
		"maxItems": { "type": "number", "label": "Max items", "default": 100 }
	}
}

该 schema 会告诉 EmDash 哪些值需要加密。Block Kit 中的 secret_input 只会遮蔽浏览器中的输入,它本身并不会把已存储的值标记为密钥。

ctx.settings 不需要任何 capability,因为宿主会把它的命名空间固定为当前插件。添加设置字段不会扩大插件的 declaredAccess,也不会触发 capability 的重新授权。管理员在该插件的设置表单中输入凭证,即表示授予该插件对此凭证的访问权限。

插件还必须提供一个名为 admin 的私有路由。页面打开时 EmDash 会发送 page_load,用户提交表单时会发送 form_submit。

添加 @emdash-cms/blocks 和 zod,以使用响应类型并校验交互:

pnpm add @emdash-cms/blocks zod

下面的路由会加载三个值,并且只写入经过校验的表单字段:

import type { BlockResponse } from "@emdash-cms/blocks";
import type { PluginContext, SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";

const interactionSchema = z.discriminatedUnion("type", [
	z.object({ type: z.literal("page_load"), page: z.string() }),
	z.object({
		type: z.literal("form_submit"),
		action_id: z.string(),
		block_id: z.string().optional(),
		values: z.object({
			apiKey: z.string().optional(),
			enabled: z.boolean(),
			maxItems: z.number().int().min(1).max(1000),
		}),
	}),
	z.object({
		type: z.literal("block_action"),
		action_id: z.string(),
		block_id: z.string().optional(),
		value: z.unknown().optional(),
	}),
]);

const plugin: SandboxedPlugin = {
	routes: {
		admin: {
			handler: async (routeCtx, ctx) => {
				const parsed = interactionSchema.safeParse(routeCtx.input);
				if (!parsed.success) return { blocks: [] };
				const interaction = parsed.data;

				if (interaction.type === "page_load" && interaction.page === "/settings") {
					return renderSettings(ctx);
				}

				if (interaction.type === "form_submit" && interaction.action_id === "save") {
					await saveSettings(ctx, interaction.values);
					return {
						...(await renderSettings(ctx)),
						toast: { message: "Settings saved", type: "success" },
					};
				}

				return { blocks: [] };
			},
		},
	},
};

export default plugin;

async function renderSettings(ctx: PluginContext): Promise<BlockResponse> {
	const apiKeyConfigured = (await ctx.settings.get<string>("apiKey")) !== null;
	const enabled = (await ctx.settings.get<boolean>("enabled")) ?? true;
	const maxItems = (await ctx.settings.get<number>("maxItems")) ?? 100;

	return {
		blocks: [
			{ type: "header", text: "Plugin settings" },
			{
				type: "form",
				block_id: "settings",
				fields: [
					{
						type: "secret_input",
						action_id: "apiKey",
						label: "API key",
						has_value: apiKeyConfigured,
					},
					{
						type: "toggle",
						action_id: "enabled",
						label: "Enabled",
						initial_value: enabled,
					},
					{
						type: "number_input",
						action_id: "maxItems",
						label: "Max items",
						min: 1,
						max: 1000,
						initial_value: maxItems,
					},
				],
				submit: { label: "Save", action_id: "save" },
			},
		],
	};
}

async function saveSettings(
	ctx: PluginContext,
	values: { apiKey?: string; enabled: boolean; maxItems: number },
) {
	if (values.apiKey) await ctx.settings.set("apiKey", values.apiKey);
	await ctx.settings.set("enabled", values.enabled);
	await ctx.settings.set("maxItems", values.maxItems);
}

在用户编辑密钥之前,提交的值中会省略该密钥;如果用户聚焦后又清空了该字段,则可能包含空字符串。只有当提交的字符串非空时,saveSettings 才会写入新的 API 密钥。页面使用 has_value 来表明已存在保存的值,而无需把该值返回给浏览器。

Block Kit 是交互、区块、表单元素、构建器和条件字段的权威参考。

密钥值

EmDash 使用 AES-GCM 加密 schema 中的 secret 字段。认证数据会把每个值绑定到其插件 ID 和设置键,因此把信封复制到另一个插件或另一个键会导致解密失败。EMDASH_ENCRYPTION_KEY 中的第一个密钥用于加密新写入的内容;读取时,EmDash 会根据指纹选择较旧的密钥。缺失、错误或被篡改的密钥会导致失败关闭(fail closed)。管理后台的响应和宿主错误中不包含明文。插件读取或写入某个密钥后,宿主日志记录器会从 ctx.log 的消息和结构化数据中,对该键当前值以及紧邻的上一个值的精确匹配内容进行脱敏。

插件仍然会收到明文,并且可以对其进行转换,或通过已声明的网络或邮件访问将其发送出去。在输入凭证之前,请审查这些 capability,并且切勿记录由密钥派生或编码得到的密钥材料。

已有的明文值仍然可以读取。再次保存该值即可用加密信封替换它。如果某个密钥即使以加密形式也不得写入 EmDash 数据库,请使用由部署密钥或外部凭证服务支持的原生插件。沙箱插件无法读取宿主进程的环境变量或平台绑定。

如果用户需要清除某个密钥,请提供一个单独的、明确的操作。把空的遮蔽字段当作删除处理,可能会在用户保存无关设置时抹掉一个仍然有效的凭证。

默认值与升级

读取键时应用默认值,这样现有安装无需迁移就能获得新设置:

const enabled = (await ctx.settings.get<boolean>("enabled")) ?? true;
const maxItems = (await ctx.settings.get<number>("maxItems")) ?? 100;

你可以在安装期间持久化初始值:

hooks: {
	"plugin:install": async (_event, ctx) => {
		await ctx.settings.set("enabled", true);
		await ctx.settings.set("maxItems", 100);
	},
},

plugin:install 只在全新安装时运行。当后续版本添加设置时,现有站点不会再次运行它。请保留读取时的回退值,或在 plugin:activate 期间以幂等方式初始化缺失的键。

选择 KV 或 storage

数据使用
小型的用户可配置值ctx.settings
小型内部状态或游标带 state: 前缀的 ctx.kv
提交记录或日志等可查询的记录已声明的 ctx.storage 集合
通过常规 EmDash 编辑器编辑的内容站点内容集合

KV 支持直接按键访问和按前缀列出,但没有字段查询或索引。存储提供文档集合,支持带索引的过滤、排序、计数和分页。

原生插件也可以在 definePlugin() 中声明 admin.settingsSchema,由 EmDash 生成表单。该格式请参阅你的第一个原生插件。