プラグインは、管理 UI と外部連携向けに API ルートを公開できます。ルートは /_emdash/api/plugins/<slug>/<route-name> 配下にマウントされ(<slug> は emdash-plugin.jsonc のプラグインの slug フィールド — ランタイムでは ctx.plugin.id として公開)、フックが受け取るのと同じ PluginContext を持つサンドボックスランタイム内で実行されます。
このページはサンドボックス化されたプラグインを扱います。ネイティブプラグインは同じルートオプション、認証、URL レイアウトを使いますが、ハンドラは 1 つの結合されたコンテキストオブジェクトを受け取ります。そのシグネチャについては Your first native plugin を参照してください。
ルートの定義
ルートは src/plugin.ts のデフォルトエクスポートで宣言します。ルートが入力を検証する、または MCP ツールとして公開される場合は、ランタイム依存関係として zod を追加します。
pnpm add zod
次の例は送信リクエストを検証し、プラグインストレージを照会します。
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;
SandboxedPlugin アノテーションはルートとプラグインコンテキストの型を推論するため、パラメータにアノテーションは不要です。サンドボックス化されたルートハンドラは 2 つの引数 (routeCtx, ctx) を取ります。
routeCtxはリクエスト形のデータ{ input, request, requestMeta }を運びます。そのinputはunknownのままなので、使用前に検証してください。ctxはフック内で得るのと同じPluginContextです —ctx.storage、ctx.settings、ctx.kv、ctx.content、ctx.http、ctx.log。
インデックス付きコンテンツフィールドのフィルタ
content:read 能力を持つプラグインは、コレクションが indexed とマークしたカスタムフィールドを
フィルタできます。フィルタはデータベースで実行され、AND セマンティクスで結合されます。
const result = await ctx.content.list("items", {
where: {
fieldFilters: {
priority: { in: ["urgent", "high"] },
score: { gte: 80 },
resolved: false,
},
},
});
スカラー値は完全一致を使います。null 一致には null、完全一致値の集合には { in: [...] }、
範囲比較には gt、gte、lt、lte を使います。EmDash はインデックスされていないフィールドのフィルタ、
フィールド型に合わない値、およびクエリあたり 20 を超えるフィールドフィルタを拒否します。in フィルタは
最大 50 値を受け付け、すべての完全一致値、範囲境界、in メンバーは合わせてクエリあたり
50 オペランドの予算があります。null 一致はその予算を消費しません。
ルート URL
ルートは /_emdash/api/plugins/<slug>/<route-name> にマウントされます。ルート名にはネストしたパス用のスラッシュを含められます。
| 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 |
認証と CSRF
プラグインルートはデフォルトで認証されます。 ディスパッチャはハンドラを呼ぶ前にセッション(または admin スコープのトークン)を要求します。プライベートルートは後方互換のためデフォルトで plugins:manage 権限です。操作が既存のコンテンツ、メディア、スキーマ、設定の能力に属する場合は、permission をより狭い EmDash RBAC 権限に設定してください。
routes: {
create: {
permission: "content:create",
handler: async (routeCtx, ctx) => {
// Validate routeCtx.input, then create content through ctx.
},
},
},
プライベートルートは、宣言した権限をすべての HTTP メソッドで要求します。また、プラグインルートは任意のメソッドで同じハンドラを実行できるため、GET と HEAD を含む Cookie 認証リクエストには CSRF ヘッダ X-EmDash-Request: 1 も必要です。管理 UI はヘッダを自動送信します。トークン認証リクエストはヘッダ免除ですが、admin トークンスコープとルート権限は依然必要です。
ルートを認証から外すには、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 };
},
},
},
公開ルートの公開は、プラグインのレビュー済みアクセスの一部です。公開ルートを持つプラグインのインストールには同意が必要です。公開ルートの追加、またはプライベートルートの公開化は、プラグイン更新時に再度同意が必要です。
認証済みの呼び出し元
プライベートルートでは、routeCtx.user はリクエストを行う認証済みユーザーです — ハンドラ実行前に EmDash が解決・認可するため、ユーザー単位のロジック(ユーザー単位 API キー、OAuth 接続、プラグイン管理の設定)に信頼して使えます。
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 です(認証をスキップするため呼び出し元はバインドされません — 訪問者が管理セッションを持っていても同様)。また、ユーザーにバインドされていないトークン認証リクエスト(マシン用トークン)でも undefined です。形状は ctx.users が返す UserInfo と同じです:{ id, email, name, role, createdAt } — 機密フィールドはありません。
呼び出し元の同一性は users:read 能力とは別です。routeCtx.user は 誰が呼び出しているか を示し、プライベートルートでは常に利用可能です。一方 ctx.users は能力を必要とするユーザーディレクトリの 検索 です。
ルートを MCP ツールとして公開する
プラグインは、選択したプライベートルートを EmDash の MCP サーバー経由で明示的に公開できます。MCP 公開はルート一覧から推論されません。
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 はこれを <pluginId>__createEvent として公開します。参照されるルートはプライベートで、permission を宣言する必要があります。入力スキーマは必須、出力スキーマは任意です。削除、上書き、公開、課金、その他取り消しにくい操作を行うツールには destructive: true を設定します。
管理者は、名前、説明、ルート、権限、破壊的フラグをレビューしたうえで、プラグインの MCP ツールを別途有効化する必要があります。ツール呼び出しには、ルート権限と、mcp:tools トークンスコープまたは mcp:tools:<pluginId> の両方が必要です。
MCP ツールは response: "raw" のルートを参照できません。MCP ツールは JSON ルート契約を使います。
リクエストボディ
request 宣言のないルートは、元の入力動作を保ちます。EmDash は POST、PUT、PATCH の JSON リクエスト
ボディと、GET、HEAD、DELETE のクエリパラメータを解析します。解析された値はサンドボックス化されたハンドラに
routeCtx.input: unknown として届きます。
ルートが別のボディ形式や特定のバイト上限を必要とするときは request.body を宣言します。利用可能なモードは
none、json、text、bytes、form-data です。リクエストボディはバッファされます。
デフォルト上限は 1 MiB で、ルートは maxBytes を最大 8 MiB まで上げられます。
宣言したボディモードから入力型を推論するには pluginRoute() を使います。ヘルパーはランタイムで引数を
そのまま返します。
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;
body: "none" のとき、routeCtx.input は解析済みクエリ文字列レコードです。json 宣言は入力型を
unknown のままにするので、使用前に検証してください。text 宣言は文字列を、bytes は
Uint8Array を生成します。
form-data は multipart/form-data と application/x-www-form-urlencoded を受け付けます。順序付きの
entries 配列を生成します。テキストエントリは { name, kind: "text", value }、ファイルエントリは
{ name, kind: "file", filename, contentType, bytes } を含みます。EmDash は最大 100 パート、パートあたり
1 MiB、ファイル名は最大 255 UTF-8 バイトを受け付けます。ファイル名に制御文字やパス区切りは使えません。
エンコードされたリクエスト全体もルートのボディ上限に収まる必要があります。
フィールドを読む、または副作用を行う前に、解析済み値を検証してください。無効入力が想定される呼び出し元エラーのときは
safeParse を使います。これにより、無効入力を内部例外にせず、安定した JSON 結果を返せます。
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 };
},
},
},
クエリ文字列入力(GET/HEAD/DELETE)
ボディのないメソッドにはリクエストボディがないため、入力は URL のクエリ文字列から来ます。値はすべて文字列です。繰り返されたキーは配列になり、?tag=a&tag=b は { tag: ["a", "b"] } になります。単一の ?tag=a は { tag: "a" } のままです。数値など非文字列には z.coerce を使います。
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;
// ...
},
},
},
JSON 戻り値
ルートは response: "raw" を宣言しない限り JSON 応答契約を使います。JSON シリアライズ可能な値を
返してください。ディスパッチャは EmDash の標準エンベロープ
({ success: true, data: <your value> })に包み、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] }
エラー
サンドボックス化されたルートが完了できないときは throw します。EmDash は例外をログし、ROUTE_ERROR を返します。投げたメッセージがその応答に含まれることがあるため、例外メッセージに認証情報、個人データ、内部パス、スタックトレースを入れないでください。
handler: async (_routeCtx, ctx) => {
try {
return await refreshRemoteIndex(ctx);
} catch {
ctx.log.error("Remote index refresh failed");
throw new Error("Remote index refresh failed");
}
},
サンドボックス化されたプラグインコードは、Response を throw して任意の HTTP ステータスを選べません。Response はすべてのサンドボックスランナーの境界を構造化エラーとして越えません。EmDash はハンドラ実行前に、認証、認可、CSRF、欠落ルートの失敗にステータスを割り当てます。想定される検証・ドメイン結果には JSON 結果を返し、予期しない失敗に例外を予約してください。
JSON として返した想定エラーは、ルートの成功 HTTP 応答を使い、EmDash の外側の { success: true, data: ... } エンベロープ内に現れます。クライアントが区別できるよう、安定したアプリケーションレベルのコードを含めてください。
HTTP メソッド
ルート名は 1 つのハンドラを選びます。どの HTTP メソッドがそれを呼び出せるかを制限するには methods を宣言します。
リクエストメソッドが宣言されていないとき、EmDash はハンドラを呼ぶ前に Allow ヘッダ付きの
405 Method Not Allowed を返します。
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 };
}
},
},
},
methods のないルートは互換のためメソッド非依存のままです。レガシールートではミューテーション前に
routeCtx.request.method を確認するか、methods を追加してホストに制限を強制させてください。
生レスポンス
ルートがカスタムステータスと安全な応答ヘッダ付きの未ラップのテキストまたはバイトを返す必要があるときは、
response: "raw" を宣言します。emdash/plugin の pluginResponse() を返してください。WHATWG の Response は
サンドボックス境界を越えません。
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;
応答ボディは { kind: "text", value: string } または
{ kind: "bytes", value: Uint8Array } で、最大 8 MiB までバッファされます。生レスポンスは
Accept-Ranges、Content-Disposition、Content-Encoding、Content-Language、Content-Range、
Content-Type、ETag、Last-Modified、Location、Retry-After を設定できます。ホストはそれ以外の
プラグイン提供ヘッダをすべて除去します。ホストは
X-Content-Type-Options: nosniff、サンドボックス化されたドキュメントのコンテンツセキュリティポリシー、
Referrer-Policy: no-referrer を追加します。ルートの cacheControl は成功した公開
GET と HEAD 応答にのみ適用されます。他の応答は private, no-store を使います。
生ルートはアクティブな同一オリジンコンテンツを配信できません。EmDash は HTML、JavaScript と
ECMAScript、XHTML、SVG、XML、CSS、WebAssembly、multipart/related、
multipart/x-mixed-replace メディアタイプを拒否します。応答がアクティブなブラウザコンテンツを実行する必要があるときは、
ネイティブプラグインまたは別オリジンを使ってください。
リクエストへのアクセス
routeCtx.request は SandboxedRequest です。インプロセスと isolate 内で同一に振る舞うポータブルな { url, method, headers } レコードです。headers は小文字のヘッダ名をキーとする Record<string, string> です — 小文字名でインデックスするか、Object.entries で反復してください。url は文字列なので、new URL(request.url) でクエリパラメータを解析します。利用可能なとき、routeCtx.requestMeta はプラットフォーム間で正規化された IP、ユーザーエージェント、地理データを運びます。
request 宣言のあるルートでは、request.headers 内の名前だけがハンドラに届きます。EmDash は
認証情報、Cookie、Cloudflare Access ヘッダ、プロキシ認可、Set-Cookie、
X-EmDash-Request CSRF ヘッダの宣言を拒否します。レガシールートを含むすべてのサンドボックス化された
リクエストから、それらのヘッダを除去します。
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" };
},
よくあるパターン
設定とページネーションされたデータ
プラグイン設定はプライベートルート、Block Kit フォーム、ctx.settings を使います。Settings に読み込み、検証、フォーム、暗号化シークレットの完全なパターンがあります。
プラグインデータを一覧するルートは、ctx.storage.<collection>.query() からのカーソルを返すべきです。Storage pagination は、カーソルを渡し、ページ最大 100 件を超えずに複数ページを処理する方法を示します。
外部 API プロキシ
ctx.http 経由で外部サービスへのリクエストをプロキシします(network:request 能力と 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() は両方のサンドボックスランナーでバッファされた WHATWG Response を返します。arrayBuffer() や blob() などのバイナリメソッドは、Cloudflare Worker Loader と Node/workerd でバイトを保持します。リクエストとレスポンスのボディはそれぞれデコード後 8 MiB に制限されます。リダイレクト先は各ホップ前に検査され、リダイレクトがオリジンを跨ぐとき認証ヘッダは除去されます。
Block Kit からルートを呼ぶ
サンドボックス化されたプラグインは管理画面に React コードを送りません。admin ルートを宣言し、Block Kit 応答を返します。EmDash は正しい URL と CSRF ヘッダで、page_load、block_action、form_submit インタラクションをそのプライベートルートに送ります。Block Kit にインタラクション契約と完全なルートがあります。
キューとスケジュールハンドラからルートを呼ぶ
プラットフォームイベントハンドラ(Cloudflare Queue コンシューマ、カスタム scheduled() ハンドラ)には HTTP リクエストがなく、したがって locals.emdash もありません。emdash/middleware の withEmDashRuntime() を使い、ランタイムを直接取得して、リクエストなしでプラグインルートを呼び出します。
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();
}
});
},
};
これはリクエストハンドラが使うのと同じキャッシュ済みランタイムを解決するため、プラグインストレージ、フック、メディアアクセスはリクエスト中とまったく同じように振る舞います。接続ベースのデータベースアダプタ(例: Hyperdrive 経由の Postgres)では、コールバックはイベントスコープの接続下で実行され、戻り時にコミットされて閉じられます。
外部からルートを呼ぶ
公開ルートは直接呼び出せます。
curl -X POST https://your-site.com/_emdash/api/plugins/forms/track \
-H "Content-Type: application/json" \
-d '{"event": "pageview"}'
プライベートルートにはセッション認証情報と X-EmDash-Request: 1、または admin スコープの API トークンが必要です。次のサーバー間リクエストはトークンを使います。
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"}'
ルートコンテキストのリファレンス
次のインターフェースは、サンドボックス化されたルートハンドラが利用できるポータブルな値を要約します。
// 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
}
ネイティブプラグインは 2 つを結合した単一の RouteContext 引数を受け取ります — その道を行く場合は Creating native plugins を参照してください。