Files
dckc 4b4ef8fea6 capsite: potluck, a fresh EmDash site (cloudflare:blog out of the box)
pnpm create emdash@latest potluck --pm pnpm --install --yes, per
https://docs.emdashcms.com/ ("Create your first EmDash site"); the
--yes defaults are cloudflare + blog. Replaces the old madmode/
starter (never committed).

Generated-by: create-emdash 1.2.0, run by Claude Opus 5.5
2026-10-07 19:57:43 -05:00

246 lines
12 KiB
Markdown

# API routes and MCP tools
Sandboxed plugin routes are mounted at `/_emdash/api/plugins/<slug>/<route-name>`. Define them in the typed default export from `src/plugin.ts`.
## Define a route
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
const submissionInput = z.object({
formId: z.string().min(1),
limit: z.coerce.number().int().min(1).max(100).default(50),
});
const plugin: SandboxedPlugin = {
routes: {
submissions: {
permission: "content:read",
handler: async (routeCtx, ctx) => {
const parsed = submissionInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_INPUT" };
const result = await ctx.storage.submissions.query({
where: { formId: parsed.data.formId },
limit: parsed.data.limit,
});
return { ok: true, ...result };
},
},
},
};
export default plugin;
```
The handler receives two arguments:
```typescript
interface SandboxedRouteContext {
input: unknown;
request: {
url: string;
method: string;
headers: Record<string, string>;
};
requestMeta?: {
ip: string | null;
userAgent: string | null;
referer: string | null;
geo: { country: string | null; region: string | null; city: string | null } | null;
};
user?: UserInfo;
}
```
`routeCtx.request` is a serialized record in both sandbox runners, not a WHATWG `Request`. Header keys are lowercased. Read parsed request data from `routeCtx.input`; the body has already been consumed by the host.
The published authoring type currently declares `requestMeta` as `unknown`, although both runners send the normalized shape shown above. Narrow it before reading fields when TypeScript cannot infer the shape.
## Input sources and validation
Undeclared routes keep the original input contract: the host parses JSON for `POST`, `PUT`, and
`PATCH`, and parses the query string for `GET`, `HEAD`, and `DELETE`. Repeated query keys become
arrays. Validate the resulting `unknown` value inside the handler.
Declare `request.body` as `none`, `json`, `text`, `bytes`, or `form-data` to select a parser. Bodies
are buffered with a 1 MiB default maximum; `maxBytes` can raise the limit to 8 MiB. Use
`pluginRoute()` from `emdash/plugin` to infer `PluginRouteQuery`, `string`, `Uint8Array`, or
`PluginFormData` from the mode. JSON remains `unknown` for validation.
Form data accepts multipart and URL-encoded requests. The multipart limits are 100 parts, 1 MiB per
part, and 255 UTF-8 bytes for a safe filename. The total request must also fit the route body limit.
Declare request header names explicitly in `request.headers`. Only those safe headers reach the
handler. Credentials, cookies, Cloudflare Access headers, proxy authorization, `Set-Cookie`, and
the EmDash CSRF header cannot be declared and never cross the sandbox boundary.
The plugin CLI probe currently does not retain a route entry's `input` schema, so do not rely on route-level Zod validation for a built sandboxed plugin. An MCP tool still requires its own Zod input schema.
## Authentication, permission, and CSRF
Routes are private unless they set `public: true`.
A private route requires:
- an authenticated session, or a token with the `admin` scope;
- the route's declared EmDash RBAC `permission`, defaulting to `plugins:manage`;
- `X-EmDash-Request: 1` for cookie-authenticated calls, for every HTTP method.
The host resolves these checks before invoking the plugin. `routeCtx.user` then contains the authenticated caller for a user-bound request:
```typescript
interface UserInfo {
id: string;
email: string;
name: string | null;
role: number;
createdAt: string;
}
```
Caller identity is not gated by `users:read`; it identifies the current authorized caller. `ctx.users` is a directory lookup and does require `users:read`. `routeCtx.user` is absent on public routes and on machine-token calls without a bound user.
A public route skips authentication, permission, and token-scope checks. It is internet-facing, so validate input, check the intended HTTP method, and verify webhook signatures or shared tokens where applicable.
Public exposure is reviewed during installation. Adding a public route or changing a private route
to public requires approval again on update.
## HTTP methods
The route name selects one handler. Declare `methods` to have the host reject other methods with
`405 Method Not Allowed` and an `Allow` header before the handler runs:
```typescript
methods: ["POST"],
handler: async (routeCtx, ctx) => {
// Validate input, then mutate.
},
```
Routes without `methods` remain method-agnostic. Check the method inside legacy handlers that mutate
state, or add a declaration.
## Results and errors
Return a JSON-serializable value. The HTTP endpoint wraps it in EmDash's `{ success: true, data }` envelope.
Return a stable application-level error object for expected validation and domain failures. Throw only for unexpected failures, and keep exception messages free of credentials, personal data, paths, and stack traces.
Declare `response: "raw"` for unwrapped responses. The handler must return `pluginResponse()` from
`emdash/plugin` with a text or `Uint8Array` body. Raw response bodies are buffered up to 8 MiB.
The response header allowlist is `Accept-Ranges`, `Content-Disposition`, `Content-Encoding`,
`Content-Language`, `Content-Range`, `Content-Type`, `ETag`, `Last-Modified`, `Location`, and
`Retry-After`. The host removes other plugin-supplied headers, adds security headers, and applies the
route-owned cache policy.
Raw routes cannot return active same-origin types, including HTML, JavaScript or ECMAScript, XHTML,
SVG, XML, CSS, WebAssembly, `multipart/related`, and `multipart/x-mixed-replace`. A raw route also
cannot back an MCP tool. Do not return or throw a WHATWG `Response`; it is not the portable response
wire.
## Public caching
Core accepts `cacheControl` on a public route:
```typescript
routes: {
catalog: {
public: true,
cacheControl: "public, max-age=60, stale-while-revalidate=300",
handler: async () => ({ items: [] }),
},
},
```
The value is applied only to successful public `GET` and `HEAD` responses. Private responses, errors, and other methods remain `private, no-store`. The plugin CLI carries `cacheControl` through the probe, bundle manifest, registry artifact, and generated descriptor.
## Request metadata
Both runners send the same normalized metadata:
- `ip`: a trusted client address when the platform or operator configured a trusted proxy header, otherwise `null`;
- `userAgent`: the `User-Agent` value, otherwise `null`;
- `referer`: the `Referer` value, otherwise `null`;
- `geo`: Cloudflare country, region, and city when available, otherwise `null`.
Do not treat `userAgent`, `referer`, or geographic values as authenticated identity. Use `routeCtx.user` for the caller.
## Content reads
With `content:read`, both sandbox runners match the trusted read contract. `ctx.content.get()` and `ctx.content.list()` return content identity, slug, status, locale, data, created/updated/published/scheduled timestamps, and SEO metadata when enabled.
`list()` accepts `limit`, `cursor`, `where`, and `orderBy`. Field filters, status filters, ordering, and cursor pagination reach the host repository on both runners; they are not evaluated inside the plugin isolate. `getTranslations()` and `getPublicUrl()` use the same `content:read` authority. Collection discovery uses `ctx.schema` with `schema:read`. Read [Content, schema, translations, and publication](./content.md) for the complete contract.
## Publication actions
With `content:publish`, call `getVersioned()` before `publish()`, `unpublish()`, `schedule()`, or `unschedule()`, then pass the returned `_rev`. Each successful mutation returns the item and its next `_rev`. `content:restore` separately provides `getTrashedVersioned()` and `restore()` without granting ordinary content reads. These actions execute through the host runtime, including policy and after-hooks; a stale revision or recursive action rejects without changing the entry.
## Redirects
Declare `redirects:read` to list redirect rules with cursor pagination and read one rule with its opaque `_rev`. Declare `redirects:write` to create, update, or delete rules; it implies read access and authorizes the plugin to change where visitors are sent.
Pass `_rev` back unchanged for `update()` and `delete()`. A stale value fails with `CONFLICT`; re-read the current rule before retrying. The revision tracks redirect configuration, not visitor hit counting. Redirect writes use the host redirect handlers, including path-pattern, destination-parameter, duplicate-source, and terminal-status validation. Self-loop and multi-hop-loop validation runs when a rule is created or its source or destination changes. An enabled-only update can reactivate a pre-existing loop, which the Redirects page reports. Plugin input cannot set the host-owned `auto` marker.
## External HTTP responses
`ctx.http.fetch()` returns a buffered WHATWG `Response` in both sandbox runners. `ok`, `status`, `statusText`, `headers`, `url`, `redirected`, `text()`, `json()`, `arrayBuffer()`, `blob()`, and `clone()` use the standard Web API and preserve the same values across runners.
Request and response bodies are each limited to 8 MiB of decoded bytes. The bridge enforces the limit while reading the body rather than trusting `Content-Length`. Responses are buffered rather than streamed to plugin code.
## Expose a route as an MCP tool
MCP exposure is explicit. The following tool calls the private route as `<pluginId>__createEvent`:
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "zod";
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) => {
const parsed = createEventInput.safeParse(routeCtx.input);
if (!parsed.success) return { ok: false, error: "INVALID_EVENT" };
return { ok: true, id: crypto.randomUUID() };
},
},
},
mcp: {
tools: {
createEvent: {
description: "Create a calendar event requested by the user.",
route: "events/create",
input: createEventInput,
output: z.object({ ok: z.boolean(), id: z.string().optional() }),
destructive: false,
},
},
},
};
export default plugin;
```
An MCP tool must:
- use a tool name containing only letters, digits, `_`, or `-`;
- reference an existing private route;
- reference a route with an explicit valid `permission`;
- reference a JSON route, not a route with `response: "raw"`;
- declare an input Zod schema;
- set `destructive: true` for deletion, overwrite, publishing, charging, or another difficult-to-reverse action.
The optional output schema becomes structured MCP output. The bundle converts both schemas to JSON Schema.
Installation and updates show the exact MCP tools for consent. Adding a tool or changing a route from private to public requires fresh approval. After installation, an administrator separately enables plugin MCP tools. A caller then needs the route permission and either the `mcp:tools` scope or `mcp:tools:<pluginId>`.
The production core parser, shared plugin-types parser, plugin CLI artifact, and generated descriptor preserve MCP declarations and route permission/cache metadata. `@emdash-cms/plugin-test` exposes the parsed manifest so tests can assert that transport. Its `invokeRoute()` still bypasses the HTTP catch-all, so use the host for plugin execution and bridge behavior, not route authorization, response caching, MCP registration, or consent behavior.