Routes d’API

Sur cette page

Les plugins peuvent exposer des routes d’API pour leur UI d’administration et des intégrations externes. Les routes sont montées sous /_emdash/api/plugins/<slug>/<route-name> (le <slug> est le champ slug du plugin dans emdash-plugin.jsonc — exposé à l’exécution comme ctx.plugin.id) et s’exécutent dans le runtime sandbox avec le même PluginContext que reçoivent les hooks.

Cette page couvre les plugins isolés (sandboxed). Les plugins natifs utilisent les mêmes options de route, authentification et disposition d’URL, mais leurs handlers reçoivent un seul objet de contexte combiné. Voir Your first native plugin pour cette signature.

Définir des routes

Déclarez les routes dans l’export par défaut de src/plugin.ts. Ajoutez zod comme dépendance runtime lorsqu’une route valide l’entrée ou est exposée comme outil MCP :

pnpm add zod

L’exemple suivant valide une requête de soumissions et interroge le stockage du 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;

L’annotation SandboxedPlugin infère les types de contexte de route et de plugin, donc les paramètres n’ont pas besoin d’annotations. Les handlers de routes sandboxed prennent deux arguments : (routeCtx, ctx).

  • routeCtx transporte des données en forme de requête : { input, request, requestMeta }. Son input reste unknown, donc validez-le avant utilisation.
  • ctx est le même PluginContext que vous obtenez dans les hooks — ctx.storage, ctx.settings, ctx.kv, ctx.content, ctx.http et ctx.log.

Filtrer les champs de contenu indexés

Les plugins avec la capacité content:read peuvent filtrer des champs personnalisés qu’une collection marque comme indexed. Les filtres s’exécutent dans la base de données et se combinent avec une sémantique AND :

const result = await ctx.content.list("items", {
	where: {
		fieldFilters: {
			priority: { in: ["urgent", "high"] },
			score: { gte: 80 },
			resolved: false,
		},
	},
});

Les valeurs scalaires utilisent une correspondance exacte. Utilisez null pour une correspondance nulle, { in: [...] } pour un ensemble de valeurs exactes, ou gt, gte, lt et lte pour des comparaisons de plage. EmDash rejette les filtres pour les champs qui ne sont pas indexés, les valeurs qui ne correspondent pas au type de champ, et plus de 20 filtres de champ par requête. Un filtre in accepte au plus 50 valeurs, et toutes les valeurs exactes, bornes de plage et membres in ensemble ont un budget de 50 opérandes par requête. Les correspondances nulles ne consomment pas ce budget.

URLs des routes

Les routes se montent sur /_emdash/api/plugins/<slug>/<route-name>. Les noms de route peuvent inclure des barres obliques pour des chemins imbriqués.

Plugin idRoute nameURL
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

Authentification et CSRF

Les routes de plugin sont authentifiées par défaut. Le dispatcher exige une session (ou un jeton avec la portée admin) avant d’appeler votre handler. Les routes privées utilisent par défaut la permission plugins:manage pour la rétrocompatibilité. Définissez permission sur une permission RBAC EmDash plus étroite lorsque l’opération appartient à une capacité existante de contenu, médias, schéma ou paramètres :

routes: {
	create: {
		permission: "content:create",
		handler: async (routeCtx, ctx) => {
			// Validate routeCtx.input, then create content through ctx.
		},
	},
},

Les routes privées exigent leur permission déclarée pour chaque méthode HTTP. Elles exigent aussi l’en-tête CSRF X-EmDash-Request: 1 pour les requêtes authentifiées par cookie, y compris GET et HEAD, car une route de plugin peut exécuter le même handler pour n’importe quelle méthode. L’UI d’administration envoie l’en-tête automatiquement. Les requêtes authentifiées par jeton sont exemptées de l’en-tête mais ont toujours besoin de la portée de jeton admin et de la permission de la route.

Pour exclure une route de l’authentification, marquez-la 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 };
		},
	},
},

L’exposition des routes publiques fait partie de l’accès examiné du plugin. Installer un plugin avec des routes publiques nécessite un consentement. Ajouter une route publique, ou passer une route privée en publique, nécessite à nouveau un consentement lors de la mise à jour du plugin.

L’appelant authentifié

Sur les routes privées, routeCtx.user est l’utilisateur authentifié qui fait la requête — résolu et autorisé par EmDash avant l’exécution de votre handler, vous pouvez donc lui faire confiance pour une logique par utilisateur (clés API par utilisateur, connexions OAuth, préférences gérées par le 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 est undefined sur les routes publiques (elles sautent l’auth, donc aucun appelant n’est lié — même lorsque le visiteur a une session d’administration) et pour les requêtes authentifiées par jeton où le jeton n’est pas lié à un utilisateur (jetons machine). La forme correspond au UserInfo renvoyé par ctx.users : { id, email, name, role, createdAt } — sans champs sensibles.

Notez que l’identité de l’appelant est distincte de la capacité users:read : routeCtx.user vous dit qui appelle et est toujours disponible sur les routes privées, tandis que ctx.users est une recherche dans l’annuaire utilisateurs qui exige la capacité.

Exposer une route comme outil MCP

Les plugins peuvent exposer explicitement des routes privées sélectionnées via le serveur MCP d’EmDash. L’exposition MCP n’est jamais déduite de la liste des routes :

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;

EmDash l’expose comme <pluginId>__createEvent. La route référencée doit être privée et déclarer permission. Les schémas d’entrée sont requis ; les schémas de sortie sont optionnels. Définissez destructive: true pour les outils qui suppriment, écrasent, publient, facturent ou effectuent autrement une action difficile à annuler.

Un administrateur doit activer séparément les outils MCP d’un plugin après avoir examiné leurs noms, descriptions, routes, permissions et indicateurs destructifs. L’appel de l’outil exige alors à la fois la permission de la route et soit la portée de jeton mcp:tools, soit mcp:tools:<pluginId>.

Un outil MCP ne peut pas référencer une route avec response: "raw". Les outils MCP utilisent le contrat de route JSON.

Corps de requête

Les routes sans déclaration request conservent le comportement d’entrée d’origine. EmDash analyse les corps de requête JSON pour POST, PUT et PATCH, et les paramètres de requête pour GET, HEAD et DELETE. La valeur analysée atteint un handler sandboxed comme routeCtx.input: unknown.

Déclarez request.body lorsque la route a besoin d’un autre format de corps ou d’une limite d’octets spécifique. Les modes disponibles sont none, json, text, bytes et form-data. Les corps de requête sont mis en tampon. Le maximum par défaut est 1 Mio, et une route peut élever maxBytes jusqu’à 8 Mio au plus.

Utilisez pluginRoute() pour inférer le type d’entrée à partir du mode de corps déclaré. Le helper renvoie son argument inchangé à l’exécution :

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;

Pour body: "none", routeCtx.input est l’enregistrement de chaîne de requête analysé. Une déclaration json garde le type d’entrée comme unknown, donc validez-le avant utilisation. Une déclaration text produit une chaîne, et bytes produit un Uint8Array.

form-data accepte multipart/form-data et application/x-www-form-urlencoded. Il produit un tableau entries ordonné. Les entrées texte contiennent { name, kind: "text", value } ; les entrées fichier contiennent { name, kind: "file", filename, contentType, bytes }. EmDash accepte au plus 100 parties, 1 Mio par partie, et des noms de fichier jusqu’à 255 octets UTF-8. Les noms de fichier ne peuvent pas contenir de caractères de contrôle ni de séparateurs de chemin. La requête encodée totale doit aussi tenir dans la limite de corps de la route.

Validez les valeurs analysées avant de lire des champs ou d’effectuer des effets de bord. Utilisez safeParse lorsque une entrée invalide est une erreur d’appelant attendue. Cela permet à la route de renvoyer un résultat JSON stable au lieu de transformer une entrée invalide en exception interne :

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 };
		},
	},
},

Entrée de chaîne de requête (GET/HEAD/DELETE)

Les méthodes sans corps n’ont pas de corps de requête, donc leur entrée vient de la chaîne de requête de l’URL. Chaque valeur est une chaîne. Les clés répétées deviennent des tableaux, donc ?tag=a&tag=b devient { tag: ["a", "b"] } ; un seul ?tag=a reste { tag: "a" }. Utilisez z.coerce pour les nombres et autres valeurs non chaînes :

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;
			// ...
		},
	},
},

Valeurs de retour JSON

Les routes utilisent le contrat de réponse JSON sauf si elles déclarent response: "raw". Renvoyez toute valeur sérialisable en JSON. Le dispatcher l’enveloppe dans l’enveloppe standard d’EmDash ({ success: true, data: <your value> }) et la sert en 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] }

Erreurs

Lancez lorsque une route sandboxed ne peut pas se terminer. EmDash journalise l’exception et renvoie un ROUTE_ERROR. Le message lancé peut être inclus dans cette réponse, donc ne mettez jamais d’identifiants, de données personnelles, de chemins internes ou de traces de pile dans un message d’exception :

handler: async (_routeCtx, ctx) => {
	try {
		return await refreshRemoteIndex(ctx);
	} catch {
		ctx.log.error("Remote index refresh failed");
		throw new Error("Remote index refresh failed");
	}
},

Le code de plugin sandboxed ne peut pas choisir un statut HTTP arbitraire en lançant une Response ; une Response ne traverse pas la frontière de chaque exécuteur sandbox comme erreur structurée. EmDash attribue des statuts aux échecs d’authentification, d’autorisation, CSRF et de route manquante avant l’exécution du handler. Renvoyez un résultat JSON pour les résultats attendus de validation et de domaine, et réservez les exceptions aux échecs inattendus.

Une erreur attendue renvoyée en JSON utilise toujours la réponse HTTP réussie de la route et apparaît dans l’enveloppe externe { success: true, data: ... } d’EmDash. Incluez un code stable au niveau application pour que les clients puissent distinguer ce résultat.

Méthodes HTTP

Le nom de la route sélectionne un handler. Déclarez methods pour restreindre quelles méthodes HTTP peuvent l’invoquer. EmDash renvoie 405 Method Not Allowed avec un en-tête Allow avant d’appeler le handler lorsque la méthode de la requête n’est pas déclarée :

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 };
			}
		},
	},
},

Les routes sans methods restent agnostiques à la méthode pour la compatibilité. Vérifiez routeCtx.request.method dans une route legacy avant d’effectuer une mutation, ou ajoutez methods pour que l’hôte applique la restriction.

Réponses brutes

Déclarez response: "raw" lorsqu’une route doit renvoyer du texte ou des octets non enveloppés avec un statut personnalisé et des en-têtes de réponse sûrs. Renvoyez pluginResponse() depuis emdash/plugin ; une Response WHATWG ne traverse pas la frontière du 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;

Le corps de la réponse est { kind: "text", value: string } ou { kind: "bytes", value: Uint8Array } et est mis en tampon jusqu’à 8 Mio. Les réponses brutes peuvent définir Accept-Ranges, Content-Disposition, Content-Encoding, Content-Language, Content-Range, Content-Type, ETag, Last-Modified, Location et Retry-After ; l’hôte supprime tout autre en-tête fourni par le plugin. Il ajoute X-Content-Type-Options: nosniff, une politique de sécurité de contenu de document isolé, et Referrer-Policy: no-referrer. Il applique le cacheControl de la route uniquement aux réponses publiques réussies GET et HEAD. Les autres réponses utilisent private, no-store.

Les routes brutes ne peuvent pas servir de contenu actif same-origin. EmDash rejette HTML, JavaScript et ECMAScript, XHTML, SVG, XML, CSS, WebAssembly, multipart/related et les types de médias multipart/x-mixed-replace. Utilisez un plugin natif ou une origine séparée lorsque la réponse doit exécuter du contenu navigateur actif.

Accéder à la requête

routeCtx.request est un SandboxedRequest : un enregistrement portable { url, method, headers } qui se comporte de façon identique en processus et dans un isolate. headers est un Record<string, string> indexé par nom d’en-tête en minuscules — indexez par le nom en minuscules, ou itérez avec Object.entries. url est une chaîne, donc new URL(request.url) analyse les paramètres de requête. routeCtx.requestMeta transporte l’IP, l’user agent et les données géo normalisées entre plateformes lorsqu’elles sont disponibles.

Pour une route avec une déclaration request, seuls les noms dans request.headers atteignent le handler. EmDash rejette les déclarations pour les identifiants, cookies, en-têtes Cloudflare Access, autorisation proxy, Set-Cookie et l’en-tête CSRF X-EmDash-Request. Il retire ces en-têtes de chaque requête sandboxed, y compris les routes 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" };
},

Modèles courants

Paramètres et données paginées

Les paramètres de plugin utilisent des routes privées, des formulaires Block Kit et ctx.settings. Settings fournit le modèle complet de chargement, validation, formulaire et secrets chiffrés.

Les routes qui listent des données de plugin doivent renvoyer le curseur de ctx.storage.<collection>.query(). Storage pagination montre comment passer un curseur et vider plusieurs pages sans dépasser le maximum de 100 éléments par page.

Proxy d’API externe

Proxifiez une requête vers un service externe via ctx.http (nécessite la capacité network:request et une entrée dans 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() renvoie une Response WHATWG mise en tampon dans les deux exécuteurs sandbox. Les méthodes binaires comme arrayBuffer() et blob() préservent les octets sur Cloudflare Worker Loader et Node/workerd. Les corps de requête et de réponse sont chacun limités à 8 Mio de données décodées. Les cibles de redirection sont vérifiées avant chaque saut, et les en-têtes d’identifiants sont retirés lorsqu’une redirection croise les origines.

Appeler des routes depuis Block Kit

Les plugins sandboxed n’envoient pas de code React à l’administration. Déclarez une route admin et renvoyez des réponses Block Kit. EmDash envoie les interactions page_load, block_action et form_submit à cette route privée avec l’URL et l’en-tête CSRF corrects. Block Kit montre le contrat d’interaction et une route complète.

Appeler des routes depuis des handlers de file et planifiés

Les handlers d’événements de plateforme (un consommateur Cloudflare Queue, un handler scheduled() personnalisé) n’ont pas de requête HTTP et donc pas de locals.emdash. Utilisez withEmDashRuntime() depuis emdash/middleware pour obtenir le runtime directement et invoquer une route de plugin sans requête :

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();
			}
		});
	},
};

Cela résout le même runtime mis en cache que les handlers de requête, donc le stockage du plugin, les hooks et l’accès aux médias se comportent exactement comme pendant une requête. Sur les adaptateurs de base de données basés sur la connexion (par ex. Postgres via Hyperdrive), le callback s’exécute sous une connexion à portée d’événement qui est validée et fermée à son retour.

Appeler des routes en externe

Les routes publiques sont appelables directement :

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

Les routes privées ont besoin d’identifiants de session plus X-EmDash-Request: 1, ou d’un jeton d’API avec la portée admin. La requête serveur à serveur suivante utilise un jeton :

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"}'

Référence du contexte de route

Les interfaces suivantes résument les valeurs portables disponibles pour un handler de route 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
}

Les plugins natifs reçoivent un seul argument RouteContext qui combine les deux — voir Creating native plugins si vous prenez cette voie.