Plugins podem expor rotas de API para sua UI de administração e integrações externas. As rotas são montadas em /_emdash/api/plugins/<slug>/<route-name> (o <slug> é o campo slug do plugin em emdash-plugin.jsonc — exposto em runtime como ctx.plugin.id) e são executadas no runtime do sandbox com o mesmo PluginContext que os hooks recebem.
Esta página cobre plugins isolados (sandboxed). Plugins nativos usam as mesmas opções de rota, autenticação e layout de URL, mas seus handlers recebem um único objeto de contexto combinado. Veja Your first native plugin para essa assinatura.
Definir rotas
Declare as rotas na exportação padrão de src/plugin.ts. Adicione zod como dependência de runtime quando uma rota validar a entrada ou for exposta como ferramenta MCP:
pnpm add zod
O exemplo a seguir valida uma solicitação de envios e consulta o armazenamento do plugin:
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionsInput = z.object({
formId: z.string().optional(),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().optional(),
});
const plugin: SandboxedPlugin = {
routes: {
status: {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
submissions: {
handler: async (routeCtx, ctx) => {
const parsed = submissionsInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { formId, limit, cursor } = parsed.data;
const result = await ctx.storage.submissions.query({
where: formId ? { formId } : undefined,
orderBy: { createdAt: "desc" },
limit,
cursor,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
A anotação SandboxedPlugin infere os tipos de contexto de rota e de plugin, então os parâmetros não precisam de anotações. Handlers de rotas sandboxed recebem dois argumentos: (routeCtx, ctx).
routeCtxcarrega dados em forma de solicitação:{ input, request, requestMeta }. Seuinputpermaneceunknown, então valide-o antes de usar.ctxé o mesmoPluginContextque você obtém dentro dos hooks —ctx.storage,ctx.settings,ctx.kv,ctx.content,ctx.httpectx.log.
Filtrar campos de conteúdo indexados
Plugins com a capacidade content:read podem filtrar campos personalizados que uma collection marca como
indexed. Os filtros são executados no banco de dados e se combinam com semântica AND:
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
Valores escalares usam correspondência exata. Use null para correspondência nula, { in: [...] } para um conjunto de valores
exatos, ou gt, gte, lt e lte para comparações de intervalo. O EmDash rejeita filtros para campos
que não estão indexados, valores que não correspondem ao tipo do campo e mais de 20 filtros de campo por
consulta. Um filtro in aceita no máximo 50 valores, e todos os valores exatos, limites de intervalo e membros
in juntos têm um orçamento de 50 operandos por consulta. Correspondências nulas não consomem esse orçamento.
URLs das rotas
As rotas montam em /_emdash/api/plugins/<slug>/<route-name>. Os nomes de rota podem incluir barras para caminhos aninhados.
| Plugin id | Route name | URL |
|---|---|---|
forms | status | /_emdash/api/plugins/forms/status |
forms | submissions | /_emdash/api/plugins/forms/submissions |
seo | settings/save | /_emdash/api/plugins/seo/settings/save |
analytics | events/recent | /_emdash/api/plugins/analytics/events/recent |
Autenticação e CSRF
As rotas de plugin são autenticadas por padrão. O dispatcher exige uma sessão (ou um token com o escopo admin) antes de chamar seu handler. Rotas privadas usam por padrão a permissão plugins:manage para compatibilidade. Defina permission como uma permissão RBAC do EmDash mais restrita quando a operação pertencer a uma capacidade existente de conteúdo, mídia, esquema ou configurações:
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
Rotas privadas exigem sua permissão declarada para cada método HTTP. Também exigem o cabeçalho CSRF X-EmDash-Request: 1 para solicitações autenticadas por cookie, incluindo GET e HEAD, porque uma rota de plugin pode executar o mesmo handler para qualquer método. A UI de administração envia o cabeçalho automaticamente. Solicitações autenticadas por token estão isentas do cabeçalho, mas ainda precisam do escopo de token admin e da permissão da rota.
Para isentar uma rota da autenticação, marque-a com public: true:
routes: {
track: {
public: true,
handler: async (routeCtx, ctx) => {
const parsed = z.object({ event: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
ctx.log.info("Tracked", { event: parsed.data.event });
return { ok: true };
},
},
},
A exposição de rotas públicas faz parte do acesso revisado do plugin. Instalar um plugin com rotas públicas exige consentimento. Adicionar uma rota pública, ou mudar uma rota privada para pública, exige consentimento novamente quando o plugin for atualizado.
O chamador autenticado
Em rotas privadas, routeCtx.user é o usuário autenticado que faz a solicitação — resolvido e autorizado pelo EmDash antes de seu handler ser executado, então você pode confiar nele para lógica por usuário (chaves de API por usuário, conexões OAuth, preferências gerenciadas pelo plugin):
routes: {
"connect/start": {
handler: async (routeCtx, ctx) => {
// Never read the acting user from the request body — any authenticated
// session could impersonate another user that way. Use routeCtx.user.
const caller = routeCtx.user;
if (!caller) throw new Error("No caller bound");
await ctx.kv.set(`user:${caller.id}:connection`, { startedAt: Date.now() });
return { userId: caller.id };
},
},
},
routeCtx.user é undefined em rotas públicas (elas pulam a autenticação, então nenhum chamador é vinculado — mesmo quando o visitante tem uma sessão de administração) e para solicitações autenticadas por token em que o token não está vinculado a um usuário (tokens de máquina). A forma corresponde ao UserInfo retornado por ctx.users: { id, email, name, role, createdAt } — sem campos sensíveis.
Observe que a identidade do chamador é separada da capacidade users:read: routeCtx.user diz quem está chamando e está sempre disponível em rotas privadas, enquanto ctx.users é uma consulta ao diretório de usuários que exige a capacidade.
Expor uma rota como ferramenta MCP
Plugins podem expor explicitamente rotas privadas selecionadas por meio do servidor MCP do EmDash. A exposição MCP nunca é inferida da lista de rotas:
const createEventInput = z.object({
title: z.string().min(1),
startsAt: z.string().datetime(),
});
const plugin: SandboxedPlugin = {
routes: {
"events/create": {
permission: "content:create",
handler: async (routeCtx, ctx) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
const input = parsed.data;
return { id: await createEvent(input, ctx) };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event when the user asks to add one.",
route: "events/create",
input: createEventInput,
output: z.object({ id: z.string() }),
destructive: false,
},
},
},
};
export default plugin;
O EmDash expõe isso como <pluginId>__createEvent. A rota referenciada deve ser privada e declarar permission. Esquemas de entrada são obrigatórios; esquemas de saída são opcionais. Defina destructive: true para ferramentas que excluem, sobrescrevem, publicam, cobram ou de outra forma realizam uma ação difícil de reverter.
Um administrador deve habilitar separadamente as ferramentas MCP de um plugin após revisar seus nomes, descrições, rotas, permissões e flags destrutivas. Chamar a ferramenta então exige tanto a permissão da rota quanto o escopo de token mcp:tools ou mcp:tools:<pluginId>.
Uma ferramenta MCP não pode referenciar uma rota com response: "raw". Ferramentas MCP usam o contrato de rota JSON.
Corpos de solicitação
Rotas sem uma declaração request mantêm o comportamento de entrada original. O EmDash analisa corpos de solicitação
JSON para POST, PUT e PATCH, e parâmetros de consulta para GET, HEAD e DELETE. O
valor analisado chega a um handler sandboxed como routeCtx.input: unknown.
Declare request.body quando a rota precisar de outro formato de corpo ou de um limite de bytes específico. Os
modos disponíveis são none, json, text, bytes e form-data. Os corpos de solicitação são armazenados em buffer.
O máximo padrão é 1 MiB, e uma rota pode elevar maxBytes até no máximo 8 MiB.
Use pluginRoute() para inferir o tipo de entrada a partir do modo de corpo declarado. O helper retorna seu
argumento inalterado em runtime:
import { pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
import: pluginRoute({
methods: ["POST"],
request: {
body: "bytes",
maxBytes: 4 * 1024 * 1024,
headers: ["content-type", "x-import-signature"],
},
handler: async (routeCtx) => {
const bytes = routeCtx.input; // Uint8Array
const signature = routeCtx.request.headers["x-import-signature"];
return { accepted: bytes.byteLength, signature };
},
}),
},
};
export default plugin;
Para body: "none", routeCtx.input é o registro de string de consulta analisado. Uma declaração json mantém
o tipo de entrada como unknown, então valide-o antes de usar. Uma declaração text produz uma string, e
bytes produz um Uint8Array.
form-data aceita multipart/form-data e application/x-www-form-urlencoded. Produz um
array entries ordenado. Entradas de texto contêm { name, kind: "text", value }; entradas de arquivo contêm
{ name, kind: "file", filename, contentType, bytes }. O EmDash aceita no máximo 100 partes, 1 MiB por
parte e nomes de arquivo de até 255 bytes UTF-8. Nomes de arquivo não podem conter caracteres de controle nem
separadores de caminho. A solicitação codificada total também deve caber no limite de corpo da rota.
Valide valores analisados antes de ler campos ou realizar efeitos colaterais. Use safeParse quando
entrada inválida for um erro esperado do chamador. Isso permite que a rota retorne um resultado JSON estável em vez
de transformar entrada inválida em uma exceção interna:
const createInput = z.object({
title: z.string().min(1).max(200),
email: z.string().email(),
priority: z.enum(["low", "medium", "high"]).default("medium"),
tags: z.array(z.string()).optional(),
});
routes: {
create: {
handler: async (routeCtx, ctx) => {
const parsed = createInput.safeParse(routeCtx.input);
if (!parsed.success) {
return { ok: false, error: { code: "VALIDATION_ERROR" } };
}
const { title, email, priority, tags } = parsed.data;
await ctx.storage.items.put(`item_${Date.now()}`, {
title,
email,
priority,
tags: tags ?? [],
createdAt: new Date().toISOString(),
});
return { ok: true };
},
},
},
Entrada de string de consulta (GET/HEAD/DELETE)
Métodos sem corpo não têm corpo de solicitação, então sua entrada vem da string de consulta da URL. Cada valor é uma string. Chaves repetidas se tornam arrays, então ?tag=a&tag=b se torna { tag: ["a", "b"] }; um único ?tag=a permanece { tag: "a" }. Use z.coerce para números e outros valores que não são strings:
const listInput = z.object({
status: z.enum(["open", "closed"]).optional(),
limit: z.coerce.number().int().min(1).max(100).default(20),
tag: z.union([z.string(), z.array(z.string())]).optional(),
});
routes: {
list: {
// GET /_emdash/api/plugins/<slug>/list?status=open&limit=20&tag=a&tag=b
handler: async (routeCtx, ctx) => {
const parsed = listInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_QUERY" };
const { status, limit, tag } = parsed.data;
// ...
},
},
},
Valores de retorno JSON
Rotas usam o contrato de resposta JSON a menos que declarem response: "raw". Retorne qualquer
valor serializável em JSON. O dispatcher o envolve no envelope padrão do EmDash
({ success: true, data: <your value> }) e o serve como application/json.
return { id: "abc", count: 42 }; // wrapped to { success: true, data: { id, count } }
return [1, 2, 3]; // wrapped to { success: true, data: [1, 2, 3] }
Erros
Lance quando uma rota sandboxed não puder ser concluída. O EmDash registra a exceção e retorna um ROUTE_ERROR. A mensagem lançada pode ser incluída nessa resposta, então nunca coloque credenciais, dados pessoais, caminhos internos ou stack traces em uma mensagem de exceção:
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
Código de plugin sandboxed não pode selecionar um status HTTP arbitrário lançando uma Response; uma Response não atravessa o limite de cada executor de sandbox como erro estruturado. O EmDash atribui status a falhas de autenticação, autorização, CSRF e rota ausente antes de o handler ser executado. Retorne um resultado JSON para resultados esperados de validação e de domínio, e reserve exceções para falhas inesperadas.
Um erro esperado retornado como JSON ainda usa a resposta HTTP bem-sucedida da rota e aparece dentro do envelope externo { success: true, data: ... } do EmDash. Inclua um código estável no nível da aplicação para que os clientes possam distinguir esse resultado.
Métodos HTTP
O nome da rota seleciona um handler. Declare methods para restringir quais métodos HTTP podem invocá-lo.
O EmDash retorna 405 Method Not Allowed com um cabeçalho Allow antes de chamar o handler quando o
método da solicitação não está declarado:
routes: {
item: {
methods: ["GET", "DELETE"],
handler: async (routeCtx, ctx) => {
const parsed = z.object({ id: z.string() }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_ID" };
const { id } = parsed.data;
switch (routeCtx.request.method) {
case "GET":
return await ctx.storage.items.get(id);
case "DELETE":
await ctx.storage.items.delete(id);
return { deleted: true };
}
},
},
},
Rotas sem methods permanecem agnósticas ao método por compatibilidade. Verifique
routeCtx.request.method dentro de uma rota legacy antes de realizar uma mutação, ou adicione methods para
fazer o host aplicar a restrição.
Respostas brutas
Declare response: "raw" quando uma rota precisar retornar texto ou bytes sem envelopar com um status personalizado e
cabeçalhos de resposta seguros. Retorne pluginResponse() de emdash/plugin; uma Response WHATWG não
atravessa o limite do sandbox:
import { pluginResponse, pluginRoute, type SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
routes: {
download: pluginRoute({
public: true,
methods: ["GET"],
request: { body: "none" },
response: "raw",
cacheControl: "public, max-age=60",
handler: async () =>
pluginResponse({
status: 200,
headers: {
"content-type": "text/csv; charset=utf-8",
"content-disposition": 'attachment; filename="report.csv"',
},
body: { kind: "text", value: "name,count\nPublished,12\n" },
}),
}),
},
};
export default plugin;
O corpo da resposta é { kind: "text", value: string } ou
{ kind: "bytes", value: Uint8Array } e é armazenado em buffer até 8 MiB. Respostas brutas podem definir
Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range,
Content-Type, ETag, Last-Modified, Location e Retry-After; o host remove qualquer outro
cabeçalho fornecido pelo plugin. Ele adiciona
X-Content-Type-Options: nosniff, uma política de segurança de conteúdo de documento isolado e
Referrer-Policy: no-referrer. Aplica o cacheControl da rota apenas a respostas públicas bem-sucedidas
GET e HEAD. Outras respostas usam private, no-store.
Rotas brutas não podem servir conteúdo ativo same-origin. O EmDash rejeita HTML, JavaScript e
ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related e
tipos de mídia multipart/x-mixed-replace. Use um plugin nativo ou uma origem separada quando a resposta
precisar executar conteúdo ativo do navegador.
Acessar a solicitação
routeCtx.request é um SandboxedRequest: um registro portátil { url, method, headers } que se comporta de forma idêntica em processo e dentro de um isolate. headers é um Record<string, string> com chaves em minúsculas — indexe pelo nome em minúsculas, ou itere com Object.entries. url é uma string, então new URL(request.url) analisa parâmetros de consulta. routeCtx.requestMeta carrega IP, user agent e dados geo normalizados entre plataformas quando disponíveis.
Para uma rota com uma declaração request, apenas nomes em request.headers chegam ao handler. O EmDash
rejeita declarações de credenciais, cookies, cabeçalhos Cloudflare Access, autorização de proxy,
Set-Cookie e o cabeçalho CSRF X-EmDash-Request. Ele remove esses cabeçalhos de toda solicitação
sandboxed, incluindo rotas legacy.
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;
const signature = request.headers["x-import-signature"]; // lowercased key, no .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");
ctx.log.info("Request", { meta: requestMeta });
if (request.method !== "POST") return { error: "POST_REQUIRED" };
},
Padrões comuns
Configurações e dados paginados
Configurações de plugin usam rotas privadas, formulários Block Kit e ctx.settings. Settings fornece o padrão completo de carregamento, validação, formulário e segredos criptografados.
Rotas que listam dados do plugin devem retornar o cursor de ctx.storage.<collection>.query(). Storage pagination mostra como passar um cursor e esgotar várias páginas sem exceder o máximo de 100 itens por página.
Proxy de API externa
Faça proxy de uma solicitação para um serviço externo por meio de ctx.http (exige a capacidade network:request e uma entrada em allowedHosts):
routes: {
forecast: {
handler: async (routeCtx, ctx) => {
const parsed = z.object({ city: z.string().min(1) }).safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_CITY" };
if (!ctx.http) throw new Error("Network capability not granted");
const apiKey = await ctx.settings.get<string>("apiKey");
if (!apiKey) throw new Error("API key not configured");
const response = await ctx.http.fetch(
`https://api.weather.example.com/forecast?city=${encodeURIComponent(parsed.data.city)}`,
{ headers: { "X-API-Key": apiKey } },
);
if (!response.ok) {
throw new Error(`Weather API error: ${response.status}`);
}
return response.json();
},
},
},
ctx.http.fetch() retorna uma Response WHATWG em buffer em ambos os executores de sandbox. Métodos binários como arrayBuffer() e blob() preservam bytes no Cloudflare Worker Loader e no Node/workerd. Corpos de solicitação e resposta são cada um limitados a 8 MiB de dados decodificados. Destinos de redirecionamento são verificados antes de cada hop, e cabeçalhos de credenciais são removidos quando um redirecionamento cruza origens.
Chamar rotas a partir do Block Kit
Plugins sandboxed não enviam código React para a administração. Declare uma rota admin e retorne respostas Block Kit. O EmDash envia as interações page_load, block_action e form_submit para essa rota privada com a URL e o cabeçalho CSRF corretos. Block Kit mostra o contrato de interação e uma rota completa.
Chamar rotas a partir de handlers de fila e agendados
Handlers de eventos de plataforma (um consumidor Cloudflare Queue, um handler scheduled() personalizado) não têm solicitação HTTP e, portanto, não têm locals.emdash. Use withEmDashRuntime() de emdash/middleware para obter o runtime diretamente e invocar uma rota de plugin sem uma solicitação:
import { withEmDashRuntime } from "emdash/middleware";
export default {
// ... fetch/scheduled from @emdash-cms/cloudflare/worker
async queue(batch: MessageBatch) {
await withEmDashRuntime(async (runtime) => {
for (const message of batch.messages) {
const result = await runtime.handlePluginApiRoute(
"my-plugin",
"POST",
"/finishJob",
new Request("https://internal/", {
method: "POST",
body: JSON.stringify(message.body),
}),
);
if (result.success) message.ack();
else message.retry();
}
});
},
};
Isso resolve o mesmo runtime em cache que os handlers de solicitação usam, então armazenamento do plugin, hooks e acesso a mídia se comportam exatamente como durante uma solicitação. Em adaptadores de banco de dados baseados em conexão (por ex. Postgres sobre Hyperdrive), o callback é executado sob uma conexão com escopo de evento que é confirmada e fechada quando retorna.
Chamar rotas externamente
Rotas públicas são chamáveis diretamente:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
Rotas privadas precisam de credenciais de sessão mais X-EmDash-Request: 1, ou de um token de API com o escopo admin. A seguinte solicitação de servidor para servidor usa um token:
curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"title": "Hello", "email": "user@example.com"}'
Referência do contexto de rota
As interfaces a seguir resumem os valores portáteis disponíveis para um handler de rota sandboxed:
// What sandboxed route handlers receive as their two arguments
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}
interface SandboxedRouteContext {
input: unknown; // validate inside the handler before use
request: SandboxedRequest;
requestMeta?: unknown;
user?: UserInfo; // authenticated caller on private routes; undefined on public routes
}
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
interface PluginContext {
plugin: { id: string; version: string };
storage: PluginStorage;
kv: KVAccess;
log: LogAccess;
site: SiteInfo;
url(path: string): string;
cron?: CronAccess;
content?: ContentAccess; // when content:read or content:write declared
schema?: SchemaAccess; // when schema:read declared
taxonomies?: TaxonomyAccess; // when taxonomies:read declared
redirects?: RedirectAccess; // when redirects:read or redirects:write declared
media?: MediaAccess; // when any media capability is declared
http?: HttpAccess; // when network:request declared
users?: UserAccess; // when users:read declared
email?: EmailAccess; // when email:send declared and provider configured
}
Plugins nativos recebem um único argumento RouteContext que combina os dois — veja Creating native plugins se estiver seguindo esse caminho.