샌드박스 플러그인은 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는 스키마의 secret 필드를 AES-GCM으로 암호화합니다. 인증된 데이터가 각 값을 플러그인 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가 폼을 생성하게 할 수도 있습니다. 해당 형식은 첫 번째 네이티브 플러그인을 참고하세요.