Routes d’API

Sur cette page

Les plugins peuvent exposer des routes d’API pour leur interface d’administration et pour 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 sous la forme ctx.plugin.id) et s’exécutent dans l’environnement d’exécution du bac à sable avec le même PluginContext que celui reçu par les hooks.

Cette page traite des plugins sandboxés. Les plugins natifs utilisent les mêmes options de route, la même authentification et la même structure d’URL, mais leurs gestionnaires reçoivent un unique objet de contexte combiné. Consultez Votre premier plugin natif 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 d’exécution 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 déduit les types de la route et du contexte du plugin, les paramètres n’ont donc pas besoin d’annotations. Les gestionnaires de route sandboxés prennent deux arguments : (routeCtx, ctx).

  • routeCtx contient les données propres à la requête : { input, request, requestMeta }. Son input reste unknown ; validez-le donc avant de l’utiliser.
  • ctx est le même PluginContext que celui que vous recevez 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 disposant de la capability content:read peuvent filtrer les champs personnalisés qu’une collection marque comme indexed. Les filtres s’exécutent dans la base de données et se combinent avec la 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 la correspondance avec null, { in: [...] } pour un ensemble de valeurs exactes, ou gt, gte, lt et lte pour les comparaisons de plage. EmDash rejette les filtres portant sur des champs non indexés, les valeurs qui ne correspondent pas au type du champ et plus de 20 filtres de champ par requête. Un filtre in accepte au plus 50 valeurs, et l’ensemble des valeurs exactes, des bornes de plage et des membres de in dispose d’un budget de 50 opérandes par requête. Les correspondances avec null ne consomment pas ce budget.

URL des routes

Les routes sont montées sur /_emdash/api/plugins/<slug>/<route-name>. Les noms de route peuvent contenir des barres obliques pour créer des chemins imbriqués.

ID du pluginNom de la routeURL
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 répartiteur exige une session (ou un jeton avec le scope admin) avant d’appeler votre gestionnaire. Pour des raisons de rétrocompatibilité, les routes privées utilisent par défaut la permission plugins:manage. Définissez permission sur une permission RBAC EmDash plus restreinte lorsque l’opération relève d’une capability existante de contenu, de médias, de schéma ou de réglages :

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 également 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 gestionnaire pour n’importe quelle méthode. L’interface d’administration envoie cet en-tête automatiquement. Les requêtes authentifiées par jeton en sont dispensées, mais ont tout de même besoin du scope 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 de routes publiques fait partie de l’accès examiné d’un plugin. L’installation d’un plugin comportant des routes publiques nécessite un consentement. L’ajout d’une route publique, ou le passage d’une route privée en public, nécessite un nouveau consentement lors de la mise à jour du plugin.

L’appelant authentifié

Sur les routes privées, routeCtx.user est l’utilisateur authentifié qui effectue la requête — résolu et autorisé par EmDash avant l’exécution de votre gestionnaire, vous pouvez donc lui faire confiance pour la logique par utilisateur (clés d’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 vaut undefined sur les routes publiques (elles contournent l’authentification, donc aucun appelant n’est associé — même lorsque le visiteur possède par hasard une session d’administrateur) et pour les requêtes authentifiées par jeton dont le jeton n’est pas lié à un utilisateur (jetons machine). Sa forme correspond à celle de UserInfo renvoyé par ctx.users : { id, email, name, role, createdAt } — aucun champ sensible.

Notez que l’identité de l’appelant est distincte de la capability users:read : routeCtx.user vous indique qui appelle et est toujours disponible sur les routes privées, alors que ctx.users est une recherche dans l’annuaire des utilisateurs qui nécessite la capability.

Exposer une route comme outil MCP

Les plugins peuvent exposer explicitement certaines routes privé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 sous la forme <pluginId>__createEvent. La route référencée doit être privée et déclarer permission. Les schémas d’entrée sont obligatoires ; les schémas de sortie sont facultatifs. 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 nécessite alors à la fois la permission de la route et soit le scope 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 parvient à un gestionnaire sandboxé sous la forme routeCtx.input: unknown.

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

Utilisez pluginRoute() pour déduire le type d’entrée à partir du mode de corps déclaré. À l’exécution, l’assistant renvoie son argument inchangé :

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;

Avec body: "none", routeCtx.input est l’enregistrement de la chaîne de requête analysée. Une déclaration json conserve le type d’entrée unknown ; validez-le donc avant de l’utiliser. 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 de texte contiennent { name, kind: "text", value } ; les entrées de fichier contiennent { name, kind: "file", filename, contentType, bytes }. EmDash accepte au plus 100 parties, 1 Mio par partie et des noms de fichier allant jusqu’à 255 octets UTF-8. Les noms de fichier ne peuvent contenir ni caractères de contrôle ni séparateurs de chemin. La requête encodée complète doit également 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 lorsqu’une entrée invalide constitue une erreur attendue de la part de l’appelant. La route peut ainsi renvoyer un résultat JSON stable au lieu de transformer l’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 par chaîne de requête (GET/HEAD/DELETE)

Les méthodes sans corps n’ont pas de corps de requête ; leur entrée provient donc 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, de sorte que ?tag=a&tag=b devient { tag: ["a", "b"] } ; un seul ?tag=a reste { tag: "a" }. Utilisez z.coerce pour les nombres et les autres valeurs qui ne sont pas des 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 n’importe quelle valeur sérialisable en JSON. Le répartiteur 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

Levez une exception lorsqu’une route sandboxée ne peut pas aboutir. EmDash journalise l’exception et renvoie un ROUTE_ERROR. Le message levé peut être inclus dans cette réponse ; ne placez donc jamais d’identifiants, de données personnelles, de chemins internes ni 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 d’un plugin sandboxé ne peut pas choisir un statut HTTP arbitraire en levant une Response ; une Response ne franchit pas, sous forme d’erreur structurée, la frontière de tous les exécuteurs de bac à sable. EmDash attribue les statuts des échecs d’authentification, d’autorisation, de CSRF et de route introuvable avant l’exécution du gestionnaire. Renvoyez un résultat JSON pour les issues attendues 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 à l’intérieur de l’enveloppe externe { success: true, data: ... } d’EmDash. Incluez un code stable au niveau de l’application afin que les clients puissent distinguer cette issue.

Méthodes HTTP

Le nom de la route sélectionne un seul gestionnaire. Déclarez methods pour restreindre les méthodes HTTP autorisées à l’invoquer. EmDash renvoie 405 Method Not Allowed avec un en-tête Allow avant d’appeler le gestionnaire 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 indifférentes à la méthode par souci de compatibilité. Vérifiez routeCtx.request.method dans une route héritée 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 franchit pas la frontière du bac à sable :

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 il est mis en mémoire 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é du contenu pour document sandboxé et Referrer-Policy: no-referrer. Il n’applique le cacheControl de la route qu’aux réponses GET et HEAD publiques réussies. Les autres réponses utilisent private, no-store.

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

Accéder à la requête

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

Pour une route avec déclaration request, seuls les noms listés dans request.headers parviennent au gestionnaire. EmDash rejette les déclarations portant sur les identifiants, les cookies, les en-têtes Cloudflare Access, l’autorisation de proxy, Set-Cookie et l’en-tête CSRF X-EmDash-Request. Il supprime ces en-têtes de toute requête sandboxée, y compris pour les routes héritées.

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

Schémas courants

Réglages et données paginées

Les réglages du plugin utilisent des routes privées, des formulaires Block Kit et ctx.settings. Réglages fournit le schéma complet de chargement, de validation, de formulaire et de secrets chiffrés.

Les routes qui listent les données du plugin doivent renvoyer le curseur provenant de ctx.storage.<collection>.query(). Pagination du stockage montre comment transmettre un curseur et parcourir plusieurs pages sans dépasser le maximum de 100 éléments par page.

Proxy d’API externe

Relayez une requête vers un service externe via ctx.http (nécessite la capability 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 mémoire tampon dans les deux exécuteurs de bac à sable. Les méthodes binaires telles que arrayBuffer() et blob() préservent les octets entre 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’identification sont supprimés lorsqu’une redirection change d’origine.

Appeler des routes depuis Block Kit

Les plugins sandboxés n’embarquent pas de code React dans 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 présente le contrat d’interaction et une route complète.

Appeler des routes depuis des gestionnaires de file d’attente et de tâches planifiées

Les gestionnaires d’événements de plateforme (un consommateur Cloudflare Queue, un gestionnaire scheduled() personnalisé) n’ont pas de requête HTTP et donc pas de locals.emdash. Utilisez withEmDashRuntime() depuis emdash/middleware pour obtenir directement l’environnement d’exécution 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 environnement d’exécution mis en cache que celui utilisé par les gestionnaires de requêtes ; le stockage du plugin, les hooks et l’accès aux médias se comportent donc exactement comme pendant une requête. Sur les adaptateurs de base de données reposant sur une connexion (par exemple Postgres via Hyperdrive), le callback s’exécute sous une connexion limitée à l’événement, validée et fermée lorsqu’il retourne.

Appeler des routes depuis l’extérieur

Les routes publiques peuvent être appelées 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 nécessitent des identifiants de session plus X-EmDash-Request: 1, ou un jeton d’API avec le scope 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": "[email protected]"}'

Référence du contexte de route

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

Les plugins natifs reçoivent un unique argument RouteContext qui combine les deux ; consultez Votre premier plugin natif si vous empruntez cette voie.