沙箱外掛透過 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。