沙箱外掛透過 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 }
}
}
該結構描述會告訴 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 欄位。認證資料會把每個值綁定到其外掛 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 支援直接按鍵存取和按前綴列出,但沒有欄位查詢或索引。Storage 提供文件集合,支援帶索引的篩選、排序、計數和分頁。
原生外掛也可以在 definePlugin() 中宣告 admin.settingsSchema,由 EmDash 產生表單。該格式請參閱你的第一個原生外掛。