Los plugins en sandbox almacenan la configuración específica del sitio mediante ctx.settings. Una página de administración de Block Kit carga los valores actuales, acepta cambios, los valida y los escribe a través del mismo almacén con ámbito de plugin. Los campos declarados con type: "secret" se cifran antes de que EmDash los escriba en la base de datos.
Leer y escribir ajustes
Cada hook y cada ruta reciben esta interfaz de ajustes en 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 }>>;
}
Los ajustes tienen un espacio de nombres por plugin. Dos plugins pueden usar la misma clave sin leer ni sobrescribir los valores del otro.
Cuando varias solicitudes simultáneas puedan modificar la misma clave, usa las escrituras condicionales para rechazar las actualizaciones basadas en una revisión obsoleta. Los mismos métodos funcionan en plugins nativos y en plugins en sandbox.
Usa ctx.kv por separado para el estado interno y los valores en caché:
| API | Propósito | Ejemplo |
|---|---|---|
ctx.settings | Valores configurables por el usuario | apiKey |
ctx.kv con state: | Estado interno persistente | state:lastSync |
ctx.kv con cache: | Datos calculados o remotos reutilizables | cache:feed |
Las siguientes llamadas cubren las operaciones de 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 devuelve null cuando la clave no existe. list devuelve las claves sin el prefijo interno del espacio de nombres del plugin de EmDash.
Los plugins existentes pueden seguir leyendo ctx.kv.get("settings:<key>"). El alias KV completo settings: seguirá siendo compatible durante toda la serie 1.x. Cualquier eliminación en una versión mayor posterior incluirá un periodo de deprecación y una guía de migración. El código nuevo debería usar ctx.settings.
Añadir una página de ajustes
Declara la página en emdash-plugin.jsonc para que aparezca en la navegación de administración del plugin:
"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 }
}
}
El esquema le indica a EmDash qué valores requieren cifrado. Un secret_input en Block Kit solo enmascara la entrada del navegador; por sí solo no marca un valor almacenado como secreto.
ctx.settings no necesita ninguna capability porque el host fija su espacio de nombres al plugin actual. Añadir un campo de ajustes no amplía el declaredAccess del plugin ni activa un nuevo consentimiento de capabilities. Un administrador concede al plugin acceso a una credencial al introducirla en el formulario de ajustes de ese plugin.
El plugin también debe proporcionar una ruta privada llamada admin. EmDash envía page_load cuando se abre la página y form_submit cuando el usuario envía el formulario.
Añade @emdash-cms/blocks y zod para usar el tipo de respuesta y validar las interacciones:
pnpm add @emdash-cms/blocks zod
La siguiente ruta carga tres valores y escribe únicamente los campos del formulario que han sido validados:
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);
}
Los valores enviados omiten el secreto hasta que el usuario lo edita, y pueden contener una cadena vacía si el usuario pone el foco en el campo y lo borra. saveSettings escribe una nueva clave de API solo cuando la cadena enviada no está vacía. La página usa has_value para mostrar que existe un valor guardado sin devolver ese valor al navegador.
Block Kit es la referencia canónica para interacciones, bloques, elementos de formulario, constructores y campos condicionales.
Valores secretos
EmDash cifra los campos secret del esquema con AES-GCM. Los datos autenticados vinculan cada valor con el ID de su plugin y la clave del ajuste, de modo que copiar un sobre a otro plugin o a otra clave hace que el descifrado falle. La primera clave de EMDASH_ENCRYPTION_KEY cifra las escrituras nuevas; al leer, EmDash selecciona las claves más antiguas según su huella. Las claves ausentes, incorrectas o manipuladas fallan de forma cerrada (fail closed). Las respuestas de administración y los errores del host no contienen el texto sin cifrar. Después de que un plugin lee o escribe un secreto, el registrador del host oculta el valor exacto actual y el inmediatamente anterior de esa clave en los mensajes de ctx.log y en los datos estructurados.
El plugin sigue recibiendo el texto sin cifrar y puede transformarlo o enviarlo mediante el acceso de red o de correo declarado. Revisa esas capabilities antes de introducir una credencial y nunca registres material secreto derivado o codificado.
Los valores en texto plano existentes siguen siendo legibles. Guarda el valor de nuevo para sustituirlo por un sobre cifrado. Si un secreto no debe escribirse en la base de datos de EmDash ni siquiera cifrado, usa un plugin nativo respaldado por un secreto de despliegue o por un servicio externo de credenciales. Los plugins en sandbox no pueden leer el entorno del proceso del host ni los bindings de la plataforma.
Proporciona una acción independiente y deliberada si los usuarios necesitan borrar un secreto. Tratar un campo enmascarado vacío como una eliminación puede borrar una credencial que funciona cuando un usuario guarda un ajuste que no tiene relación.
Valores por defecto y actualizaciones
Aplica los valores por defecto al leer una clave para que las instalaciones existentes reciban un ajuste nuevo sin una migración:
const enabled = (await ctx.settings.get<boolean>("enabled")) ?? true;
const maxItems = (await ctx.settings.get<number>("maxItems")) ?? 100;
Puedes persistir los valores iniciales durante la instalación:
hooks: {
"plugin:install": async (_event, ctx) => {
await ctx.settings.set("enabled", true);
await ctx.settings.set("maxItems", 100);
},
},
plugin:install solo se ejecuta en una instalación nueva. Cuando una versión posterior añade un ajuste, los sitios existentes no lo ejecutan de nuevo. Mantén el valor alternativo en la lectura o inicializa la clave que falta de forma idempotente durante plugin:activate.
Elegir KV o storage
| Datos | Usa |
|---|---|
| Valores pequeños configurables por el usuario | ctx.settings |
| Estado interno pequeño o cursores | ctx.kv con un prefijo state: |
| Registros consultables, como envíos o logs | Una colección ctx.storage declarada |
| Contenido editado mediante el editor normal de EmDash | Una colección de contenido del sitio |
KV admite el acceso directo por clave y el listado por prefijo, pero no tiene consultas por campo ni índices. Storage ofrece colecciones de documentos con filtrado indexado, ordenación, recuento y paginación.
En su lugar, los plugins nativos pueden declarar admin.settingsSchema dentro de definePlugin() y dejar que EmDash genere el formulario. Consulta Tu primer plugin nativo para ese formato.