Skip to content

Commit aff5de6

Browse files
authored
docs: reconcile sandboxed plugin authoring guides (emdash-cms#3047)
* docs: correct unsafe deployment and recovery guidance * docs: clarify safety guidance * docs: trim repeated safety guidance * docs: reconcile sandboxed plugin authoring guides
1 parent efb7677 commit aff5de6

13 files changed

Lines changed: 751 additions & 681 deletions

docs/src/content/docs/plugins/creating-plugins/api-routes.mdx

Lines changed: 86 additions & 172 deletions
Large diffs are not rendered by default.

docs/src/content/docs/plugins/creating-plugins/block-kit.mdx

Lines changed: 81 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@ EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The h
1212
</Aside>
1313

1414
<Aside type="tip">
15-
Block Kit elements are also used for [Portable Text block editing fields](/plugins/creating-native-plugins/portable-text-components/). When a plugin declares `fields` on a block type, the editor renders a Block Kit form for editing block data (URL, title, parameters, etc.).
15+
Native plugins also use Block Kit elements to describe [Portable Text block editing
16+
fields](/plugins/creating-native-plugins/portable-text-components/). Sandboxed plugins cannot
17+
register custom Portable Text block types because their rendering components would need to load
18+
into the host site at build time.
1619
</Aside>
1720

1821
## How it works
@@ -24,43 +27,77 @@ EmDash's Block Kit lets sandboxed plugins describe their admin UI as JSON. The h
2427
5. When the user interacts (clicks a button, submits a form), the admin sends the interaction back to the plugin.
2528
6. The plugin returns new blocks, and the cycle repeats.
2629

27-
```typescript
28-
import type { SandboxedPlugin } from "emdash/plugin";
30+
Add `@emdash-cms/blocks` and `zod` to the plugin when it defines a Block Kit page:
31+
32+
```sh
33+
pnpm add @emdash-cms/blocks zod
34+
```
2935

30-
interface BlockInteraction {
31-
type: "page_load" | "block_action" | "form_submit";
32-
page?: string;
33-
action_id?: string;
34-
values?: Record<string, unknown>;
36+
Declare the page in the plugin manifest so the admin has a navigation entry to load:
37+
38+
```jsonc title="emdash-plugin.jsonc"
39+
"admin": {
40+
"pages": [{ "path": "/settings", "label": "Settings", "icon": "settings" }],
41+
}
42+
```
43+
44+
The following `admin` route validates the interaction, renders a form on page load, and stores its values on submit:
45+
46+
```typescript title="src/plugin.ts"
47+
import type { SandboxedPlugin } from "emdash/plugin";
48+
import type { BlockResponse } from "@emdash-cms/blocks";
49+
import { z } from "zod";
50+
51+
const interactionSchema = z.discriminatedUnion("type", [
52+
z.object({ type: z.literal("page_load"), page: z.string() }),
53+
z.object({
54+
type: z.literal("block_action"),
55+
action_id: z.string(),
56+
block_id: z.string().optional(),
57+
value: z.unknown().optional(),
58+
}),
59+
z.object({
60+
type: z.literal("form_submit"),
61+
action_id: z.string(),
62+
block_id: z.string().optional(),
63+
values: z.object({ api_url: z.url(), enabled: z.boolean() }),
64+
}),
65+
]);
66+
67+
function renderSettings(): BlockResponse {
68+
return {
69+
blocks: [
70+
{ type: "header", text: "Save Log settings" },
71+
{
72+
type: "form",
73+
block_id: "settings",
74+
fields: [
75+
{ type: "text_input", action_id: "api_url", label: "API URL" },
76+
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
77+
],
78+
submit: { label: "Save", action_id: "save" },
79+
},
80+
],
81+
};
3582
}
3683

3784
export default {
3885
routes: {
3986
admin: {
4087
handler: async (routeCtx, ctx) => {
41-
const interaction = routeCtx.input as BlockInteraction;
88+
const parsed = interactionSchema.safeParse(routeCtx.input);
89+
if (!parsed.success) return { blocks: [] };
90+
const interaction = parsed.data;
4291

4392
if (interaction.type === "page_load") {
44-
return {
45-
blocks: [
46-
{ type: "header", text: "My Plugin Settings" },
47-
{
48-
type: "form",
49-
block_id: "settings",
50-
fields: [
51-
{ type: "text_input", action_id: "api_url", label: "API URL" },
52-
{ type: "toggle", action_id: "enabled", label: "Enabled", initial_value: true },
53-
],
54-
submit: { label: "Save", action_id: "save" },
55-
},
56-
],
57-
};
93+
return renderSettings();
5894
}
5995

6096
if (interaction.type === "form_submit" && interaction.action_id === "save") {
61-
await ctx.kv.set("settings", interaction.values);
97+
await ctx.kv.set("settings:apiUrl", interaction.values.api_url);
98+
await ctx.kv.set("settings:enabled", interaction.values.enabled);
6299
return {
63-
blocks: [/* ... updated blocks ... */],
100+
...renderSettings(),
64101
toast: { message: "Settings saved", type: "success" },
65102
};
66103
}
@@ -72,7 +109,7 @@ export default {
72109
} satisfies SandboxedPlugin;
73110
```
74111

75-
The route handler takes two arguments: `routeCtx` (with `input`, `request`, `requestMeta`) and `ctx` (the `PluginContext`). `satisfies SandboxedPlugin` infers both.
112+
The `admin` route is private by default. EmDash sends the correct CSRF header when the admin calls it. The handler still validates `routeCtx.input` because its TypeScript type is `unknown` and a caller can invoke a private plugin route outside the Block Kit page.
76113

77114
## Block types
78115

@@ -86,11 +123,16 @@ The route handler takes two arguments: `routeCtx` (with `input`, `request`, `req
86123
| `actions` | Horizontal row of buttons and controls |
87124
| `stats` | Dashboard metric cards with trend indicators |
88125
| `form` | Input fields with conditional visibility and submit |
89-
| `image` | Block-level image with caption |
126+
| `image` | Block-level image with alt text and an optional title |
90127
| `context` | Small muted help text |
91128
| `columns` | 2–3 column layout with nested blocks |
92-
| `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons |
129+
| `empty` | Empty-state title with an optional description, command, and action buttons |
93130
| `accordion` | Collapsible section wrapping nested blocks |
131+
| `chart` | Line or bar time series, or a chart with custom options |
132+
| `banner` | Status or alert message with a title or description |
133+
| `meter` | Numeric value displayed against a minimum and maximum |
134+
| `code` | Read-only TypeScript, TSX, JSONC, Bash, or CSS code |
135+
| `tab` | Labeled panels containing nested blocks |
94136

95137
## Element types
96138

@@ -102,16 +144,22 @@ The route handler takes two arguments: `routeCtx` (with `input`, `request`, `req
102144
| `select` | Dropdown select |
103145
| `toggle` | On/off switch |
104146
| `secret_input` | Masked input for API keys and tokens |
147+
| `checkbox` | Select several values from a fixed list |
148+
| `combobox` | Searchable single-value selection |
149+
| `date_input` | Date value |
150+
| `radio` | Single choice from a visible option list |
151+
152+
The Portable Text field editor also supports `repeater` and `media_picker`. They are not form fields for a sandboxed plugin admin page.
105153

106154
## Builder helpers
107155

108-
The `@emdash-cms/blocks` package exports builder helpers for cleaner code:
156+
The `@emdash-cms/blocks` package exports the same shapes through `blocks` and `elements` builder objects. Builders reduce property-name mistakes while returning ordinary JSON-compatible objects:
109157

110158
```typescript
111159
import { blocks, elements } from "@emdash-cms/blocks";
112160

113-
const { header, form, section, stats } = blocks;
114-
const { textInput, toggle, select, button } = elements;
161+
const { header, form } = blocks;
162+
const { textInput, toggle, select } = elements;
115163

116164
return {
117165
blocks: [
@@ -155,6 +203,8 @@ Form fields can be conditionally shown based on other field values:
155203

156204
The `api_key` field only appears when `auth_enabled` is toggled on. Conditions are evaluated client-side with no round-trip.
157205

206+
`secret_input` uses `has_value: true` to show that a value already exists; it does not accept or return the stored value on page load. The field masks typing in the browser, but it does not encrypt a value written to `ctx.kv`. Follow [Secret settings](/plugins/creating-plugins/settings/#secret-values) before storing credentials.
207+
158208
## Try it
159209

160210
Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) to interactively build and test block layouts.

docs/src/content/docs/plugins/creating-plugins/capabilities.mdx

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Capabilities live in `emdash-plugin.jsonc`, alongside `slug` and the rest of the
2323
}
2424
```
2525

26-
Declare only what the plugin actually needs. Capability declarations are also what the marketplace shows site operators on the consent dialog — extra capabilities are friction at install time and a security flag in audits.
26+
Declare only what the plugin actually needs. The marketplace shows these capabilities to site operators before installation, so every extra declaration asks them to approve access the plugin does not use.
2727

2828
## Capability reference
2929

@@ -42,7 +42,7 @@ Declare only what the plugin actually needs. Capability declarations are also wh
4242
| `hooks.email-events:register` | Allows registering `email:beforeSend` / `email:afterSend` hooks |
4343
| `hooks.page-fragments:register` | Allows registering the `page:fragments` hook (native plugins only) |
4444

45-
A few things worth knowing:
45+
The following rules affect which capabilities a plugin needs:
4646

4747
- **Implications.** `content:write` automatically implies `content:read`; `media:write` implies `media:read`; `network:request:unrestricted` implies `network:request`. You don't need to list both.
4848
- **Taxonomies are a separate, read-only surface.** `taxonomies:read` grants access to taxonomy definitions, their terms, and the terms assigned to an entry via `ctx.taxonomies`. It is independent of `content:read` — declare both if the plugin reads content *and* its classification. There is no taxonomy *write* access from plugins.
@@ -65,19 +65,19 @@ Locale matching is case-insensitive and stores the casing from the site's locale
6565

6666
## Network host allowlists
6767

68-
Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. Wildcards are supported for subdomains:
68+
Plugins with `network:request` can only fetch hosts listed in `allowedHosts`. A leading `*.` matches both the named domain and its subdomains:
6969

7070
```jsonc title="emdash-plugin.jsonc"
7171
"capabilities": ["network:request"],
7272
"allowedHosts": [
7373
"api.example.com", // exact host
74-
"*.cdn.example.com" // any subdomain of cdn.example.com
74+
"*.cdn.example.com" // cdn.example.com and any subdomain
7575
]
7676
```
7777

7878
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.
7979

80-
`network:request:unrestricted` skips the allowlist check entirely. It's intended for plugins where the operator configures the destination URL at runtime (webhook senders, generic HTTP forwarders). Avoid it for plugins where the destination is part of the plugin's design — declare `network:request` with explicit hosts instead, so the consent dialog tells operators exactly where the plugin is going to call.
80+
`network:request:unrestricted` skips the manifest host allowlist. The sandbox bridge still accepts only HTTP and HTTPS, blocks known internal hosts and private literal addresses, rechecks every redirect, and removes credential headers when a redirect crosses origins. Use unrestricted access only when an operator supplies the destination at runtime. For fixed destinations, declare `network:request` with explicit hosts so the consent dialog names them.
8181

8282
## What the sandbox enforces
8383

@@ -87,37 +87,37 @@ When a sandbox runner is active, the runtime enforces:
8787

8888
1. **Capability gating.** The PluginContext factory only populates `ctx.content`, `ctx.taxonomies`, `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.
8989

90-
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.
90+
2. **Storage and KV scoping.** Every storage and KV operation is scoped to the runtime plugin ID. A plugin can't read another plugin's KV or storage collections, and it can access only collections declared in its manifest.
9191

9292
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.
9393

9494
4. **No host bindings.** Sandboxed plugins don't see environment variables, the filesystem, or any platform bindings — even if your host worker has them. The plugin runtime is a clean isolate with only the bridge and the declared capabilities.
9595

96-
5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). The Node.js workerd runner (`@emdash-cms/sandbox-workerd`) enforces wall-clock time via `Promise.race`; CPU and memory limits are Cloudflare platform features and are not enforced by standalone workerd. Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that.
96+
5. **Resource limits.** The Cloudflare runner defaults to 50 ms of CPU, 10 subrequests, and 30 seconds of wall time per invocation. Worker Loader enforces CPU and subrequests; the runner enforces wall time. Worker Loader has a platform memory ceiling, but its per-plugin `memoryMb` option is not currently enforceable. The Node.js workerd runner enforces only the 30-second wall-time default; it warns when a site configures CPU, memory, or subrequest limits that standalone workerd cannot enforce. A per-hook `timeout` applies only when the sandboxed-format plugin runs in process.
9797

9898
</Steps>
9999

100100
## What the sandbox doesn't enforce
101101

102102
A few things the capability system doesn't and can't cover:
103103

104-
- **Behaviour within a granted capability.** A plugin with `content:write` can edit any content, not only its own. Capabilities are coarse — they say "this plugin can write content," not "this plugin can write only the content it created." Audit-time review is the only check on what a plugin actually does within its grant.
105-
- **Entry edit locks.** `ctx.content.update()` writes through an entry's [edit lock](/reference/rest-api/#entry-edit-lock), so an editor who has that entry open in the admin does not stop a plugin write. `ctx.content.delete()` behaves the same way, and releases the lock along with the entry it trashes.
104+
- **Behaviour within a granted capability.** A plugin with `content:write` can edit any content, not only its own. Capabilities are coarse — they say "this plugin can write content," not "this plugin can write only the content it created." An operator must evaluate the plugin's code and publisher before granting that access.
105+
- **Entry edit locks.** `ctx.content.update()` and `ctx.content.delete()` are programmatic writes. An editor holding the entry's advisory edit lock does not block them. Coordinate plugin writes with editors when both may update the same entry.
106106
- **Operator trust on Node.js.** When the configured sandbox runner reports unavailable (no Cloudflare Worker Loader, no Node-side runner installed, etc.), `sandboxed: []` plugins are skipped at startup. You can move them into `plugins: []` to run them in-process — but then there's no V8 isolate, no resource limits, and the plugin can call `fetch()` directly or read environment variables. Treat that as native-level trust.
107107
- **Side channels.** Timing, log output, and stored data are all visible to anyone with appropriate access to the host environment. Don't use the sandbox as a confidentiality boundary against the operator running it.
108108

109109
## Capability consent
110110

111111
When an operator installs a sandboxed plugin from the marketplace, EmDash shows a consent dialog listing the declared capabilities. Updates that add capabilities — for example, a plugin that previously only read content now wants to make network requests — surface as a capability diff and require fresh approval before the new version takes effect.
112112

113-
This is why declaring extra capabilities matters even if you "might use them later". They show up as friction at every install and update, and security audits flag plugins that ask for more than they obviously need. List exactly what the plugin uses, and add new capabilities in a real version when the plugin actually starts using them.
113+
Declaring capabilities for possible future use makes every installation or update ask for unnecessary access. List what the current version uses, then add a capability in the version that starts using it.
114114

115115
## Bundle-time validation
116116

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

119119
- Every declared capability must be in the recognised set (typos fail the build).
120-
- `network:request` requires a non-empty `allowedHosts`; `network:request:unrestricted` requires it to be empty. See [the manifest reference](/plugins/creating-plugins/manifest/#capabilities).
120+
- `network:request` requires a non-empty `allowedHosts`; `network:request:unrestricted` requires it to be empty. See [Capabilities and hosts](/plugins/creating-plugins/manifest/#capabilities-and-hosts).
121121
- The bundled `backend.js` can't import Node.js built-ins (`fs`, `path`, `child_process`, etc.) — sandbox runtimes don't provide them.
122122

123-
See [Bundling and publishing](/plugins/creating-plugins/publishing/) for the full list of checks.
123+
See [the manifest reference](/plugins/creating-plugins/manifest/#trust-contract) for the authoring fields and [Bundling and publishing](/plugins/creating-plugins/publishing/#validation) for bundle checks.

0 commit comments

Comments
 (0)