沙箱插件通过 ctx.settings 存储站点特定配置。Block Kit 管理页面会加载当前值、接受更改、校验它们,并通过同一插件作用域的存储写入。声明为 type: "secret" 的字段会在 EmDash 写入数据库之前加密。
读取与写入设置
每个 hook 和路由都会在 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 别名在 0.x 发布线的剩余时间内仍受支持。EmDash 不会在 1.0 之前移除它,之后的移除将包含弃用期和迁移指南。新代码应使用 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 加密 secret schema 字段。经过认证的数据将每个值绑定到其插件 ID 和设置键,因此将信封复制到另一个插件或键会导致解密失败。EMDASH_ENCRYPTION_KEY 中的第一个密钥加密新写入;EmDash 在读取时按指纹选择较旧密钥。缺失、错误或被篡改的密钥会失败关闭。管理响应和主机错误不包含明文。插件读取或写入密钥后,主机日志记录器会从 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 支持直接键访问和前缀列表,但没有字段查询或索引。Storage 提供带索引过滤、排序、计数和分页的文档集合。
原生插件可以改为在 definePlugin() 内声明 admin.settingsSchema,并让 EmDash 生成表单。该格式参见 Your first native plugin。