Skip to content
Merged
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
24 changes: 24 additions & 0 deletions .changeset/unified-plugin-capabilities.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"emdash": minor
"@emdash-cms/cloudflare": minor
"@emdash-cms/admin": patch
---

Unifies plugin capability names under a single `<resource>[.<sub-resource>]:<verb>[:<qualifier>]` formula so capabilities read like RBAC permissions, separates hook-registration permissions from data-access ones for clearer audits, and replaces the overloaded `:any` qualifier with the more conspicuous `:unrestricted`. Old names are still accepted with `@deprecated` warnings; `emdash plugin bundle` and `emdash plugin validate` warn for each deprecated name and `emdash plugin publish` refuses manifests that still use them.

The Cloudflare sandbox bridge and HTTP fetch helper now enforce canonical names (`content:read`, `content:write`, `media:read`, `media:write`, `users:read`, `network:request`, `network:request:unrestricted`). Manifests that still declare legacy names continue to work — the runner normalizes capabilities before passing them into the bridge, so installed plugins with `read:content` resolve to `content:read` and reach the same code path.

| 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` |

Existing installs keep working — manifests are normalized at every external boundary and `diffCapabilities` normalizes both sides so version upgrades that only rename do not trigger a "capability changed" prompt. Deprecated names will be removed in the next minor.
2 changes: 1 addition & 1 deletion docs/src/content/docs/coming-from/astro.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ import { definePlugin } from "emdash";
export default definePlugin({
id: "analytics",
version: "1.0.0",
capabilities: ["read:content"],
capabilities: ["content:read"],

hooks: {
"content:afterSave": async (event, ctx) => {
Expand Down
14 changes: 7 additions & 7 deletions docs/src/content/docs/migration/porting-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -398,13 +398,13 @@ export function createPlugin() {

Plugins must declare required capabilities for security sandboxing:

| Capability | Provides | Use Case |
| --------------- | ----------------------------- | ------------------- |
| `network:fetch` | `ctx.http.fetch()` | External API calls |
| `read:content` | `ctx.content.get()`, `list()` | Reading CMS content |
| `write:content` | `ctx.content.create()`, etc. | Modifying content |
| `read:media` | `ctx.media.get()`, `list()` | Reading media |
| `write:media` | `ctx.media.getUploadUrl()` | Uploading media |
| Capability | Provides | Use Case |
| ----------------- | ----------------------------- | ------------------- |
| `network:request` | `ctx.http.fetch()` | External API calls |
| `content:read` | `ctx.content.get()`, `list()` | Reading CMS content |
| `content:write` | `ctx.content.create()`, etc. | Modifying content |
| `media:read` | `ctx.media.get()`, `list()` | Reading media |
| `media:write` | `ctx.media.getUploadUrl()` | Uploading media |

## Common Gotchas

Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/plugins/admin-ui.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ export default definePlugin({
id: "analytics",
version: "1.0.0",

capabilities: ["network:fetch"],
capabilities: ["network:request"],
allowedHosts: ["api.analytics.example.com"],

storage: {
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/plugins/api-routes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -323,14 +323,14 @@ routes: {

### External API Proxy

Proxy requests to external services (requires `network:fetch` capability):
Proxy requests to external services (requires `network:request` capability):

```typescript
definePlugin({
id: "weather",
version: "1.0.0",

capabilities: ["network:fetch"],
capabilities: ["network:request"],
allowedHosts: ["api.weather.example.com"],

routes: {
Expand Down
2 changes: 1 addition & 1 deletion docs/src/content/docs/plugins/creating-plugins.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export function createPlugin(options: MyPluginOptions = {}) {
version: "1.0.0",

// Declare required capabilities
capabilities: ["read:content"],
capabilities: ["content:read"],

// Plugin storage (document collections)
storage: {
Expand Down
4 changes: 2 additions & 2 deletions docs/src/content/docs/plugins/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,7 @@ Runs after content is successfully deleted.
### `content:afterPublish`

Runs after content is published (promoted from draft to live). Use for side effects like cache invalidation, notifications, or syncing to external systems.
Requires `read:content` capability.
Requires `content:read` capability.

```typescript
"content:afterPublish": async (event, ctx) => {
Expand Down Expand Up @@ -297,7 +297,7 @@ Requires `read:content` capability.
### `content:afterUnpublish`

Runs after content is unpublished (reverted from live to draft). Use for side effects like cache invalidation or notifying external systems.
Requires `read:content` capability.
Requires `content:read` capability.

```typescript
"content:afterUnpublish": async (event, ctx) => {
Expand Down
12 changes: 6 additions & 6 deletions docs/src/content/docs/plugins/installing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,14 @@ Before installation, you'll see a dialog listing what the plugin needs access to

| Capability | What it means |
| ---------- | ------------- |
| `read:content` | Read your content |
| `write:content` | Create, update, and delete content |
| `read:media` | Access your media library |
| `write:media` | Upload and manage media |
| `network:fetch` | Make network requests to specific hosts |
| `content:read` | Read your content |
| `content:write` | Create, update, and delete content |
| `media:read` | Access your media library |
| `media:write` | Upload and manage media |
| `network:request` | Make network requests to specific hosts |

<Aside type="caution">
Only install plugins from authors you trust. The capability system limits what a sandboxed plugin can access, but a plugin with `write:content` can modify any content on your site.
Only install plugins from authors you trust. The capability system limits what a sandboxed plugin can access, but a plugin with `content:write` can modify any content on your site.
</Aside>

### Security Audit
Expand Down
67 changes: 46 additions & 21 deletions docs/src/content/docs/plugins/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default definePlugin({
version: "1.0.0",

// What APIs the plugin needs access to
capabilities: ["read:content", "network:fetch"],
capabilities: ["content:read", "network:request"],

// Hosts the plugin can make HTTP requests to
allowedHosts: ["api.example.com"],
Expand Down Expand Up @@ -106,39 +106,64 @@ Every hook and route handler receives a `PluginContext` object with access to:
| ------------- | ---------------------------------------------------- | -------------------------------------- |
| `ctx.storage` | Plugin's document collections | Always (if declared) |
| `ctx.kv` | Key-value store for settings and state | Always |
| `ctx.content` | Read/write site content | With `read:content` or `write:content` |
| `ctx.media` | Read/write media files | With `read:media` or `write:media` |
| `ctx.http` | HTTP client for external requests | With `network:fetch` |
| `ctx.content` | Read/write site content | With `content:read` or `content:write` |
| `ctx.media` | Read/write media files | With `media:read` or `media:write` |
| `ctx.http` | HTTP client for external requests | With `network:request` |
| `ctx.log` | Structured logger (debug, info, warn, error) | Always |
| `ctx.plugin` | Plugin metadata (id, version) | Always |
| `ctx.site` | Site info: `name`, `url`, `locale` | Always |
| `ctx.url()` | Generate absolute URLs from paths | Always |
| `ctx.users` | Read user info: `get()`, `getByEmail()`, `list()` | With `read:users` |
| `ctx.users` | Read user info: `get()`, `getByEmail()`, `list()` | With `users:read` |
| `ctx.cron` | Schedule tasks: `schedule()`, `cancel()`, `list()` | Always |
| `ctx.email` | Send email: `send()` | With `email:send` + provider configured |

The context shape is identical across all hooks and routes. Capability-gated properties are only present when the plugin declares the required capability.

## Capabilities

Capabilities determine what APIs are available in the plugin context:

| Capability | Grants Access To |
| ----------------- | ---------------------------------------------------------------------- |
| `read:content` | `ctx.content.get()`, `ctx.content.list()` |
| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
| `read:media` | `ctx.media.get()`, `ctx.media.list()` |
| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) |
| `network:fetch:any` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) |
| `read:users` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
| `email:send` | `ctx.email.send()` (requires a provider plugin) |
| `email:provide` | Register `email:deliver` exclusive hook (transport provider) |
| `email:intercept` | Register `email:beforeSend` / `email:afterSend` hooks |
| `page:inject` | Register `page:metadata` / `page:fragments` hooks |
Capabilities determine what APIs are available in the plugin context. Capability
names follow the formula `<resource>[.<sub-resource>]:<verb>[:<qualifier>]` —
resource first, verb second.

| Capability | Grants Access To |
| ----------------------------------- | ---------------------------------------------------------------------- |
| `content:read` | `ctx.content.get()`, `ctx.content.list()` |
| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` |
| `media:read` | `ctx.media.get()`, `ctx.media.list()` |
| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.upload()`, `ctx.media.delete()` |
| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) |
| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) |
| `users:read` | `ctx.users.get()`, `ctx.users.getByEmail()`, `ctx.users.list()` |
| `email:send` | `ctx.email.send()` (requires a provider plugin) |
| `hooks.email-transport:register` | Register `email:deliver` exclusive hook (transport provider) |
| `hooks.email-events:register` | Register `email:beforeSend` / `email:afterSend` hooks |
| `hooks.page-fragments:register` | Register `page:fragments` hook (inject scripts/styles into pages) |

<Aside type="tip">
`write:content` implies `read:content`. Same for media. Declare only what you need.
`content:write` implies `content:read`. Same for media. Declare only what you
need.
</Aside>

<Aside type="caution" title="Renamed in this minor">
The capability names were unified in this minor. Old names are still accepted
with `@deprecated` 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` refuses manifests that still use
deprecated names — re-bundle after renaming.
</Aside>

## Registration
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/plugins/sandbox.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ EmDash plugins run in one of two modes: **sandboxed** or **native**. Both use th
| **Platform** | Cloudflare Workers | All platforms |
| **Admin UI** | Block Kit (JSON-based) | React components or Block Kit |
| **PT block types** | Not available | Astro components via `componentsEntry` |
| **Page fragments** | Not available | Available with `page:inject` capability |
| **Page fragments** | Not available | Available with `hooks.page-fragments:register` capability |

## What Both Modes Support

Expand Down Expand Up @@ -147,7 +147,7 @@ Every `ctx` method is a proxy to the bridge. The bridge validates capabilities,

1. **Capability enforcement**

If a plugin declares `capabilities: ["read:content"]`, it can call `ctx.content.get()` and `ctx.content.list()` -- nothing else. Attempting `ctx.content.create()` throws a permission error. The plugin cannot bypass this because it has no direct database access.
If a plugin declares `capabilities: ["content:read"]`, it can call `ctx.content.get()` and `ctx.content.list()` -- nothing else. Attempting `ctx.content.create()` throws a permission error. The plugin cannot bypass this because it has no direct database access.

2. **Resource limits**

Expand Down Expand Up @@ -204,7 +204,7 @@ export default defineConfig({

In native mode:

- **Capabilities are advisory.** A plugin declaring `["read:content"]` can still access anything in the process. The `capabilities` field documents what the plugin *intends* to use, but nothing prevents it from importing modules, calling `fetch()` directly, or reading environment variables.
- **Capabilities are advisory.** A plugin declaring `["content:read"]` can still access anything in the process. The `capabilities` field documents what the plugin *intends* to use, but nothing prevents it from importing modules, calling `fetch()` directly, or reading environment variables.
- **No resource limits.** CPU, memory, and network usage are unbounded.
- **Full process access.** The plugin shares the runtime with your Astro site.

Expand Down Expand Up @@ -248,7 +248,7 @@ import { definePlugin } from "emdash";
export default definePlugin({
id: "my-plugin",
version: "1.0.0",
capabilities: ["read:content"],
capabilities: ["content:read"],
hooks: {
"content:afterSave": async (event, ctx) => {
ctx.log.info("Content saved", { id: event.content.id });
Expand Down Expand Up @@ -322,7 +322,7 @@ export default definePlugin({
hooks: {
"content:afterSave": async (event, ctx) => {
// Native mode: ctx.http is always present (capabilities not enforced)
// Sandboxed mode: ctx.http is present because the descriptor declares "network:fetch"
// Sandboxed mode: ctx.http is present because the descriptor declares "network:request"
await ctx.http.fetch("https://api.analytics.example.com/track", {
method: "POST",
body: JSON.stringify({ contentId: event.content.id }),
Expand Down
18 changes: 9 additions & 9 deletions docs/src/content/docs/reference/hooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ Email hooks form a pipeline: `email:beforeSend` → `email:deliver` → `email:a

### `email:beforeSend`

**Capability:** `email:intercept`
**Capability:** `hooks.email-events:register`

Middleware hook that runs before delivery. Transform messages or cancel delivery.

Expand Down Expand Up @@ -368,7 +368,7 @@ interface EmailBeforeSendEvent {

### `email:deliver`

**Capability:** `email:provide` | **Exclusive:** Yes
**Capability:** `hooks.email-transport:register` | **Exclusive:** Yes

The transport provider. Only one plugin can deliver emails. Responsible for actually sending the message via an email service.

Expand All @@ -385,7 +385,7 @@ hooks: {

### `email:afterSend`

**Capability:** `email:intercept`
**Capability:** `hooks.email-events:register`

Fire-and-forget hook after successful delivery. Errors are logged but do not propagate.

Expand All @@ -406,7 +406,7 @@ Comment hooks form a pipeline: `comment:beforeCreate` → `comment:moderate` →

### `comment:beforeCreate`

**Capability:** `read:users`
**Capability:** `users:read`

Middleware hook before a comment is stored. Enrich, validate, or reject comments.

Expand Down Expand Up @@ -448,7 +448,7 @@ interface CommentBeforeCreateEvent {

### `comment:moderate`

**Capability:** `read:users` | **Exclusive:** Yes
**Capability:** `users:read` | **Exclusive:** Yes

Decide whether a comment is approved, pending, or spam. Only one moderation provider is active.

Expand Down Expand Up @@ -491,7 +491,7 @@ interface CommentModerateEvent {

### `comment:afterCreate`

**Capability:** `read:users`
**Capability:** `users:read`

Fire-and-forget hook after a comment is stored. Use for notifications.

Expand All @@ -511,7 +511,7 @@ hooks: {

### `comment:afterModerate`

**Capability:** `read:users`
**Capability:** `users:read`

Fire-and-forget hook when an admin manually changes a comment's status.

Expand All @@ -532,7 +532,7 @@ Page hooks run when rendering public pages. They allow plugins to inject metadat

### `page:metadata`

**Capability:** `page:inject`
**Capability:** None required

Contribute meta tags, Open Graph properties, JSON-LD structured data, or link tags to the page head.

Expand Down Expand Up @@ -562,7 +562,7 @@ The `key` field deduplicates contributions — only the last contribution with a

### `page:fragments`

**Capability:** `page:inject`
**Capability:** `hooks.page-fragments:register`

Inject scripts or HTML into pages. Only available to native plugins.

Expand Down
Loading
Loading