Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default defineConfig({
items: [
{ label: "Plugin Overview", slug: "plugins/overview" },
{ label: "Installing Plugins", slug: "plugins/installing" },
{ label: "Upgrading Plugins", slug: "plugins/upgrading-sites" },
{ label: "Field Kit", slug: "plugins/field-kit" },
],
},
Expand All @@ -102,6 +103,14 @@ export default defineConfig({
label: "Your First Plugin",
slug: "plugins/creating-plugins/your-first-plugin",
},
{
label: "The Manifest",
slug: "plugins/creating-plugins/manifest",
},
{
label: "The Plugin CLI",
slug: "plugins/creating-plugins/cli",
},
{ label: "Hooks", slug: "plugins/creating-plugins/hooks" },
{ label: "API Routes", slug: "plugins/creating-plugins/api-routes" },
{ label: "Storage", slug: "plugins/creating-plugins/storage" },
Expand All @@ -115,6 +124,10 @@ export default defineConfig({
label: "Bundling & Publishing",
slug: "plugins/creating-plugins/publishing",
},
{
label: "Migrating to the CLI",
slug: "plugins/creating-plugins/migrating-to-the-cli",
},
],
},
{
Expand Down
41 changes: 23 additions & 18 deletions docs/src/content/docs/plugins/creating-plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,20 @@ import { Aside, Tabs, TabItem } from "@astrojs/starlight/components";

Plugins can expose API routes for their admin UI and external integrations. Routes are mounted under `/_emdash/api/plugins/<plugin-id>/<route-name>` and run inside the sandbox runtime with the same `PluginContext` that hooks receive.

This page covers sandboxed (standard-format) plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for details.
This page covers sandboxed plugins. The API surface for native plugins is the same; the only difference is the handler signature — see the note in [Native plugins](/plugins/creating-native-plugins/your-first-native-plugin/) for details.

## Defining routes

Declare routes in `definePlugin()` from your `sandbox-entry.ts`:
Declare routes in the default export of `src/plugin.ts`:

```typescript title="src/sandbox-entry.ts"
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
```typescript title="src/plugin.ts"
import type { SandboxedPlugin } from "emdash/plugin";
import { z } from "astro/zod";

export default definePlugin({
export default {
routes: {
status: {
handler: async (_routeCtx, ctx: PluginContext) => {
handler: async (_routeCtx, ctx) => {
return { ok: true, plugin: ctx.plugin.id };
},
},
Expand All @@ -32,7 +31,7 @@ export default definePlugin({
limit: z.number().default(50),
cursor: z.string().optional(),
}),
handler: async (routeCtx, ctx: PluginContext) => {
handler: async (routeCtx, ctx) => {
const { formId, limit, cursor } = routeCtx.input;

const result = await ctx.storage.submissions.query({
Expand All @@ -46,10 +45,10 @@ export default definePlugin({
},
},
},
});
} satisfies SandboxedPlugin;
```

Standard-format route handlers take **two arguments**: `(routeCtx, ctx)`.
`satisfies SandboxedPlugin` infers `routeCtx` and `ctx` — no parameter annotations needed. Sandboxed route handlers take **two arguments**: `(routeCtx, ctx)`.

- `routeCtx` carries request-shaped data: `{ input, request, requestMeta }`.
- `ctx` is the same `PluginContext` you get inside hooks — `ctx.storage`, `ctx.kv`, `ctx.content`, `ctx.http`, `ctx.log`, etc.
Expand All @@ -66,7 +65,7 @@ Routes mount at `/_emdash/api/plugins/<plugin-id>/<route-name>`. Route names can
| `analytics` | `events/recent` | `/_emdash/api/plugins/analytics/events/recent` |

<Aside type="caution">
Plugin ids must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The `pluginId` segment in the route is a single Astro path segment, so anything outside that pattern (scoped names, leading digits, dots, slashes) won't route, and storage-index provisioning rejects it too. Pair an unscoped id with a scoped npm package name in `entrypoint`, the way first-party plugins do (`id: "field-kit"`, `entrypoint: "@emdash-cms/plugin-field-kit"`).
The plugin's `slug` (in `emdash-plugin.jsonc`) must match `/^[a-z][a-z0-9_-]*$/` — start with a lowercase letter, then letters, digits, hyphens, or underscores. The `pluginId` segment in the route is a single Astro path segment, so anything outside that pattern (scoped names, leading digits, dots, slashes) won't route, and storage-index provisioning rejects it too. Pair an unscoped `slug` with a scoped npm package name, the way first-party plugins do (`slug: "field-kit"`, package `@emdash-cms/plugin-field-kit`).
</Aside>

## Authentication and CSRF
Expand Down Expand Up @@ -191,17 +190,17 @@ routes: {

## Accessing the request

The full `Request` object is available as `routeCtx.request` for headers, raw body access, and URL parsing. `routeCtx.requestMeta` carries IP, user agent, and geo data normalised across platforms.
`routeCtx.request` is a **`SandboxedRequest`**: a portable `{ url, method, headers }` record that behaves identically in-process and inside an isolate. `headers` is a `Record<string, string>` keyed by lowercased header name — index it by the lowercased name, or iterate with `Object.entries`. `url` is a string, so `new URL(request.url)` parses query params. `routeCtx.requestMeta` carries IP, user agent, and geo data normalised across platforms when available.

```typescript
handler: async (routeCtx, ctx) => {
const { request, requestMeta } = routeCtx;

const auth = request.headers.get("Authorization");
const auth = request.headers["authorization"]; // lowercased key, no .get()
const url = new URL(request.url);
const page = url.searchParams.get("page");

ctx.log.info("Request", { ip: requestMeta.ip, ua: requestMeta.userAgent });
ctx.log.info("Request", { meta: requestMeta });

if (request.method !== "POST") {
throw new Response("POST required", { status: 405 });
Expand Down Expand Up @@ -348,12 +347,18 @@ curl -X POST https://your-site.com/_emdash/api/plugins/forms/create \
## Route context reference

```typescript
// What standard-format handlers receive as their two arguments
// What sandboxed route handlers receive as their two arguments

interface StandardRouteContext<TInput = unknown> {
interface SandboxedRequest {
url: string;
method: string;
headers: Record<string, string>; // lowercased keys
}

interface SandboxedRouteContext<TInput = unknown> {
input: TInput;
request: Request;
requestMeta: { ip: string | null; userAgent: string | null; geo?: GeoData };
request: SandboxedRequest;
requestMeta?: unknown;
}

interface PluginContext {
Expand Down
11 changes: 5 additions & 6 deletions docs/src/content/docs/plugins/creating-plugins/block-kit.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,7 @@ EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The h
6. The plugin returns new blocks, and the cycle repeats.

```typescript
import { definePlugin } from "emdash";
import type { PluginContext } from "emdash";
import type { SandboxedPlugin } from "emdash/plugin";

interface BlockInteraction {
type: "page_load" | "block_action" | "form_submit";
Expand All @@ -35,10 +34,10 @@ interface BlockInteraction {
values?: Record<string, unknown>;
}

export default definePlugin({
export default {
routes: {
admin: {
handler: async (routeCtx, ctx: PluginContext) => {
handler: async (routeCtx, ctx) => {
const interaction = routeCtx.input as BlockInteraction;

if (interaction.type === "page_load") {
Expand Down Expand Up @@ -70,10 +69,10 @@ export default definePlugin({
},
},
},
});
} satisfies SandboxedPlugin;
```

The standard-format route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`).
The route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`). `satisfies SandboxedPlugin` infers both.

## Block types

Expand Down
60 changes: 18 additions & 42 deletions docs/src/content/docs/plugins/creating-plugins/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,25 +5,21 @@ description: Declare what your plugin needs, and understand what the sandbox enf

import { Aside, Steps } from "@astrojs/starlight/components";

Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to **declare a capability** on its descriptor. The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn't declare `content:read` doesn't get a `ctx.content`, and one that didn't declare `network:request` doesn't get `ctx.http`.
Sandboxed plugins are isolated by default. To do anything beyond reading and writing their own KV and storage, a plugin has to **declare a capability** in its [manifest](/plugins/creating-plugins/manifest/). The sandbox bridge gates every host-provided API based on those declarations — a plugin that didn't declare `content:read` doesn't get a `ctx.content`, and one that didn't declare `network:request` doesn't get `ctx.http`.

This page covers what each capability grants, how the sandbox enforces them, and what's not enforceable.

## Declaring capabilities

Capabilities live on the descriptor (the file imported by `astro.config.mjs`), alongside `id`, `version`, and `entrypoint`:
Capabilities live in `emdash-plugin.jsonc`, alongside `slug` and the rest of the trust contract:

```typescript title="src/index.ts"
export function helloPlugin(): PluginDescriptor {
return {
id: "plugin-hello",
version: "0.1.0",
format: "standard",
entrypoint: "@my-org/plugin-hello/sandbox",
```jsonc title="emdash-plugin.jsonc"
{
"slug": "plugin-hello",
// ...identity + profile...

capabilities: ["content:read", "network:request"],
allowedHosts: ["api.example.com"],
};
"capabilities": ["content:read", "network:request"],
"allowedHosts": ["api.example.com"]
}
```

Expand Down Expand Up @@ -51,35 +47,16 @@ A few things worth knowing:
- **`network:request:unrestricted` exists for user-configured URLs.** A webhook plugin where the operator types in the destination URL needs to reach hosts that aren't in the manifest. Plugins that always call known APIs should use `network:request` + `allowedHosts`.
- **`email:send` is gated by configuration, not just the capability.** A plugin can declare `email:send`, but `ctx.email` will only be populated if some other plugin has registered an `email:deliver` transport.

<Aside type="caution" title="Renamed in this minor">
Some capability names were unified. Old names still work with deprecation warnings and will be removed in the next minor:

| Old | New |
| -------------------- | ---------------------------------- |
| `read:content` | `content:read` |
| `write:content` | `content:write` |
| `read:media` | `media:read` |
| `write:media` | `media:write` |
| `read:users` | `users:read` |
| `network:fetch` | `network:request` |
| `network:fetch:any` | `network:request:unrestricted` |
| `email:provide` | `hooks.email-transport:register` |
| `email:intercept` | `hooks.email-events:register` |
| `page:inject` | `hooks.page-fragments:register` |

`emdash plugin bundle` and `emdash plugin validate` warn for each deprecated name. `emdash plugin publish` rejects manifests that still use deprecated names — re-bundle after renaming.
</Aside>

## Network host allowlists

Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. Wildcards are supported for subdomains:

```typescript
capabilities: ["network:request"],
allowedHosts: [
"api.example.com", // exact host
"*.cdn.example.com", // any subdomain of cdn.example.com
],
```jsonc title="emdash-plugin.jsonc"
"capabilities": ["network:request"],
"allowedHosts": [
"api.example.com", // exact host
"*.cdn.example.com" // any subdomain of cdn.example.com
]
```

The bridge checks the request URL's host against the allowlist before forwarding the request. A request to a host that wasn't declared throws inside the plugin without ever leaving the sandbox.
Expand All @@ -94,7 +71,7 @@ When a sandbox runner is active, the runtime enforces:

1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.media`, `ctx.http`, `ctx.users`, `ctx.email` when the corresponding capability is declared. Calling a method on an undeclared capability isn't possible — there's no object there.

2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's id. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared on the descriptor.
2. **Storage and KV scoping.** Every storage and KV operation is scoped to the plugin's slug. A plugin can't read another plugin's KV or its storage collections, and it can only access storage collections it declared in the manifest.

3. **Network isolation.** Direct `fetch()` and other network primitives are blocked by the runner. The only way to reach the network is `ctx.http.fetch()`, which goes through the bridge's host validation.

Expand All @@ -120,11 +97,10 @@ This is why declaring extra capabilities matters even if you "might use them lat

## Bundle-time validation

`emdash plugin bundle` and `emdash plugin publish` perform additional checks:
`emdash-plugin bundle` and `emdash-plugin publish` perform additional checks:

- Every declared capability must be in the known set (typos fail the build).
- A plugin that declares `network:request` without populating `allowedHosts` triggers a warning — declare hosts or switch to `network:request:unrestricted` and document why.
- Deprecated capability names trigger warnings during `bundle`/`validate` and a hard fail on `publish`.
- Every declared capability must be in the recognised set (typos fail the build).
- `network:request` requires a non-empty `allowedHosts`; `network:request:unrestricted` requires it to be empty. See [the manifest reference](/plugins/creating-plugins/manifest/#capabilities).
- The bundled `backend.js` can't import Node.js built-ins (`fs`, `path`, `child_process`, etc.) — sandbox runtimes don't provide them.

See [Bundling and publishing](/plugins/creating-plugins/publishing/) for the full list of checks.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Choose native only when you need a feature the sandbox can't provide.

| | Sandboxed | Native |
| ----------------------------------- | -------------------------------------------------- | ------------------------------------- |
| `format` field on descriptor | `"standard"` | `"native"` |
| Authoring shape | `emdash-plugin.jsonc` + `src/plugin.ts` | `definePlugin()` descriptor |
| Install method | One-click from the admin marketplace | `npm install` + edit `astro.config` |
| Runs in | An isolated runtime provided by a sandbox runner | Same process as your Astro site |
| Capabilities | Enforced by the sandbox bridge | Enforced in-process by the same bridge |
Expand Down Expand Up @@ -45,7 +45,7 @@ If your plugin can do its job in the sandbox, it should.
There are three reasons to choose native, and they're all about features that need build-time integration with the host site:

1. **Custom React admin pages or widgets.** Sandboxed plugins describe their admin UI with Block Kit — a JSON schema that the admin renders on the plugin's behalf. If you need full React (custom hooks, third-party components, complex state), you need native.
2. **Astro components for rendering Portable Text blocks on the public site.** A plugin can declare a custom block type with `format: "standard"`, but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a `componentsEntry`.
2. **Astro components for rendering Portable Text blocks on the public site.** A sandboxed plugin can declare a custom block type, but the Astro components that render it on the public site have to be loaded at build time from npm. Only native plugins can provide a `componentsEntry`.
3. **Injecting raw HTML, scripts, or stylesheets into public pages.** The `page:fragments` hook ships first-party code to visitors' browsers — outside any sandbox boundary. It's restricted to native plugins. Sandboxed plugins can still contribute to public pages through the `page:metadata` hook, which covers a lot of real use cases:

- `meta` tags (`name` + `content`) — SEO descriptions, robots directives, Twitter cards
Expand All @@ -71,7 +71,7 @@ The runner most sites use today is `sandbox()` from `@emdash-cms/cloudflare`, wh

If no runner is configured, or if the configured runner reports as unavailable on the current platform, plugins listed under `sandboxed: []` are skipped at startup with a debug-level log.

If you want a standard-format plugin to run on a platform without a sandbox runner, move it from `sandboxed: []` into the `plugins: []` array — it'll execute in-process. Capability declarations are still honoured (the same `PluginContext` factory gates `ctx.content`, `ctx.http`, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call `fetch()` directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes.
If you want a sandboxed plugin to run on a platform without a sandbox runner, move it from `sandboxed: []` into the `plugins: []` array — it'll execute in-process. Capability declarations are still honoured (the same `PluginContext` factory gates `ctx.content`, `ctx.http`, and friends), but there is no isolation boundary, no resource limits, and a buggy or malicious plugin can call `fetch()` directly, read environment variables, or block the event loop. Without a sandbox runner active, treat every plugin as a native plugin for trust purposes.

## Next

Expand Down
Loading
Loading