設定

本頁內容

沙箱外掛透過 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 產生表單。該格式請參閱你的第一個原生外掛。