Rotas de API

Nesta página

Os plugins podem expor rotas de API para a sua interface de administração e para 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 tempo de execução como ctx.plugin.id) e são executadas dentro do runtime do sandbox com o mesmo PluginContext que os hooks recebem.

Esta página trata dos plugins em sandbox. Os plugins nativos usam as mesmas opções de rota, a mesma autenticação e a mesma estrutura de URL, mas os seus handlers recebem um único objeto de contexto combinado. Consulte Seu primeiro plugin nativo para ver essa assinatura.

Definindo 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 requisiçã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 da rota e do contexto do plugin, portanto os parâmetros não precisam de anotações. Os handlers de rota em sandbox recebem dois argumentos: (routeCtx, ctx).

  • routeCtx contém dados próprios da requisição: { input, request, requestMeta }. Seu input continua unknown, então valide-o antes de usar.
  • ctx é o mesmo PluginContext que você recebe dentro dos hooks — ctx.storage, ctx.settings, ctx.kv, ctx.content, ctx.http e ctx.log.

Filtrando campos de conteúdo indexados

Plugins com a capability content:read podem filtrar os campos personalizados que uma coleção 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 com nulo, { in: [...] } para um conjunto de valores exatos, ou gt, gte, lt e lte para comparações de intervalo. O EmDash rejeita filtros em 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 de in juntos têm um orçamento de 50 operandos por consulta. Correspondências com nulo não consomem esse orçamento.

URLs das rotas

As rotas são montadas em /_emdash/api/plugins/<slug>/<route-name>. Os nomes das rotas podem incluir barras para caminhos aninhados.

ID do pluginNome da rotaURL
formsstatus/_emdash/api/plugins/forms/status
formssubmissions/_emdash/api/plugins/forms/submissions
seosettings/save/_emdash/api/plugins/seo/settings/save
analyticsevents/recent/_emdash/api/plugins/analytics/events/recent

Autenticação e CSRF

As rotas de plugin são autenticadas por padrão. O despachante exige uma sessão (ou um token com o escopo admin) antes de chamar o seu handler. Por compatibilidade com versões anteriores, as rotas privadas usam por padrão a permissão plugins:manage. Defina permission como uma permissão RBAC do EmDash mais restrita quando a operação pertencer a uma capability 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.
		},
	},
},

As rotas privadas exigem a permissão declarada para todos os métodos HTTP. Elas também exigem o cabeçalho CSRF X-EmDash-Request: 1 nas requisições autenticadas por cookie, incluindo GET e HEAD, porque uma rota de plugin pode executar o mesmo handler para qualquer método. A interface de administração envia o cabeçalho automaticamente. As requisiçõ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 excluir 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 de um plugin. Instalar um plugin com rotas públicas exige consentimento. Adicionar uma rota pública, ou mudar uma rota privada para pública, exige novo consentimento quando o plugin é atualizado.

O chamador autenticado

Em rotas privadas, routeCtx.user é o usuário autenticado que faz a requisição — resolvido e autorizado pelo EmDash antes de o seu handler ser executado, então você pode confiar nele para a 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 ignoram a autenticação, então nenhum chamador é associado — mesmo quando o visitante por acaso tem uma sessão de administrador) e em requisições autenticadas por token cujo token não está associado a um usuário (tokens de máquina). O formato corresponde ao UserInfo retornado por ctx.users: { id, email, name, role, createdAt } — sem campos sensíveis.

Observe que a identidade do chamador é separada da capability users:read: routeCtx.user informa 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 capability.

Expondo uma rota como ferramenta MCP

Os plugins podem expor explicitamente rotas privadas selecionadas por meio do servidor MCP do EmDash. A exposição MCP nunca é inferida a partir 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. Os esquemas de entrada são obrigatórios; os esquemas de saída são opcionais. Defina destructive: true para ferramentas que excluem, sobrescrevem, publicam, cobram ou realizam de outra forma uma ação difícil de reverter.

Um administrador deve habilitar separadamente as ferramentas MCP de um plugin depois de revisar seus nomes, descrições, rotas, permissões e indicadores destrutivos. Chamar a ferramenta exige então 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". As ferramentas MCP usam o contrato de rota JSON.

Corpos de requisição

As rotas sem declaração request mantêm o comportamento de entrada original. O EmDash analisa corpos de requisição JSON para POST, PUT e PATCH, e parâmetros de consulta para GET, HEAD e DELETE. O valor analisado chega a um handler em sandbox 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 requisição são mantidos 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. Em tempo de execução, o helper retorna seu argumento sem alterações:

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;

Com body: "none", routeCtx.input é o registro da query string analisada. 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. Ele produz um array entries ordenado. As entradas de texto contêm { name, kind: "text", value }; as 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. Os nomes de arquivo não podem conter caracteres de controle nem separadores de caminho. A requisição codificada inteira também deve caber no limite de corpo da rota.

Valide os valores analisados antes de ler campos ou executar efeitos colaterais. Use safeParse quando uma entrada inválida for um erro esperado do chamador. Assim a rota pode retornar um resultado JSON estável em vez de transformar a 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 por query string (GET/HEAD/DELETE)

Métodos sem corpo não têm corpo de requisição, então a entrada vem da query string da URL. Todo valor é uma string. Chaves repetidas viram arrays, de modo que ?tag=a&tag=b vira { tag: ["a", "b"] }; um único ?tag=a continua sendo { tag: "a" }. Use z.coerce para números e outros valores que não sejam 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

As rotas usam o contrato de resposta JSON, a menos que declarem response: "raw". Retorne qualquer valor serializável em JSON. O despachante 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 uma exceção quando uma rota em sandbox 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 na mensagem de uma 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");
	}
},

O código de um plugin em sandbox não pode escolher um status HTTP arbitrário lançando uma Response; uma Response não atravessa, como erro estruturado, a fronteira de todos os executores de sandbox. O EmDash atribui os status das falhas de autenticação, autorização, CSRF e rota inexistente antes de o handler ser executado. Retorne um resultado JSON para os resultados esperados de validação e de domínio, e reserve as exceções para falhas inesperadas.

Um erro esperado retornado como JSON ainda usa a resposta HTTP de sucesso da rota e aparece dentro do envelope externo { success: true, data: ... } do EmDash. Inclua um código estável em nível de aplicação para que os clientes possam distinguir esse resultado.

Métodos HTTP

O nome da rota seleciona um único 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 requisiçã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 };
			}
		},
	},
},

As rotas sem methods continuam agnósticas quanto ao método, por compatibilidade. Verifique routeCtx.request.method dentro de uma rota legada antes de realizar uma mutação, ou adicione methods para que o host aplique a restrição.

Respostas brutas

Declare response: "raw" quando uma rota precisar retornar texto ou bytes sem envelope, com um status personalizado e cabeçalhos de resposta seguros. Retorne pluginResponse() de emdash/plugin; uma Response do WHATWG não atravessa a fronteira 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 é mantido em buffer até 8 MiB. As 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 para documentos em sandbox e Referrer-Policy: no-referrer. Aplica o cacheControl da rota apenas a respostas públicas GET e HEAD bem-sucedidas. As demais respostas usam private, no-store.

As rotas brutas não podem servir conteúdo ativo de mesma origem. O EmDash rejeita os tipos de mídia HTML, JavaScript e ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related e multipart/x-mixed-replace. Use um plugin nativo ou uma origem separada quando a resposta precisar executar conteúdo ativo no navegador.

Acessando a requisição

routeCtx.request é uma SandboxedRequest: um registro portável { url, method, headers } que se comporta de forma idêntica no mesmo processo e dentro de um isolate. headers é um Record<string, string> indexado pelo nome do cabeçalho em minúsculas — acesse pelo nome em minúsculas, ou itere com Object.entries. url é uma string, então new URL(request.url) analisa os parâmetros de consulta. routeCtx.requestMeta contém IP, user agent e dados de geolocalização normalizados entre plataformas, quando disponíveis.

Em uma rota com declaração request, apenas os nomes listados em request.headers chegam ao handler. O EmDash rejeita declarações para credenciais, cookies, cabeçalhos do Cloudflare Access, autorização de proxy, Set-Cookie e o cabeçalho CSRF X-EmDash-Request. Ele remove esses cabeçalhos de toda requisição em sandbox, inclusive em rotas legadas.

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

As configurações do plugin usam rotas privadas, formulários do Block Kit e ctx.settings. Configurações fornece o padrão completo de carregamento, validação, formulário e segredos criptografados.

As rotas que listam dados do plugin devem retornar o cursor de ctx.storage.<collection>.query(). Paginação do armazenamento mostra como passar um cursor e percorrer várias páginas sem exceder o máximo de 100 itens por página.

Proxy de API externa

Faça proxy de uma requisição para um serviço externo por meio de ctx.http (requer a capability 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 do WHATWG em buffer nos dois executores de sandbox. Métodos binários como arrayBuffer() e blob() preservam os bytes entre o Cloudflare Worker Loader e o Node/workerd. Os corpos de requisição e de resposta são limitados a 8 MiB de dados decodificados cada. Os destinos de redirecionamento são verificados antes de cada salto, e os cabeçalhos de credenciais são removidos quando um redirecionamento atravessa origens.

Chamando rotas a partir do Block Kit

Os plugins em sandbox não enviam código React para o painel de administração. Declare uma rota admin e retorne respostas do Block Kit. O EmDash envia as interações page_load, block_action e form_submit a essa rota privada com a URL e o cabeçalho CSRF corretos. Block Kit mostra o contrato de interação e uma rota completa.

Chamando rotas a partir de handlers de fila e de tarefas agendadas

Os handlers de eventos da plataforma (um consumidor de Cloudflare Queue, um handler scheduled() personalizado) não têm requisiçã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 requisiçã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 usado pelos handlers de requisição, então o armazenamento do plugin, os hooks e o acesso à mídia se comportam exatamente como durante uma requisição. Em adaptadores de banco de dados baseados em conexão (por exemplo, Postgres via Hyperdrive), o callback é executado sob uma conexão com escopo de evento, que é confirmada e fechada quando o callback retorna.

Chamando rotas externamente

As rotas públicas podem ser chamadas diretamente:

curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
  -H "Content-Type: application/json" \
  -d '{"event": "pageview"}'

As rotas privadas precisam de credenciais de sessão mais X-EmDash-Request: 1, ou de um token de API com o escopo admin. A requisição de servidor para servidor a seguir 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": "[email protected]"}'

Referência do contexto de rota

As interfaces a seguir resumem os valores portáveis disponíveis para um handler de rota em sandbox:

// 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
	bylines?: BylineAccess;        // when bylines: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
}

Os plugins nativos recebem um único argumento RouteContext que combina os dois; consulte Seu primeiro plugin nativo se você for por esse caminho.