沙箱插件通过 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 生成表单。该格式请参阅你的第一个原生插件。