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
This commit is contained in:
dckc committed 2026-10-07 19:57:43 -05:00
1 parent af8dd75fd1
commit 4b4ef8fea6
52 files changed
+18118

No files matched your search

@@ -0,0 +1,134 @@
---
name: creating-plugins
description: Create EmDash CMS plugins with sandboxed hooks, routes, storage, content and media APIs, MCP tools, and declarative admin UI, or native React and Astro extensions. Use when scaffolding or implementing an EmDash plugin.
---
# Creating EmDash plugins
Build against the API that reaches the intended execution mode. Source types and production-boundary tests take precedence over examples in this skill when they disagree.
## Choose a format
| Format | Runtime source | Admin UI | Distribution |
| --------- | --------------------------------------------------- | --------------------------------------------------------- | ---------------------------- |
| Sandboxed | `src/plugin.ts` default-exports a `SandboxedPlugin` | Block Kit pages, widgets, saved-entry panels, and actions | Plugin CLI and registry |
| Native | `definePlugin()` / `createPlugin()` | React, Block Kit, and Astro components | Trusted site dependency only |
Use a sandboxed plugin unless the feature needs host-process access, React admin code, Astro rendering components, raw page fragments, or custom Portable Text block definitions. Native plugins run with the site's authority and cannot be installed from the registry.
## Scaffold a sandboxed plugin
```sh
pnpm dlx @emdash-cms/plugin-cli init my-plugin
cd my-plugin
pnpm install
pnpm run test
```
The manifest is the identity and trust contract. Runtime hooks and routes live in `src/plugin.ts`; the CLI generates descriptors, manifests, and bundles. Do not create a separate descriptor factory or `sandbox-entry.ts`.
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
},
},
};
export default plugin;
```
Import authoring types from `emdash/plugin` with `import type`. Value imports are limited to lightweight helpers such as `pluginRoute()` and `pluginResponse()`, which the CLI bundles. Sandboxed runtime code can use Web APIs but not Node.js built-ins.
## Declare access
Declare every host API in `emdash-plugin.jsonc`. Adding authority, exposing a route publicly, or adding MCP tools requires renewed administrator approval.
| Capability | Grants |
| -------------------------------- | -------------------------------------------------------------------------- |
| `schema:read` | Public collection and field definitions |
| `admin.editor-draft:read` | Selected unsaved field values after an explicit editor interaction |
| `admin.editor-draft:patch` | Host-validated unsaved field changes proposed for editor review |
| `content:read` | Content identity, translations, and published public URLs |
| `content:revisions:read` | Retained revision data; implies content read |
| `content:write` | Create, update, delete, and translation creation; implies read |
| `content:publish` | Revision-fenced publish, unpublish, schedule, and unschedule; implies read |
| `content:restore` | Revision-fenced reads and restoration of trashed content |
| `hooks.content-policy:register` | Pre-publish, pre-schedule, and pre-unpublish policy hooks |
| `taxonomies:read` | Taxonomy definitions, terms, and entry assignments |
| `taxonomies:write` | Term creation and assignment deltas; implies read |
| `redirects:read` | Versioned redirect inspection |
| `redirects:write` | Versioned redirect creation, update, and deletion; implies read |
| `comments:read` | Stored non-trashed comments and their personal data |
| `comments:moderate` | Expected-status moderation; implies read |
| `media:read` | Ready-media metadata and authenticated asset URLs |
| `media:bytes:read` | Bounded media bytes and content hashes |
| `media:metadata:write` | Alt text, caption, and focal-point updates |
| `media:write` | Upload and delete; implies media read |
| `network:request` | `ctx.http.fetch()` restricted to `allowedHosts` |
| `network:request:unrestricted` | `ctx.http.fetch()` without a host list |
| `users:read` | User directory lookup; also required by comment hooks |
| `email:send` | Email delivery when a transport is configured |
| `hooks.email-transport:register` | Exclusive `email:deliver` hook |
| `hooks.email-events:register` | Email before/after hooks |
| `hooks.page-fragments:register` | Trusted-only page fragments; excluded from sandbox registration |
Settings, KV, declared storage, logging, and cron scheduling are plugin-scoped and need no capability. Use `ctx.settings` for user configuration, `ctx.kv` for internal key-value state, and declared `ctx.storage.<collection>` for queryable records.
Read the focused reference for the API being used:
- [Content, schema, translations, and publication](./references/content.md)
- [Taxonomies and redirects](./references/taxonomies-and-redirects.md)
- [Comments](./references/comments.md)
- [Media](./references/media.md)
- [Storage, KV, and encrypted settings](./references/storage.md)
## Routes, HTTP, and MCP
Routes are private by default and require authentication, their declared RBAC permission, token scope or CSRF as appropriate. Public routes are internet-facing and require explicit install consent.
Undeclared routes keep the legacy JSON/query envelope. Declare methods and body modes for host-enforced parsing. Use `pluginResponse()` only on a route that declares a raw response. Outbound `ctx.http.fetch()` responses and declared route bodies are buffered and bounded.
MCP tools reference private JSON routes with explicit permissions and Zod input schemas. Mark difficult-to-reverse operations `destructive: true`. Read [API routes and MCP tools](./references/api-routes.md).
## Hooks
Sandboxed hooks enter the same priority, dependency, timeout, error-policy, enablement, exclusive-provider, and capability pipeline as trusted hooks. Publication policy hooks identify action origin and actor without granting publication authority. Comment moderation invoked through `ctx.comments` reports plugin origin and runs the normal after-hook once. Read [Hooks](./references/hooks.md).
## Declarative admin UI
Sandboxed pages and widgets return validated Block Kit. Structured links use host-resolved targets; `routeCtx.ui` carries host-attested locale, direction, and surface. External images require matching network authority.
Saved-entry panels and actions point to private routes. Ordinary panel load receives only host-reloaded saved identity and version. Add `admin.editor-draft:read` or `admin.editor-draft:patch` plus extension-level collection and field selectors when an explicit interaction must receive selected unsaved fields or propose an atomic whole-field patch. Patch does not imply read. The host previews accepted patches, marks the form dirty, and never saves them automatically. Read saved content through capability-gated `ctx.content`.
Declarative field widgets currently compose supported Block Kit elements into a JSON value. Custom Portable Text blocks and Astro render components remain native-only. Read [Admin UI](./references/admin-ui.md), [Block Kit](./references/block-kit.md), and [Portable Text blocks](./references/portable-text-blocks.md).
## Runner parity
Cloudflare Worker Loader and Node/workerd execute the same bundle behind a plugin-scoped bridge. Both return buffered WHATWG responses from `ctx.http.fetch()` with binary bytes preserved and decoded request/response bodies limited to 8 MiB. Write against exported types, not extra methods found in one wrapper.
Read [Sandbox boundaries](./references/sandbox-boundaries.md) before designing around an API not listed here.
## Test the production boundary
Use `createPluginTestHost()` for fast hook, route, manifest, capability, KV, settings, and storage transport tests. Use `createPluginRuntimeTestHost()` when the test must exercise real content actions, plugin activation, media, comments, redirects, scheduling, restart, authorization, CSRF, caching, Block Kit validation, or saved-entry extensions.
Runtime fixtures establish state without firing hooks. Runtime actions call production boundaries; inspectors read observable state. Queue outbound HTTP responses with `host.http.respond()` and inspect requests with `host.http.requests()`. Dispose every host after use. Add Node/workerd parity only for runner-sensitive behavior.
## References
- [Content, schema, translations, and publication](./references/content.md)
- [Taxonomies and redirects](./references/taxonomies-and-redirects.md)
- [Comments](./references/comments.md)
- [Media](./references/media.md)
- [Hooks](./references/hooks.md)
- [Storage, KV, and encrypted settings](./references/storage.md)
- [Admin UI and field widgets](./references/admin-ui.md)
- [API routes and MCP tools](./references/api-routes.md)
- [Block Kit](./references/block-kit.md)
- [Portable Text blocks](./references/portable-text-blocks.md)
- [Sandbox boundaries](./references/sandbox-boundaries.md)
- [Publishing](./references/publishing.md)
@@ -0,0 +1,211 @@
# Admin UI and field widgets
Sandboxed plugins return declarative Block Kit from a private route. Native plugins may instead ship React components. Keep these paths separate: sandboxed plugin JavaScript never runs in the browser.
## Sandboxed pages and dashboard widgets
Declare navigation and widget cards in `emdash-plugin.jsonc`:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"pages": [
{ "path": "/settings", "label": "Settings", "icon": "settings" },
{ "path": "/reports", "label": "Reports", "icon": "chart" },
],
"widgets": [{ "id": "status", "title": "Plugin status", "size": "half" }],
},
}
```
Pages mount at `/_emdash/admin/plugins/<plugin-id>/<path>`. Widget sizes are `full`, `half`, and `third`.
Any sandboxed plugin that declares a page or widget must define an `admin` route. The admin sends a `page_load`, `block_action`, or `form_submit` interaction as `routeCtx.input`:
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import type { BlockResponse } from "@emdash-cms/blocks";
import { z } from "zod";
const interactionSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("page_load"), page: z.string() }),
z.object({
type: z.literal("block_action"),
action_id: z.string(),
block_id: z.string().optional(),
value: z.unknown().optional(),
}),
z.object({
type: z.literal("form_submit"),
action_id: z.string(),
block_id: z.string().optional(),
values: z.object({ enabled: z.boolean() }),
}),
]);
function settingsForm(enabled: boolean): BlockResponse {
return {
blocks: [
{ type: "header", text: "Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: enabled },
],
submit: { action_id: "save", label: "Save" },
},
],
};
}
const plugin: SandboxedPlugin = {
routes: {
admin: {
permission: "plugins:manage",
handler: async (routeCtx, ctx) => {
const parsed = interactionSchema.safeParse(routeCtx.input);
if (!parsed.success) return { blocks: [] };
const interaction = parsed.data;
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.settings.set("enabled", interaction.values.enabled === true);
return {
...settingsForm(interaction.values.enabled === true),
toast: { type: "success", message: "Settings saved" },
};
}
const enabled = (await ctx.settings.get<boolean>("enabled")) ?? false;
return settingsForm(enabled);
},
},
},
};
export default plugin;
```
Validate interactions before production side effects; `routeCtx.input` is `unknown`. Read [Block Kit](./block-kit.md) for exact interaction, block, and element shapes.
The plugin CLI preserves `admin.settingsSchema` in the registry manifest and generated descriptor, so the host can generate a settings form. Both sandbox bridges route `ctx.settings` through the same options records as that form. Read a generated setting with `ctx.settings.get("<key>")`; writes, deletes, list operations, and revision-based operations use the same namespace on Cloudflare and Node/workerd.
The `secret` settings field is write-only in the admin response and encrypted before persistence. The site must provide `EMDASH_ENCRYPTION_KEY`; missing, wrong, or tampered key material fails closed. Keep the encryption-key list with database backups. Existing `ctx.kv.get("settings:<key>")` reads remain compatible throughout EmDash 1.x.
## Sandboxed saved-entry extensions
Declare saved-entry panels and actions in `emdash-plugin.jsonc`:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"editorPanels": [
{
"id": "health",
"title": "Content health",
"route": "editor/health",
"collections": ["posts"],
"draft": {
"read": { "translatable": true },
"patch": { "fields": ["title", "excerpt"] },
},
},
],
"editorActions": [
{
"id": "repair",
"label": "Repair metadata",
"route": "editor/repair",
"placement": "overflow",
"style": "danger",
"confirm": {
"title": "Repair?",
"text": "This changes the saved entry.",
"confirm": "Repair",
"deny": "Cancel",
},
},
],
},
}
```
Every referenced route must be private. EmDash reloads the saved entry and checks ownership plus the route permission before invoking it. `routeCtx.ui.entry` contains only the canonical collection, ID, locale, and version.
Panels start collapsed. `panel_load` never includes draft values. After an explicit `block_action`, `form_submit`, or `editor_action`, `admin.editor-draft:read` can attach only the fields selected by the extension's `draft.read` declaration. `fields` selects explicit slugs and `translatable: true` selects current schema fields marked translatable. Draft declarations require explicit collection scope.
`admin.editor-draft:patch` permits a separate `draft.patch` field selector and does not imply read. Return `patch: { type: "editor-draft-patch", operations }` with whole-field `set` or `clear` operations. The host rejects the complete patch on an unknown, forbidden, invalid, unsupported, oversized, or stale operation. Accepted patches receive a host-rendered preview, update the form atomically, mark it dirty, and remain unsaved. A response may contain a toast and one terminal effect: patch, refresh, or navigation.
Use `createPluginRuntimeTestHost().admin` to exercise this boundary. `captureEditorDraft()` creates a saved-entry draft request, the existing panel/action helpers invoke the production route, and `applyEditorDraftPatch()` applies only a current response through the host validator.
## Sandboxed declarative field widgets
Core and the admin contain a declarative field-widget path. Declare the widget in the registry manifest:
```jsonc title="emdash-plugin.jsonc"
{
"admin": {
"fieldWidgets": [
{
"name": "event-picker",
"label": "Event",
"fieldTypes": ["json"],
"elements": [
{ "type": "text_input", "action_id": "eventId", "label": "Event ID" },
{ "type": "toggle", "action_id": "featured", "label": "Featured" },
],
},
],
},
}
```
A schema field selects it with `widget: "pluginId:widgetName"`. The editor stores an object keyed by each element's `action_id`. Use a `json` field for this object. The manifest schema accepts other compatible field types, but the repository has no end-to-end test proving that the composed object saves through them.
The current field-widget renderer supports:
- `text_input`
- `number_input`
- `toggle`
- `select`
- `media_picker`
Other Block Kit element types display an unsupported-element message in this surface.
`emdash-plugin.jsonc` accepts `admin.fieldWidgets`, and the plugin CLI carries the definitions through the bundle manifest and generated descriptor for registry installation. The artifact round-trip is covered by plugin CLI, shared manifest, and plugin-test tests. The browser E2E fixture still tests a native React color picker rather than a registry-installed declarative widget, so verify the real editor render and value persistence for the chosen elements.
The sandbox admin route receives `routeCtx.ui` with the host-attested admin locale, text direction, and surface. Use it to select localized text in a runtime Block Kit response. Labels in manifest metadata remain static strings; registry plugins do not hand translation catalogs to the host.
## Native React pages, widgets, and fields
Native plugins may set `admin.entry` and export React components:
```typescript title="src/admin.tsx"
export const pages = {
"/settings": SettingsPage,
};
export const widgets = {
status: StatusWidget,
};
export const fields = {
picker: ColorPickerField,
};
```
The plugin definition points to the entry and declares its surfaces:
```typescript
definePlugin({
id: "color",
version: "1.0.0",
admin: {
entry: "@my-org/plugin-color/admin",
pages: [{ path: "/settings", label: "Settings" }],
widgets: [{ id: "status", title: "Status", size: "half" }],
fieldWidgets: [{ name: "picker", label: "Color picker", fieldTypes: ["string"] }],
},
});
```
Native admin code must follow the repository's Kumo, localization, accessibility, and RTL rules. It runs with the site's authority and is not registry-installable.
@@ -0,0 +1,245 @@
# 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.
@@ -0,0 +1,584 @@
# Block Kit
Declarative JSON UI for sandboxed plugin admin pages. The host renders blocks — no plugin JavaScript runs in the browser. Inspired by Slack's Block Kit but not identical — similar concepts and naming, different block/element types and capabilities.
Trusted plugins (declared in `astro.config.ts`) can ship custom React components instead. Block Kit is for runtime-installed sandboxed plugins.
Native plugins also use Block Kit elements for [Portable Text block editing fields](./portable-text-blocks.md). Plugin CLI and registry packages cannot register Portable Text block types.
## How It Works
1. User navigates to plugin admin page
2. Admin sends `page_load` interaction to plugin's admin route
3. Plugin returns `BlockResponse` with array of blocks
4. Admin renders blocks using `BlockRenderer`
5. User interacts (button click, form submit) → interaction sent back
6. Plugin returns new blocks
```typescript
import type { BlockInteraction } from "@emdash-cms/blocks";
routes: {
admin: {
handler: async (ctx) => {
// EmDash parses the request body once and exposes it as ctx.input;
// read it directly rather than ctx.request.json() (the body is consumed).
// BlockInteraction is the discriminated union of page_load,
// block_action, and form_submit payloads.
const interaction = ctx.input as BlockInteraction;
if (interaction.type === "page_load") {
return {
blocks: [
{ type: "header", text: "My Plugin Settings" },
{
type: "form",
block_id: "settings",
fields: [
{ type: "text_input", action_id: "api_url", label: "API URL" },
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
],
submit: { label: "Save", action_id: "save" },
},
],
};
}
if (interaction.type === "form_submit" && interaction.action_id === "save") {
await ctx.kv.set("settings", interaction.values);
return {
blocks: [/* updated blocks */],
toast: { message: "Settings saved", type: "success" },
};
}
},
},
}
```
## Block Types
| Type | Description |
| ----------- | --------------------------------------------------- |
| `header` | Large bold heading |
| `section` | Text with optional accessory element |
| `divider` | Horizontal rule |
| `fields` | Two-column label/value grid |
| `table` | Data table with formatting, sorting, pagination |
| `actions` | Horizontal row of buttons and controls |
| `stats` | Dashboard metric cards with trend indicators |
| `form` | Input fields with conditional visibility and submit |
| `image` | Block-level image with alt text and optional title |
| `context` | Small muted help text |
| `columns` | 2-3 column layout with nested blocks |
| `chart` | Charts (timeseries line/bar, pie, custom ECharts) |
| `code` | Syntax-highlighted code block |
| `meter` | Progress/quota meter bar |
| `banner` | Info, warning, or error inline messages |
| `empty` | Empty state with optional command and actions |
| `accordion` | Collapsible section containing nested blocks |
## Element Types
| Type | Description |
| -------------- | --------------------------------------------------------- |
| `button` | Action button with optional confirmation dialog |
| `link` | Host-resolved navigation that does not dispatch an action |
| `text_input` | Single-line or multiline text input |
| `number_input` | Numeric input with min/max |
| `select` | Dropdown select |
| `toggle` | On/off switch |
| `secret_input` | Masked input for API keys and tokens |
| `checkbox` | Multi-select checkboxes |
| `radio` | Single-select radio buttons |
| `date_input` | Date picker |
| `combobox` | Searchable dropdown select |
| `repeater` | Array of records with scalar sub-fields |
| `media_picker` | Media-library picker that stores the asset URL |
## Block Syntax
### Header
```json
{ "type": "header", "text": "Settings" }
```
### Section
```json
{
"type": "section",
"text": "Configure your plugin settings below.",
"accessory": { "type": "button", "label": "Refresh", "action_id": "refresh" }
}
```
### Divider
```json
{ "type": "divider" }
```
### Fields
```json
{
"type": "fields",
"fields": [
{ "label": "Status", "value": "Active" },
{ "label": "Last Sync", "value": "2 hours ago" }
]
}
```
### Stats
```json
{
"type": "stats",
"items": [
{ "label": "Total", "value": "1,234", "trend": "up", "description": "+12% vs last week" },
{ "label": "Active", "value": "567" }
]
}
```
- `items` — the array key is `items`, not `stats`
- `trend` — `"up"`, `"down"`, or `"neutral"`; renders an arrow icon next to the value
- `description` — secondary line beneath the value, for context such as "+12% vs last week"
### Table
```json
{
"type": "table",
"columns": [
{ "key": "name", "label": "Name" },
{ "key": "status", "label": "Status" },
{ "key": "date", "label": "Date" }
],
"rows": [{ "name": "Item 1", "status": "Active", "date": "2025-01-01" }],
"page_action_id": "browse_items",
"empty_text": "No items yet."
}
```
- `page_action_id` — required. The admin sends it as the `block_action` id when the user sorts a column or pages through results.
- `empty_text` — shown in place of the table when `rows` is empty
- `next_cursor` — set it to render a "Load more" control
### Actions
```json
{
"type": "actions",
"elements": [
{ "type": "button", "label": "Save", "action_id": "save", "style": "primary" },
{ "type": "button", "label": "Cancel", "action_id": "cancel" }
]
}
```
### Form
```json
{
"type": "form",
"block_id": "settings",
"fields": [
{ "type": "text_input", "action_id": "name", "label": "Name" },
{ "type": "number_input", "action_id": "count", "label": "Count", "min": 0, "max": 100 },
{
"type": "select",
"action_id": "theme",
"label": "Theme",
"options": [
{ "label": "Light", "value": "light" },
{ "label": "Dark", "value": "dark" }
]
},
{ "type": "toggle", "action_id": "enabled", "label": "Enabled", "initial_value": true },
{ "type": "secret_input", "action_id": "api_key", "label": "API Key" }
],
"submit": { "label": "Save", "action_id": "save_settings" }
}
```
### Columns
```json
{
"type": "columns",
"columns": [
[
{ "type": "header", "text": "Usage" },
{ "type": "meter", "label": "Storage used", "value": 65 }
],
[
{ "type": "header", "text": "Activity" },
{ "type": "context", "text": "Last sync 2 hours ago" }
]
]
}
```
- `columns` — 2 or 3 columns, each one an array of blocks
### Chart (Timeseries)
```json
{
"type": "chart",
"config": {
"chart_type": "timeseries",
"series": [
{
"name": "Requests",
"data": [
[1709596800000, 42],
[1709600400000, 67],
[1709604000000, 53]
],
"color": "#086FFF"
},
{
"name": "Errors",
"data": [
[1709596800000, 2],
[1709600400000, 5],
[1709604000000, 1]
]
}
],
"x_axis_name": "Time",
"y_axis_name": "Count",
"style": "line",
"gradient": true,
"height": 300
}
}
```
- `series[].data` — array of `[timestamp_ms, value]` tuples
- `series[].color` — hex color (optional, auto-assigned from Kumo palette)
- `style` — `"line"` (default) or `"bar"`
- `gradient` — fill gradient beneath lines (default false)
- `height` — chart height in pixels (default 350)
### Chart (Custom)
For pie charts, gauges, or any ECharts visualization:
```json
{
"type": "chart",
"config": {
"chart_type": "custom",
"options": {
"series": [
{
"type": "pie",
"data": [
{ "value": 335, "name": "Published" },
{ "value": 234, "name": "Draft" },
{ "value": 120, "name": "Scheduled" }
]
}
]
},
"height": 300
}
}
```
- `options` — raw ECharts option object passed through to `chart.setOption()`
### Code
```json
{
"type": "code",
"code": "const greeting = \"Hello!\";\nconsole.log(greeting);",
"language": "ts"
}
```
- `language` — `"ts"`, `"tsx"`, `"jsonc"`, `"bash"`, or `"css"` (defaults to `"ts"`)
### Meter
```json
{
"type": "meter",
"label": "Storage used",
"value": 65,
"custom_value": "6.5 GB / 10 GB"
}
```
- `value` — numeric value (default range 0-100)
- `max` / `min` — custom range (defaults to 0-100)
- `custom_value` — display string instead of percentage (e.g. "750 / 1,000")
### Banner
```json
{
"type": "banner",
"title": "API key invalid",
"description": "Please check your API key in settings.",
"variant": "error"
}
```
- `variant` — `"default"` (info, default), `"alert"` (warning), or `"error"`
- At least one of `title` or `description` is required
### Empty
```json
{
"type": "empty",
"title": "No submissions",
"description": "New submissions appear here.",
"command_line": "pnpm run seed",
"size": "base",
"actions": [{ "type": "button", "action_id": "refresh", "label": "Refresh" }]
}
```
### Tabs
Use `tab` to group related blocks into labelled panels:
```json
{
"type": "tab",
"panels": [
{
"label": "General",
"blocks": [{ "type": "context", "text": "General settings" }]
}
]
}
```
### Accordion
```json
{
"type": "accordion",
"label": "Advanced settings",
"default_open": false,
"blocks": [{ "type": "context", "text": "Settings visible when expanded" }]
}
```
## Repeater and media picker elements
`repeater` and `media_picker` are admin-authoring elements, not ordinary fields in a sandboxed admin-page `form`.
`repeater` captures an array of objects. Its nested fields are limited to `text_input`, `number_input`, `select`, and `toggle`:
```typescript
{
"type": "repeater",
"action_id": "items",
"label": "Questions",
"item_label": "Question",
"fields": [
{ "type": "text_input", "action_id": "question", "label": "Question" },
{ "type": "text_input", "action_id": "answer", "label": "Answer", "multiline": true }
]
}
```
`media_picker` opens the media library and stores the selected asset's URL string:
```typescript
{
"type": "media_picker",
"action_id": "hero",
"label": "Hero image",
"mime_type_filter": "image/"
}
```
## Declarative field widgets
The admin field editor can render a plugin field widget from Block Kit elements. A schema field refers to `pluginId:widgetName`; a config-declared standard descriptor supplies `name`, `label`, compatible `fieldTypes`, and `elements`.
The field-widget renderer currently supports only these elements:
- `text_input`
- `number_input`
- `toggle`
- `select`
- `media_picker`
It stores an object keyed by each element's `action_id`. Use a `json` field for this composed value; other field types are accepted by the manifest schema but are not covered by an end-to-end save test. Other element types render an unsupported-element message.
`emdash-plugin.jsonc` accepts this field-widget definition, and the plugin CLI preserves it in the registry manifest and generated descriptor. The artifact transport is tested; the repository's browser E2E test still covers a native React field widget rather than a registry declarative widget. Verify the rendered editor and saved value for the selected elements.
## Conditional Fields
Show/hide fields based on other field values. Evaluated client-side, no round-trip.
```json
{
"type": "toggle",
"action_id": "auth_enabled",
"label": "Enable Authentication"
}
```
```json
{
"type": "secret_input",
"action_id": "api_key",
"label": "API Key",
"condition": { "field": "auth_enabled", "eq": true }
}
```
## Builder Helpers
`@emdash-cms/blocks` provides TypeScript helpers:
```typescript
import { blocks, elements } from "@emdash-cms/blocks";
const {
header,
form,
section,
stats,
timeseriesChart,
customChart,
banner: bannerBlock,
empty,
accordion,
} = blocks;
const { textInput, toggle, select, button, repeater, mediaPicker } = elements;
return {
blocks: [
header("Settings"),
form({
blockId: "settings",
fields: [
textInput("site_title", "Site Title", { initialValue: "My Site" }),
toggle("generate_sitemap", "Generate Sitemap", { initialValue: true }),
select("robots", "Default Robots", [
{ label: "Index, Follow", value: "index,follow" },
{ label: "No Index", value: "noindex,follow" },
]),
],
submit: { label: "Save", actionId: "save" },
}),
// Timeseries chart
timeseriesChart({
series: [
{
name: "Page Views",
data: [
[Date.now() - 3600000, 100],
[Date.now(), 150],
],
},
],
yAxisName: "Views",
gradient: true,
}),
// Pie chart via custom ECharts options
customChart({
options: {
series: [
{
type: "pie",
data: [
{ value: 335, name: "Published" },
{ value: 234, name: "Draft" },
],
},
],
},
}),
],
};
```
## Button Confirmations
```json
{
"type": "button",
"label": "Delete All",
"action_id": "delete_all",
"style": "danger",
"confirm": {
"title": "Are you sure?",
"text": "This cannot be undone.",
"confirm": "Delete",
"deny": "Cancel"
}
}
```
## Saved-entry panels and actions
`admin.editorPanels` and `admin.editorActions` point to private plugin routes. Panel routes return Block Kit and receive `panel_load`, `block_action`, or `form_submit`. Action routes receive `editor_action` and return only these bounded fields:
```typescript
{
toast?: { message: string; type: "success" | "error" | "info" };
refresh?: true;
navigate?: LinkTarget;
patch?: EditorDraftPatchEffect;
}
```
Use only one of `refresh`, `navigate`, or `patch`. Navigation uses the same structured target validator as `link` elements. A danger action declaration must include a confirmation dialog.
For both surfaces, `routeCtx.ui.entry` contains the host-reloaded collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the manifest declaration. Ordinary panel load contains no draft. An explicit interaction includes selected unsaved fields only when the plugin has `admin.editor-draft:read` and the extension declares bounded `draft.read` access. `admin.editor-draft:patch` separately permits atomic whole-field `set` and `clear` proposals for fields in `draft.patch`; the host previews accepted changes without saving them.
## Links and admin locale
Use a structured link target instead of returning an admin URL:
```json
{
"type": "link",
"label": "Edit article",
"target": { "kind": "content", "collection": "posts", "id": "post-1", "locale": "ar" },
"appearance": "primary"
}
```
Targets can identify saved content, a page declared by the same plugin, the plugin's generated settings page, or an absolute external HTTP, HTTPS, or `mailto:` URL. External links open in a new tab with `noopener noreferrer`. A link has no `action_id`; use a button when the interaction must call the plugin.
Every page and widget response is validated before rendering. Responses are limited to 256 KiB, 20 nested levels, 2,000 nodes, 1,000 items per array, and 64 KiB per string. Root-relative image URLs are accepted. External images require HTTPS and either `network:request` with the hostname in the plugin manifest's `allowedHosts`, or `network:request:unrestricted`.
The admin route receives host-attested UI context separately from the interaction:
```typescript
const { locale, direction, surface } = routeCtx.ui ?? {
locale: "en",
direction: "ltr",
surface: "admin-page",
};
```
The UI locale is the administrator's active locale. It is separate from the site's default content locale in `ctx.site.locale`. Runtime page labels can use it to select localized Block Kit text; manifest navigation labels remain static.
## Toast Responses
Return a `toast` alongside blocks to show a notification:
```typescript
return {
blocks: [/* ... */],
toast: { message: "Settings saved", type: "success" }, // "success" | "error" | "info"
};
```
@@ -0,0 +1,13 @@
# Comment administration
Comment hooks and stored-comment administration have separate authority.
All comment hooks require `users:read` because their events contain author and request-derived information. `comments:read` separately exposes stored non-trashed comments through `ctx.comments.get()`, `list()`, and `count()`.
Comment reads include author name and email, body, pseudonymous IP hash, user agent, and moderation metadata. They do not expose the linked EmDash user-account ID. Treat this as personal-data access.
`comments:moderate` implies read and adds `ctx.comments.setStatus()`. Supply the status observed by the caller and move only between `approved`, `pending`, and `spam`. A stale status returns `COMMENT_STATUS_CONFLICT`; read again before deciding whether to retry.
A successful plugin transition identifies `{ source: "plugin", pluginId }`, runs `comment:afterModerate` once, preserves approval notifications, and rejects recursive moderation. The API does not hard-delete comments or replace statuses in bulk.
Use the runtime test host when a test must prove the personal-data shape, conflict behavior, notifications, hook origin, recursion fencing, or the real comment HTTP/runtime path.
@@ -0,0 +1,41 @@
# Content, schema, translations, and publication
Plugin content APIs are capability-gated and shared by native, Cloudflare Worker Loader, and Node/workerd execution.
## Discovery and reads
`schema:read` exposes `ctx.schema.listCollections()` and `getCollection()` for batched public collection and field definitions.
`content:read` exposes `ctx.content.get()`, `list()`, `getTranslations()`, and `getPublicUrl()`. Content results include identity, slug, status, locale, data, timestamps, author ID, translation group, live and draft revision pointers, and row version. Public URL resolution returns only published routable URLs, never previews.
`content:revisions:read` adds `listRevisions()` and `getRevision()`. Revision snapshots can retain field values removed later but omit revision-author identity.
## Writes and translations
`content:write` adds create, update, and delete. Create a translation with:
```typescript
await ctx.content!.create("posts", data, { locale: "fr", translationOf: sourceId });
```
The source must be an active entry in the same collection. The new row joins its translation group and inherits non-translatable fields, byline credits, and taxonomy assignments. Validation and save hooks run, with re-entrancy fencing for the creating plugin. A group permits one active row per locale.
Stable failures include `CONFLICT`, `NOT_FOUND`, `VALIDATION_ERROR`, and `SAVE_REJECTED`.
## Publication policy
`hooks.content-policy:register` enables `content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` without granting content reads, writes, or publication actions. Return `{ cancel: true, reason }` to reject an action.
Events identify API, MCP, visual-editor, plugin, scheduler, and system origins. Authenticated human actions include actor identity and source. Scheduled publication runs the publish policy again; rejection unschedules the entry and records the bounded reason for administrators.
## Publication and restore actions
`content:publish` adds `getVersioned()`, `publish()`, `unpublish()`, `schedule()`, and `unschedule()`. Read first and pass the opaque `_rev` to every mutation. A successful action returns the next revision and runs the normal policy, synchronization, media-usage, cache-invalidation, and after-hook behavior.
`content:restore` separately adds `getTrashedVersioned()` and `restore()` without granting ordinary content reads. It does not grant permanent deletion.
Plugin actions report `{ source: "plugin", pluginId }`. Re-entering the same action for the same canonical entry from that plugin is rejected.
## Runtime tests
Use runtime fixtures for initial entries, translations, bylines, and taxonomy assignments. Use runtime actions for publication paths and inspectors for persisted state. Test stale revisions, policy rejection, duplicate locales, re-entrancy, restart, and cache invalidation when those behaviors matter.
@@ -0,0 +1,585 @@
# Hooks
Sandboxed plugins declare hooks in the typed default export from `src/plugin.ts`. The `SandboxedPlugin` type infers each event and return type from the hook name.
## Signature
```typescript
async (event: EventType, ctx: PluginContext) => ReturnType;
```
## Configuration
Simple handler or full config:
```typescript
import type { SandboxedPlugin } from "emdash/plugin";
const plugin: SandboxedPlugin = {
hooks: {
"content:afterSave": {
priority: 100,
timeout: 5000,
handler: async (event, ctx) => {
ctx.log.info("Saved", { id: event.content.id });
},
},
},
};
export default plugin;
```
The plugin CLI manifest retains `priority`, `timeout`, `dependencies`, `errorPolicy`, and `exclusive`. Registry-installed and config-managed sandbox hooks enter the shared host pipeline, so these settings apply consistently with trusted plugins.
Put capabilities in `emdash-plugin.jsonc`, not in `src/plugin.ts`. The host pipeline skips sandboxed and trusted hooks whose required capability is absent.
## Isolated sandbox support
The current isolated runtime dispatches these hooks to Cloudflare and Node/workerd sandbox instances through the shared host pipeline:
- `plugin:install`, `plugin:activate`, `plugin:deactivate`, and `plugin:uninstall`
- `content:beforeSave`, `content:afterSave`, `content:beforeDelete`, and `content:afterDelete`
- `content:beforePublish`, `content:beforeSchedule`, `content:beforeUnpublish`, `content:afterPublish`, `content:afterUnpublish`, `content:afterRestore`, `content:afterSchedule`, and `content:afterUnschedule`
- `media:beforeUpload` and `media:afterUpload`
- `email:beforeSend`, `email:deliver`, and `email:afterSend`
- `comment:beforeCreate`, `comment:moderate`, `comment:afterCreate`, and `comment:afterModerate`
- `cron`
- `page:metadata`
`page:fragments` is the only declared hook excluded from sandbox registration. The plugin CLI accepts it and emits a trusted-only warning; the sandbox host proxy drops it before the hook pipeline is built.
## Lifecycle Hooks
### `plugin:install`
Runs once on first install. Use to seed defaults.
```typescript
"plugin:install": async (_event, ctx) => {
await ctx.settings.set("enabled", true);
await ctx.storage.items!.put("default", { name: "Default" });
}
```
Event: `{}`
Returns: `void`
### `plugin:activate`
Runs when plugin is enabled (after install or re-enable).
```typescript
"plugin:activate": async (_event, ctx) => {
ctx.log.info("Activated");
}
```
Event: `{}`
Returns: `void`
### `plugin:deactivate`
Runs when plugin is disabled (not removed).
```typescript
"plugin:deactivate": async (_event, ctx) => {
ctx.log.info("Deactivated");
}
```
Event: `{}`
Returns: `void`
### `plugin:uninstall`
Runs when plugin is removed. Only delete data if `event.deleteData` is true.
```typescript
"plugin:uninstall": async (event, ctx) => {
if (event.deleteData) {
const result = await ctx.storage.items!.query({ limit: 1000 });
await ctx.storage.items!.deleteMany(result.items.map(i => i.id));
}
}
```
Event: `{ deleteData: boolean }`
Returns: `void`
## Content Hooks
### `content:beforeSave`
Runs before save. Return modified content, or void to keep it unchanged. To reject from the sandbox, return `{ __emdashSandboxHookResult: true, version: 1, error: { code: "SAVE_REJECTED", reason } }`. The reason must be 1–500 characters of plain text. EmDash identifies the plugin and shows the reason as text; invalid or unknown error results fail with a generic hook error. From the host process, throw `ContentSaveRejectedError` (exported from `emdash`) instead. Any other exception from either execution mode fails the save with a generic message.
```typescript
"content:beforeSave": async (event, ctx) => {
const { content } = event;
if (!content.title) {
return {
__emdashSandboxHookResult: true,
version: 1,
error: { code: "SAVE_REJECTED", reason: "Add a title before saving." },
};
}
// Transform
if (content.slug) {
content.slug = content.slug.toLowerCase().replace(/\s+/g, "-");
}
return content;
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean, id?: string, actor?: { id: string, role: number } }`. Authenticated REST, visual editing, and MCP saves include a read-only actor snapshot; internal writes may omit it. On updates, `id` identifies the existing item. The actor snapshot does not identify the request origin, so a hook cannot distinguish REST, visual editing, MCP, or another authenticated path from this field alone.
Returns: `Record<string, unknown> | SandboxHookErrorEnvelope | void`
### `content:afterSave`
Runs after successful save. Side effects only — logging, notifications, syncing.
```typescript
"content:afterSave": async (event, ctx) => {
const { content, collection, isNew } = event;
ctx.log.info(`${isNew ? "Created" : "Updated"} ${collection}/${content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string, isNew: boolean, actor?: { id: string, role: number } }`. Authenticated saves include a read-only actor snapshot; internal writes may omit it.
Returns: `void`
### `content:beforeDelete`
Runs before delete. Return `false` to cancel, `true` or void to allow.
```typescript
"content:beforeDelete": async (event, ctx) => {
if (event.collection === "pages" && event.id === "home") {
ctx.log.warn("Cannot delete home page");
return false;
}
return true;
}
```
Event: `{ id: string, collection: string }`
Returns: `boolean | void`
### `content:afterDelete`
Runs after successful delete.
```typescript
"content:afterDelete": async (event, ctx) => {
ctx.log.info(`Deleted ${event.collection}/${event.id}`);
await ctx.storage.cache!.delete(`${event.collection}:${event.id}`);
}
```
Event: `{ id: string, collection: string }`
Returns: `void`
### Publication policy hooks
`content:beforePublish`, `content:beforeSchedule`, and `content:beforeUnpublish` require `hooks.content-policy:register`. This authority is independent of `content:read`, `content:write`, and publication actions.
Publish and schedule events expose the effective draft in `content.data` and the staged slug in `content.slug`. Unpublish events expose the currently live content that the action would remove.
Return `void` to allow the action or `{ cancel: true, reason }` to reject it. The reason must contain 1–500 plain-text characters. Invalid decisions and unexpected abort-policy errors stop the action with a generic failure. Explicit cancellations return `PUBLISH_REJECTED`, `SCHEDULE_REJECTED`, or `UNPUBLISH_REJECTED`.
```typescript
"content:beforePublish": async (event) => {
const data = event.content.data;
const approvalStatus =
typeof data === "object" && data !== null && "approval_status" in data
? data.approval_status
: undefined;
if (approvalStatus !== "approved") {
return { cancel: true, reason: "Approve this entry before publishing." };
}
},
```
Events contain `{ content, collection, origin, actor? }`; `content:beforeSchedule` also contains `scheduledAt`. Human origins are `api`, `mcp`, or `visual-editor` and include the same value in `actor.source`. The visual-editor origin requires the signed short-lived token from an authenticated toolbar render. Other origins are `plugin` (with `pluginId`), `scheduler`, and `system`.
Scheduled content runs `content:beforePublish` again when it becomes due. An explicit scheduler rejection unschedules the entry and lists its reason on the dashboard until the entry is rescheduled, published, deleted, or the record is dismissed. There is no `content:beforeUnschedule`, so an administrator can always cancel a future publication.
### `content:afterPublish`
Runs after content is published (promoted from draft to live). Side effects only.
```typescript
"content:afterPublish": async (event, ctx) => {
ctx.log.info(`Published ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterUnpublish`
Runs after content is unpublished (reverted to draft). Side effects only.
```typescript
"content:afterUnpublish": async (event, ctx) => {
ctx.log.info(`Unpublished ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterRestore`
Runs after trashed content is restored. Side effects only.
```typescript
"content:afterRestore": async (event, ctx) => {
ctx.log.info(`Restored ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterSchedule`
Runs after content is scheduled for future publishing. Side effects only.
```typescript
"content:afterSchedule": async (event, ctx) => {
ctx.log.info(`Scheduled ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
### `content:afterUnschedule`
Runs after scheduled content is unscheduled. Side effects only.
```typescript
"content:afterUnschedule": async (event, ctx) => {
ctx.log.info(`Unscheduled ${event.collection}/${event.content.id}`);
}
```
Event: `{ content: Record<string, unknown>, collection: string }`
Returns: `void`
## Media Hooks
### `media:beforeUpload`
Runs before upload. Return modified file info, void to keep, or throw to cancel.
```typescript
"media:beforeUpload": async (event, ctx) => {
const { file } = event;
if (!file.type.startsWith("image/")) {
throw new Error("Only images allowed");
}
if (file.size > 10 * 1024 * 1024) {
throw new Error("Max 10MB");
}
return { ...file, name: `${Date.now()}-${file.name}` };
}
```
Event: `{ file: { name: string, type: string, size: number } }`
Returns: `{ name: string, type: string, size: number } | void`
### `media:afterUpload`
Runs after successful upload.
```typescript
"media:afterUpload": async (event, ctx) => {
ctx.log.info(`Uploaded ${event.media.filename}`, { id: event.media.id });
}
```
Event: `{ media: { id: string, filename: string, mimeType: string, size: number | null, url: string, createdAt: string } }`
Returns: `void`
## Email Hooks
Email hooks require specific capabilities. Without the required capability, hooks are silently skipped.
### `email:beforeSend`
**Requires:** `hooks.email-events:register` capability.
Runs before email delivery. Return modified message, or `false` to cancel delivery. Handlers are chained — each receives the output of the previous one.
```typescript
// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:beforeSend": async (event) => {
return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" };
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `EmailMessage | false`
### `email:deliver`
**Requires:** `hooks.email-transport:register` capability. **Exclusive hook** — exactly one provider is active.
Implements email transport (e.g. Resend, SMTP, SES). Selected by the admin in Settings > Email.
```typescript
// emdash-plugin.jsonc declares hooks.email-transport:register and network:request,
// with api.resend.com in allowedHosts.
"email:deliver": {
exclusive: true,
handler: async ({ message }, ctx) => {
const apiKey = await ctx.settings.get("apiKey");
await ctx.http!.fetch("https://api.resend.com/emails", {
method: "POST",
headers: { Authorization: `Bearer ${apiKey}` },
body: JSON.stringify({
to: message.to,
cc: message.cc,
reply_to: message.replyTo,
subject: message.subject,
text: message.text,
}),
});
},
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
### `email:afterSend`
**Requires:** `hooks.email-events:register` capability.
Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate.
```typescript
// emdash-plugin.jsonc: "capabilities": ["hooks.email-events:register"]
"email:afterSend": async (event, ctx) => {
ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source });
},
```
Event: `{ message: EmailMessage, source: string }`
Returns: `void`
## Comment hooks
Comment hooks run for sandboxed and trusted plugins through the shared pipeline. All four require `users:read`. Their events include author email and request-derived information, so the pipeline skips the hook when the capability is absent.
### `comment:beforeCreate`
Runs before storage. Return a modified event to enrich the comment or its moderator-only metadata, return `false` to reject the comment, or return nothing to keep it unchanged.
```typescript
"comment:beforeCreate": async (event) => {
if (event.comment.body.includes("blocked phrase")) return false;
return {
...event,
metadata: { ...event.metadata, reviewedBy: "rules-v1" },
};
},
```
Event:
```typescript
{
comment: {
collection: string;
contentId: string;
parentId: string | null;
authorName: string;
authorEmail: string;
authorUserId: string | null;
body: string;
ipHash: string | null;
userAgent: string | null;
}
metadata: Record<string, unknown>;
}
```
Returns: the event, `false`, or `void`.
### `comment:moderate`
The exclusive moderation provider decides the initial status after the enrichment pipeline.
```typescript
"comment:moderate": {
exclusive: true,
handler: async (event) => ({
status: event.priorApprovedCount > 0 ? "approved" : "pending",
reason: "First-time authors require review",
}),
},
```
The event contains `comment`, `metadata`, collection comment settings, and `priorApprovedCount`. Return `{ status: "approved" | "pending" | "spam", reason?: string }`.
### `comment:afterCreate`
Runs after the comment is stored. The event contains the stored comment, moderation metadata, the target content summary, and the content author when available. Use it for notifications and other side effects. Returns `void`.
### `comment:afterModerate`
Runs after an administrator or a plugin changes a comment's status. The event contains the stored comment, `previousStatus`, `newStatus`, the moderator's `{ id, name }`, and `origin`. Administrator changes use `{ source: "admin", userId }`; `ctx.comments.setStatus()` uses `{ source: "plugin", pluginId }`. A transition runs the hook once. Returns `void`.
## Cron Hook
### `cron`
Runs on a schedule. Configure schedules via `ctx.cron.schedule()` in `plugin:activate`.
```typescript
hooks: {
"plugin:activate": async (_event, ctx) => {
await ctx.cron!.schedule("daily-cleanup", { schedule: "0 2 * * *" });
},
cron: async (event, ctx) => {
if (event.name === "daily-cleanup") {
// ... cleanup logic
}
},
},
```
Event: `{ name: string, data?: Record<string, unknown> }`
Returns: `void`
## Public Page Hooks
Public page hooks let plugins contribute to the rendered output of public site pages. Templates opt in to these contributions with `<EmDashHead>`, `<EmDashBodyStart>`, and `<EmDashBodyEnd>` components.
### `page:metadata`
Contributes typed metadata to `<head>` — meta tags, OG properties, canonical/alternate links, and JSON-LD. Works in both trusted and sandboxed modes.
Returns structured contributions that core validates, dedupes (first-wins), and renders. Plugins never emit raw HTML through this hook.
```typescript
"page:metadata": async (event, ctx) => {
if (event.page.kind !== "content") return null;
return [
{ kind: "meta", name: "author", content: "My Blog" },
{
kind: "jsonld",
id: `schema:${event.page.content?.collection}:${event.page.content?.id}`,
graph: {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: event.page.pageTitle ?? event.page.title,
description: event.page.description,
},
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageMetadataContribution | PageMetadataContribution[] | null`
Contribution types:
- `{ kind: "meta", name: string, content: string, key?: string }` — `<meta name="..." content="...">`
- `{ kind: "property", property: string, content: string, key?: string }` — `<meta property="..." content="...">` (OpenGraph)
- `{ kind: "link", rel: "canonical" | "alternate", href: string, hreflang?: string, key?: string }` — `<link>` tag (HTTP/HTTPS URLs only)
- `{ kind: "jsonld", id?: string, graph: object | object[] }` — `<script type="application/ld+json">`
Dedupe rules: first contribution wins per key. Canonical is singleton.
### `page:fragments` (trusted only)
Contributes raw HTML, scripts, or markup to `head`, `body:start`, or `body:end`. The sandbox authoring type and manifest schema accept the declaration, and the plugin CLI warns that it is trusted-only. The sandbox host proxy excludes the hook, so a registry-installed plugin never contributes these fragments.
```typescript
"page:fragments": async (event, ctx) => {
return [
{
kind: "external-script",
placement: "head",
src: "https://www.googletagmanager.com/gtm.js?id=GTM-XXXXX",
async: true,
},
{
kind: "html",
placement: "body:start",
html: '<noscript><iframe src="https://www.googletagmanager.com/ns.html?id=GTM-XXXXX" height="0" width="0" style="display:none;visibility:hidden"></iframe></noscript>',
},
];
}
```
Event: `{ page: PublicPageContext }`
Returns: `PageFragmentContribution | PageFragmentContribution[] | null`
Contribution types:
- `{ kind: "external-script", placement, src, async?, defer?, attributes?, key? }`
- `{ kind: "inline-script", placement, code, attributes?, key? }`
- `{ kind: "html", placement, html, key? }`
Placements: `"head"`, `"body:start"`, `"body:end"`
## Execution Order
1. Lower `priority` values run first
2. Equal priorities: plugin registration order
3. `dependencies` forces a hook to wait for the named plugins regardless of priority
These ordering rules apply to sandboxed and trusted hooks in the shared host pipeline.
## Error Handling
- `errorPolicy: "abort"` (default) — pipeline stops, operation may fail
- `errorPolicy: "continue"` — error logged, remaining hooks still run
These policies apply to sandboxed and trusted hooks in the shared host pipeline.
## Quick Reference
| Hook | Trigger | Capability Required | Return |
| ------------------------- | -------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `plugin:install` | First install | — | `void` |
| `plugin:activate` | Plugin enabled | — | `void` |
| `plugin:deactivate` | Plugin disabled | — | `void` |
| `plugin:uninstall` | Plugin removed | — | `void` |
| `content:beforeSave` | Before save | `content:write` | Modified content, `SandboxHookErrorEnvelope`, or `void` |
| `content:afterSave` | After save | `content:read` | `void` |
| `content:beforeDelete` | Before delete | `content:read` | `false` to cancel |
| `content:afterDelete` | After delete | `content:read` | `void` |
| `content:beforePublish` | Before publish | `hooks.content-policy:register` | `void` or cancellation |
| `content:beforeSchedule` | Before schedule | `hooks.content-policy:register` | `void` or cancellation |
| `content:beforeUnpublish` | Before unpublish | `hooks.content-policy:register` | `void` or cancellation |
| `content:afterPublish` | After publish | `content:read` | `void` |
| `content:afterUnpublish` | After unpublish | `content:read` | `void` |
| `content:afterRestore` | After restore | `content:read` | `void` |
| `content:afterSchedule` | After schedule | `content:read` | `void` |
| `content:afterUnschedule` | After unschedule | `content:read` | `void` |
| `media:beforeUpload` | Before upload | `media:write` | Modified file info or `void` |
| `media:afterUpload` | After upload | `media:read` | `void` |
| `email:beforeSend` | Before email send | `hooks.email-events:register` | Modified message or `false` |
| `email:deliver` | Email delivery | `hooks.email-transport:register` | `void` (exclusive) |
| `email:afterSend` | After email send | `hooks.email-events:register` | `void` |
| `comment:beforeCreate` | Before comment save | `users:read` | Modified event, `false`, or `void` |
| `comment:moderate` | Initial moderation | `users:read` | Moderation decision (exclusive) |
| `comment:afterCreate` | After comment save | `users:read` | `void` |
| `comment:afterModerate` | After status change | `users:read` | `void` |
| `cron` | Scheduled task fires | — | `void` |
| `page:metadata` | Page render | — | Metadata contributions |
| `page:fragments` | Page render | `hooks.page-fragments:register` (native only) | Fragment contributions |
@@ -0,0 +1,25 @@
# Media access
Keep media authority as narrow as the plugin's behavior permits.
| Capability | Access |
| ---------------------- | ---------------------------------------------------------- |
| `media:read` | Ready-media metadata and authenticated ID-based asset URLs |
| `media:bytes:read` | Bounded original bytes and content hashes |
| `media:metadata:write` | Alt text, caption, and complete focal-point updates |
| `media:write` | Upload and delete; implies `media:read` |
`media:read` metadata includes dimensions, alt text, caption, focal point, blurhash, dominant color, and folder ID. It excludes storage keys, author identity, content hashes, and bytes. Logged-out asset requests stop at authentication before the media lookup.
`ctx.media.readBytes(id, { maxBytes })` buffers from the configured storage adapter. The default limit is 10 MiB and callers cannot request more than 16 MiB. The host enforces the limit while consuming the stream rather than trusting stored size metadata.
`ctx.media.updateMetadata()` changes only alt text, caption, and a complete focal-point pair. It cannot upload, replace, move, or delete the underlying file.
With `media:write`, upload bytes through the bridge:
```typescript
const bytes = await source.arrayBuffer();
const uploaded = await ctx.media!.upload("report.pdf", "application/pdf", bytes);
```
Both sandbox runners write through the configured media adapter and create a ready record. A sandbox does not follow a presigned upload URL directly.
@@ -0,0 +1,253 @@
# Portable Text Block Types
Plugin CLI and registry packages cannot define Portable Text block types. `emdash-plugin build` warns that `portableTextBlocks` require trusted mode and omits them from the sandboxed manifest. Site-side rendering also needs an Astro `componentsEntry`, which is loaded with the site at build time.
Core can forward declarative block metadata from a config-declared standard plugin descriptor, but that path does not make the definition portable through the registry and does not provide a site renderer. Treat custom Portable Text blocks as a native-plugin feature unless the site owns both the descriptor and the rendering components and has verified the complete editor-to-render path.
Plugins can add custom block types to the Portable Text editor. These appear in the slash command menu and can be inserted into any `portableText` field.
## Declaring Block Types
In `definePlugin()`, declare blocks under `admin.portableTextBlocks`:
```typescript
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image URL" },
],
},
{
type: "codepen",
label: "CodePen",
icon: "code",
placeholder: "Paste CodePen URL...",
},
],
}
```
### Block Config Fields
| Field | Type | Description |
| ------------- | -------- | ----------------------------------------------- |
| `type` | `string` | Block type name (used in PT `_type`). Required. |
| `label` | `string` | Display name in slash command menu. Required. |
| `icon` | `string` | Icon key. Optional. |
| `description` | `string` | Description in slash command menu. Optional. |
| `placeholder` | `string` | Input placeholder text. Optional. |
| `fields` | `array` | Block Kit form fields for editing UI. Optional. |
### Icons
Named icons: `video`, `code`, `link`, `link-external`. Unknown or missing falls back to a generic cube icon.
### Fields
When `fields` is declared, the editor renders a Block Kit form for editing. When omitted, a simple URL input is shown.
Fields use Block Kit element syntax:
```typescript
fields: [
{
type: "text_input",
action_id: "id",
label: "URL",
placeholder: "https://...",
},
{ type: "text_input", action_id: "title", label: "Title" },
{ type: "text_input", action_id: "poster", label: "Poster Image" },
{ type: "number_input", action_id: "start", label: "Start Time (seconds)" },
{ type: "toggle", action_id: "autoplay", label: "Autoplay" },
{
type: "select",
action_id: "size",
label: "Size",
options: [
{ label: "Small", value: "small" },
{ label: "Medium", value: "medium" },
{ label: "Large", value: "large" },
],
},
];
```
See [Block Kit](./block-kit.md) for element shapes. The Portable Text editor additionally renders `repeater` and `media_picker` authoring elements. Do not assume every element accepted by the shared union renders in every Block Kit surface.
The `action_id` of each field becomes a key in the Portable Text block data. The field with `action_id: "id"` is treated as the primary identifier (typically the URL).
### Data Flow
1. User types `/` in the editor and selects a block type
2. Modal opens with Block Kit form (or simple URL input if no fields)
3. User fills in fields and submits
4. Block is inserted with `_type` set to the block type and field values as properties
5. Editing an existing block re-opens the modal pre-populated
Portable Text output:
```json
{
"_type": "youtube",
"_key": "abc123",
"id": "https://youtube.com/watch?v=dQw4w9WgXcQ",
"title": "Never Gonna Give You Up",
"poster": "https://img.youtube.com/vi/dQw4w9WgXcQ/0.jpg"
}
```
## Site-Side Rendering
To render block types on the site, export Astro components from a `componentsEntry`.
### Component File
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import CodePen from "./CodePen.astro";
// This export name is required
export const blockComponents = {
youtube: YouTube,
codepen: CodePen,
};
```
### Astro Component
```astro
---
// src/astro/YouTube.astro
const { id, title, poster } = Astro.props.node;
// Extract video ID from URL
const videoId = id?.match(/(?:v=|youtu\.be\/)([^&]+)/)?.[1] ?? id;
---
<div class="youtube-embed">
<iframe
src={`https://www.youtube-nocookie.com/embed/${videoId}`}
title={title || "YouTube Video"}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>
</div>
```
Component receives `Astro.props.node` with the full block data.
### Plugin Descriptor
Set `componentsEntry` in the descriptor:
```typescript
export function myPlugin(options = {}): PluginDescriptor {
return {
id: "my-plugin",
entrypoint: "@my-org/my-plugin",
componentsEntry: "@my-org/my-plugin/astro",
version: "1.0.0",
options,
};
}
```
### Package Exports
Add the `./astro` export:
```json
{
"exports": {
".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" },
"./admin": { "types": "./dist/admin.d.ts", "import": "./dist/admin.js" },
"./astro": {
"types": "./dist/astro/index.d.ts",
"import": "./dist/astro/index.js"
}
}
}
```
### Auto-Wiring
Plugin block components are automatically merged into `<PortableText>` on the site. Merge order:
1. EmDash defaults (lowest priority)
2. Plugin block components
3. User-provided components (highest priority)
Site authors don't need to import anything. User components take precedence over plugin defaults.
## Complete Example
```typescript
// src/index.ts
import { definePlugin } from "emdash";
import type { PluginDescriptor } from "emdash";
export function embedsPlugin(options = {}): PluginDescriptor {
return {
id: "embeds",
version: "1.0.0",
entrypoint: "@my-org/plugin-embeds",
componentsEntry: "@my-org/plugin-embeds/astro",
options,
};
}
export function createPlugin() {
return definePlugin({
id: "embeds",
version: "1.0.0",
admin: {
portableTextBlocks: [
{
type: "youtube",
label: "YouTube Video",
icon: "video",
placeholder: "Paste YouTube URL...",
fields: [
{ type: "text_input", action_id: "id", label: "YouTube URL" },
{ type: "text_input", action_id: "title", label: "Title" },
{
type: "text_input",
action_id: "poster",
label: "Poster Image URL",
},
],
},
{
type: "linkPreview",
label: "Link Preview",
icon: "link-external",
placeholder: "Paste any URL...",
},
],
},
});
}
export default createPlugin;
```
```typescript
// src/astro/index.ts
import YouTube from "./YouTube.astro";
import LinkPreview from "./LinkPreview.astro";
export const blockComponents = {
youtube: YouTube,
linkPreview: LinkPreview,
};
```
@@ -0,0 +1,101 @@
# Publishing sandboxed plugins
Use `@emdash-cms/plugin-cli` for registry publishing. The package profile and release records belong to the Atmosphere account named by `publisher` in `emdash-plugin.jsonc`.
## Required package metadata
Before publishing, require:
- `@emdash-cms/plugin-cli` installed in the plugin package;
- a unique plugin `slug`;
- a publisher DID or Atmosphere handle;
- a version in `package.json` or `emdash-plugin.jsonc`;
- a license, at least one author, and at least one security contact;
- declared capabilities and allowed hosts that match the plugin implementation;
- a canonical public GitHub `repo` for delegated releases.
Run validation from the plugin directory:
```sh
pnpm exec emdash-plugin validate
```
## Local publishing
Use local publishing when the maintainer starts the release from their computer:
```sh
pnpm exec emdash-plugin login alice.example.com
pnpm exec emdash-plugin publish
```
`publish` builds and validates the bundle, uploads its files to the publisher's personal data server, and creates the package release record. Use `emdash-plugin bundle` to inspect a tarball without publishing it.
The success output uses the registry identifier `@<publisher-handle>/<slug>`, prints the eventual plugin-page URL, and provides `emdash-plugin info <handle> <slug> --version <version> --watch`. Before approval, `info` reads the labeler's current checks and does not return unapproved package metadata from the aggregator.
## Automated repository releases
Generate the shared workflow from one plugin package, not the monorepo root. Pass `--dir <plugin-directory>` when running the command from elsewhere:
```sh
pnpm exec emdash-plugin release setup
```
The command prepares the current signed package profile and writes `.github/workflows/emdash-release.yml` at the Git repository root. If the manifest omits `repo`, setup detects a GitHub `origin` remote and pre-fills the repository prompt. It does not push the file. The workflow is shared by all plugin packages in that repository and requires no Actions secret.
When `.changeset/config.json` exists at the repository root, interactive setup offers **Follow Changesets releases**. The generated workflow accepts the Changesets Action published-package JSON and publishes packages that also contain `emdash-plugin.jsonc`.
Connect Changesets Action v2 by exposing its outputs from the existing release job and calling the generated workflow:
```yaml
jobs:
release:
# Keep the existing runner, permissions, and steps.
outputs:
published: ${{ steps.changesets.outputs.published }}
published-packages: ${{ steps.changesets.outputs['published-packages'] }}
publish-emdash-plugins:
needs: release
if: needs.release.outputs.published == 'true'
uses: ./.github/workflows/emdash-release.yml
with:
published-packages: ${{ needs.release.outputs['published-packages'] }}
permissions:
contents: read
id-token: write
attestations: write
```
For Changesets Action v1, set the normalized `published-packages` job output from `${{ steps.changesets.outputs.publishedPackages }}` instead. Private EmDash-only packages require both `privatePackages.version: true` and `privatePackages.tag: true`; add unrelated private packages to `ignore`.
Without Changesets, the generated workflow publishes tags in `<slug>@<version>` form:
```sh
git tag gallery@1.2.3
git push origin gallery@1.2.3
```
The workflow runs `release prepare` through the exact plugin CLI version that generated it. The command finds exactly one `emdash-plugin.jsonc` whose slug matches the tag, requires the manifest version to match, builds that package, and writes its outputs for the provenance and release Actions. Duplicate slugs, missing packages, and version mismatches stop before attestation.
The first run uses GitHub OpenID Connect to request a repository connection. The service checks that the initiating package's signed profile names the same repository before creating the request. The publisher approves the repository, workflow file, ref scope, and environment in the release dashboard. A manual run requests approval the first time its branch is used; confirmation adds that scope without removing approved tags or branches.
Prepare another package without changing the workflow. Changesets users add it to a changeset; package-tag users push its tag:
```sh
pnpm exec emdash-plugin profile setup --dir packages/comments
git tag comments@1.0.0
git push origin comments@1.0.0
```
`profile setup` confirms that it published the profile, then prints `emdash-plugin publish` for a manual release and `emdash-plugin release setup` for GitHub Actions.
The later package reuses an approved repository scope only when its signed profile names that repository. Package approvals created by older workflows remain package-scoped until an unmatched package or ref is explicitly approved as a repository connection. Release confirmation remains package-specific: `escalation-only` requires approval when declared access increases, while `always` requires approval for every release.
## Verification boundaries
The release service verifies the GitHub repository and owner IDs, workflow, ref, environment, commit, run, runner, package profile, bundle checksum, manifest identity, declared access, and raw Sigstore provenance. It accepts artifacts only after the repository workflow and signed package profile both authorize the run.
Metadata moderation and release verification are separate. Registry moderation covers displayed package metadata and media. Installation independently verifies release records, bundle checksums, and declared access.
Use the public [Automated plugin releases](https://docs.emdashcms.com/plugins/creating-plugins/delegated-releases/) guide for the complete publisher journey and troubleshooting.
@@ -0,0 +1,43 @@
# Sandbox boundaries
Registry plugins run against a capability-gated host API, not the complete trusted EmDash runtime. Design within exported authoring types and do not infer an API from internal repositories or admin routes.
## Transport limits
- `ctx.http.fetch()` preserves binary requests and responses across both runners, but complete bodies are buffered and limited to 8 MiB of decoded bytes.
- Declared plugin route bodies are buffered with a 1 MiB default and 8 MiB author maximum. Raw route responses are buffered to 8 MiB.
- Media byte reads default to 10 MiB and cannot request more than 16 MiB.
- Neither test host reproduces deployed CPU, memory, or subrequest limits.
## Trusted-only surfaces
- `page:fragments` is accepted by shared authoring types but excluded from sandbox registration. Use validated `page:metadata`; raw HTML, scripts, and styles require a trusted native plugin.
- React admin code, Astro render components, build-time integrations, host bindings, Node built-ins, TCP sockets, and direct database access require trusted site code.
- Custom Portable Text definitions and Astro renderers remain native-only. Registry packages can use the declarative admin surfaces that the CLI serializes.
## Content and schema limits
- Schema access is read-only. Plugins cannot create, alter, attach, or delete collection definitions through `ctx.schema`.
- Content writes do not expose permanent deletion.
- Publication policy hooks can reject publish, schedule, and unpublish, but cannot transform the action or restore content.
- Publication and restore actions require separate capabilities and opaque revisions.
- Public URL resolution never returns previews.
## Taxonomy, redirect, comment, and media limits
- Taxonomy writes cannot manage definitions, attachments, term updates or deletion, or replace all assignments.
- Redirect writes cannot set host-owned fields and remain subject to host validation.
- Comment administration excludes trashed comments and linked user-account IDs. It cannot hard-delete or bulk-replace statuses.
- Media metadata writes cannot upload, replace, move, or delete files. Byte reads and content hashes require separate authority.
## Routes and admin UI
- `public: true` removes host authentication. It does not create a restricted public view; validate requests and return the minimum data.
- Raw routes use `pluginResponse()`, an allowlisted set of representation/download/redirect headers, host-owned caching and security headers, and no active same-origin browser content. They cannot back MCP tools.
- Credential, cookie, Cloudflare Access, proxy authorization, and EmDash CSRF headers never cross declared route-header boundaries.
- Saved-entry panels and actions receive canonical saved identity and version. Ordinary panel load and typing never send unsaved state. An explicit interaction can receive only extension-selected draft fields with `admin.editor-draft:read`, and can propose an atomic whole-field patch with the independent `admin.editor-draft:patch` capability. The host validates and previews a patch but never saves it automatically. Use capability-gated APIs to read saved content.
- `routeCtx.ui` carries host-attested admin locale, direction, and surface. Manifest labels remain static strings; the host does not consume plugin translation catalogs.
## Runner-only methods are not portable
Methods absent from public authoring types are not part of the registry contract even if one wrapper contains them. Write against `SandboxedPlugin` and exported context types, then test the feature through both bridges when behavior is runner-sensitive.
@@ -0,0 +1,213 @@
# Storage and KV
Sandboxed plugins have three plugin-scoped data APIs:
| API | Use |
| -------------------------- | ------------------------------------------------------- |
| `ctx.storage.<collection>` | Queryable records declared in `emdash-plugin.jsonc` |
| `ctx.settings` | User-configurable settings, including encrypted secrets |
| `ctx.kv` | Cursors, cached values, and other key-value state |
All three stores use the host database and are isolated by runtime plugin ID. None needs a capability.
## Declare storage collections
Declare every collection and query index in the manifest:
```jsonc title="emdash-plugin.jsonc"
{
"storage": {
"submissions": {
"indexes": ["formId", "status", "createdAt", ["formId", "createdAt"]],
"uniqueIndexes": ["externalId"],
},
},
}
```
An undeclared collection is rejected by the sandbox bridge. Fields in `uniqueIndexes` are already queryable; do not repeat them in `indexes`.
## Collection operations
Every declared collection exposes this portable API in native, Cloudflare-sandboxed, and Node/workerd-sandboxed execution:
```typescript
interface StorageCollection<T = unknown> {
get(id: string): Promise<T | null>;
put(id: string, data: T): Promise<void>;
delete(id: string): Promise<boolean>;
exists(id: string): Promise<boolean>;
getVersioned(id: string): Promise<{ value: T; revision: string } | null>;
compareAndSet(
id: string,
expectedRevision: string | null,
data: T,
): Promise<{ applied: true; revision: string } | { applied: false }>;
compareAndDelete(id: string, expectedRevision: string): Promise<{ applied: boolean }>;
updateIf(id: string, args: UpdateIfArgs<T>): Promise<UpdateIfResult<T>>;
getMany(ids: string[]): Promise<Map<string, T>>;
putMany(items: Array<{ id: string; data: T }>): Promise<void>;
deleteMany(ids: string[]): Promise<number>;
query(options?: QueryOptions): Promise<{
items: Array<{ id: string; data: T }>;
cursor?: string;
hasMore: boolean;
}>;
count(where?: WhereClause): Promise<number>;
}
```
The Node/workerd wrapper also contains content batch methods. They are not part of `StorageCollection`; storage batch methods in the interface above are portable across both runners.
## Basic and batch writes
```typescript
const submissions = ctx.storage.submissions as StorageCollection<Submission>;
await submissions.put("sub_123", {
formId: "contact",
status: "pending",
createdAt: new Date().toISOString(),
});
const item = await submissions.get("sub_123");
const exists = await submissions.exists("sub_123");
const items = await submissions.getMany(["sub_123", "sub_456"]);
await submissions.putMany([
{ id: "sub_456", data: { formId: "contact", status: "pending" } },
{ id: "sub_789", data: { formId: "sales", status: "pending" } },
]);
const deleted = await submissions.deleteMany(["sub_456", "sub_789"]);
```
`getMany()` returns a `Map`, including after crossing either sandbox bridge.
## Revision-based compare and set
Use `getVersioned()`, `compareAndSet()`, and `compareAndDelete()` when concurrent requests may replace the same whole value.
```typescript
const current = await submissions.getVersioned("sub_123");
if (!current) throw new Error("Submission not found");
const result = await submissions.compareAndSet("sub_123", current.revision, {
...current.value,
status: "processing",
});
if (!result.applied) {
// Another request changed or deleted the value. Read it again before retrying.
}
```
The operations have these preconditions:
| Operation | Behavior |
| ------------------------------------- | --------------------------------------------------------- |
| `getVersioned(key)` | Returns `{ value, revision }`, or `null` only when absent |
| `compareAndSet(key, null, value)` | Creates only when absent |
| `compareAndSet(key, revision, value)` | Replaces only when the current revision matches |
| `compareAndDelete(key, revision)` | Deletes only when the current revision matches |
A stored JSON `null` still returns a versioned envelope. Every successful write changes the revision, including an equal-value `put()` or `set()`. Revisions are opaque, key-specific values; pass them back unchanged.
Conflicts return `applied: false`. Invalid inputs, permission failures, unique-index violations, and database failures reject. After a conflict, re-read and recompute; keep retries bounded. A lost response can leave the write outcome unknown, so CAS is not an exactly-once mechanism for external side effects.
The versioned methods are also available on `ctx.kv`:
```typescript
const current = await ctx.kv.getVersioned<number>("state:completed");
const next = (current?.value ?? 0) + 1;
const result = await ctx.kv.compareAndSet("state:completed", current?.revision ?? null, next);
```
## Predicate-guarded updates
`updateIf()` changes fields of an existing document when its stored data matches a guard. The guard, field replacements, and integer deltas execute atomically for that record.
```typescript
const result = await submissions.updateIf("sub_123", {
where: { status: "pending", attempts: { lt: 3 } },
set: { status: "processing", lastAttemptAt: new Date().toISOString() },
delta: { attempts: { inc: 1 } },
});
if (result.applied) {
ctx.log.info("Claimed submission", { submission: result.data });
}
```
`updateIf()` returns `{ applied: false }` when the row is absent, the guard fails, the stored document is not an object, or integer arithmetic is unsafe. It never inserts.
Rules:
- `where` is required. An explicit `{}` means “match any existing row.”
- `set` replaces supplied top-level fields and leaves other fields unchanged.
- `delta` contains exactly one safe-integer `inc` or `dec` per field. Missing or `null` counters start at zero.
- A field cannot appear in both `set` and `delta`.
- At least one defined field must remain in `set` or `delta`.
- Pair `dec: n` with a `gte: n` guard when the value must stay nonnegative.
Malformed arguments reject without writing. In native PostgreSQL execution, serialization failures and deadlocks throw `StorageSerializationError` with `code: "STORAGE_SERIALIZATION_FAILURE"` and `retryable: true`. Sandbox transports preserve the safe fields but do not guarantee `instanceof`; check `code` and `retryable`. Restart an entire explicit transaction before retrying it.
## Indexed queries
Only declared index fields can be filtered or ordered:
```typescript
const result = await submissions.query({
where: {
formId: "contact",
status: { in: ["pending", "processing"] },
createdAt: { gte: "2026-01-01" },
},
orderBy: { createdAt: "desc" },
limit: 100,
cursor,
});
```
Supported filters are exact values, `{ in: [...] }`, `{ startsWith: "..." }`, and range objects using `gt`, `gte`, `lt`, or `lte`. A range needs at least one defined bound.
`query()` defaults to 50 items and returns at most 100 per page. Follow `cursor` while `hasMore` is true. `count(where)` accepts the same indexed filters.
Composite index order determines useful query shapes. `['formId', 'createdAt']` supports filtering by `formId` and ordering by `createdAt`; it does not replace a standalone `createdAt` index for queries that omit `formId`.
## KV operations
KV supports unconditional, versioned, delete, and prefix-list operations:
```typescript
interface KVAccess {
get<T>(key: string): Promise<T | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<boolean>;
list(prefix?: string): Promise<Array<{ key: string; value: unknown }>>;
getVersioned<T>(key: string): Promise<{ value: T; revision: string } | null>;
compareAndSet(
key: string,
expectedRevision: string | null,
value: unknown,
): Promise<{ applied: true; revision: string } | { applied: false }>;
compareAndDelete(key: string, expectedRevision: string): Promise<{ applied: boolean }>;
}
```
Use stable prefixes to keep internal KV keys discoverable:
```typescript
await ctx.settings.set("webhookUrl", url);
await ctx.kv.set("state:lastRun", new Date().toISOString());
await ctx.kv.set("cache:summary", summary);
const settings = await ctx.settings.list();
```
The plugin CLI serializes `admin.settingsSchema`, and both sandbox bridges route `ctx.settings` through the same options records as the generated admin form. Values saved in that form are available through `ctx.settings.get("<key>")`. The complete settings API supports `set`, `delete`, `list`, `getVersioned`, `compareAndSet`, and `compareAndDelete`.
Fields declared as `secret` use a versioned AES-GCM envelope with the plugin ID and setting key as authenticated data. `EMDASH_ENCRYPTION_KEY` may contain a comma-separated rotation list: the first key encrypts new values and the envelope's `kid` selects a key for reads. Missing, wrong, and tampered keys fail closed without exposing plaintext. Existing plaintext secrets remain readable and become encrypted when saved again.
Keep the full encryption-key list with operational backups. Restoring the database without every key referenced by its encrypted settings leaves those values unreadable. `ctx.kv.get("settings:<key>")` remains a compatibility alias throughout EmDash 1.x; new plugins should use `ctx.settings`.
@@ -0,0 +1,19 @@
# Taxonomies and redirects
## Taxonomies
`taxonomies:read` exposes taxonomy definitions, terms, and entry assignments. `taxonomies:write` implies read and adds `createTerm()`, `addEntryTerms()`, and `removeEntryTerms()`.
Term assignment methods accept term row IDs or translation-group IDs, not slugs. Add and remove operations apply idempotent deltas rather than replacing the full set, so concurrent additions do not overwrite one another.
The host validates taxonomy attachment, entry and term ownership, configured locales, translation identity, and hierarchy. `createTerm()` rejects a parent for a flat taxonomy. Definition management, attachment changes, assignment replacement, term updates, and term deletion remain unavailable.
## Redirects
`redirects:read` exposes cursor-paged listing and versioned reads. `redirects:write` implies read and adds create, update, and delete.
Pass the opaque redirect `_rev` unchanged to update and delete. A stale revision returns `CONFLICT`; re-read before retrying. Redirect writes use the host's source-pattern, destination-parameter, duplicate-source, status, and loop validation. Plugin input cannot set host-owned fields such as the automatic-rule marker.
Changing redirect rules changes where visitors are sent, so request this capability only when the plugin owns that behavior.
For runtime tests, establish redirect state with fixtures, invoke the plugin through the real route/action boundary, and inspect persisted rules. Cover stale revisions and loop or destination validation when relevant.