設定

本頁內容

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