diff --git a/.changeset/unified-plugin-capabilities.md b/.changeset/unified-plugin-capabilities.md new file mode 100644 index 000000000..df16e97ed --- /dev/null +++ b/.changeset/unified-plugin-capabilities.md @@ -0,0 +1,24 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": minor +"@emdash-cms/admin": patch +--- + +Unifies plugin capability names under a single `[.]:[:]` 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. diff --git a/docs/src/content/docs/coming-from/astro.mdx b/docs/src/content/docs/coming-from/astro.mdx index 3a16c90d7..d2895b88d 100644 --- a/docs/src/content/docs/coming-from/astro.mdx +++ b/docs/src/content/docs/coming-from/astro.mdx @@ -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) => { diff --git a/docs/src/content/docs/migration/porting-plugins.mdx b/docs/src/content/docs/migration/porting-plugins.mdx index e66179e78..130308e47 100644 --- a/docs/src/content/docs/migration/porting-plugins.mdx +++ b/docs/src/content/docs/migration/porting-plugins.mdx @@ -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 diff --git a/docs/src/content/docs/plugins/admin-ui.mdx b/docs/src/content/docs/plugins/admin-ui.mdx index be75c2188..f96b96ec8 100644 --- a/docs/src/content/docs/plugins/admin-ui.mdx +++ b/docs/src/content/docs/plugins/admin-ui.mdx @@ -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: { diff --git a/docs/src/content/docs/plugins/api-routes.mdx b/docs/src/content/docs/plugins/api-routes.mdx index 992b7208e..93b3730de 100644 --- a/docs/src/content/docs/plugins/api-routes.mdx +++ b/docs/src/content/docs/plugins/api-routes.mdx @@ -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: { diff --git a/docs/src/content/docs/plugins/creating-plugins.mdx b/docs/src/content/docs/plugins/creating-plugins.mdx index 561f0b5e3..4ed7dcd0d 100644 --- a/docs/src/content/docs/plugins/creating-plugins.mdx +++ b/docs/src/content/docs/plugins/creating-plugins.mdx @@ -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: { diff --git a/docs/src/content/docs/plugins/hooks.mdx b/docs/src/content/docs/plugins/hooks.mdx index eaaa95dab..7e3cb06d7 100644 --- a/docs/src/content/docs/plugins/hooks.mdx +++ b/docs/src/content/docs/plugins/hooks.mdx @@ -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) => { @@ -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) => { diff --git a/docs/src/content/docs/plugins/installing.mdx b/docs/src/content/docs/plugins/installing.mdx index 36bf771b5..8ba64c1bd 100644 --- a/docs/src/content/docs/plugins/installing.mdx +++ b/docs/src/content/docs/plugins/installing.mdx @@ -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 | ### Security Audit diff --git a/docs/src/content/docs/plugins/overview.mdx b/docs/src/content/docs/plugins/overview.mdx index da9b55d44..8325f2f6d 100644 --- a/docs/src/content/docs/plugins/overview.mdx +++ b/docs/src/content/docs/plugins/overview.mdx @@ -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"], @@ -106,14 +106,14 @@ 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 | @@ -121,24 +121,49 @@ The context shape is identical across all hooks and routes. Capability-gated pro ## 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 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) | + + ## Registration diff --git a/docs/src/content/docs/plugins/sandbox.mdx b/docs/src/content/docs/plugins/sandbox.mdx index 021ecb03e..89e20b84c 100644 --- a/docs/src/content/docs/plugins/sandbox.mdx +++ b/docs/src/content/docs/plugins/sandbox.mdx @@ -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 @@ -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** @@ -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. @@ -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 }); @@ -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 }), diff --git a/docs/src/content/docs/reference/hooks.mdx b/docs/src/content/docs/reference/hooks.mdx index a0c3bacf1..8c7e007d9 100644 --- a/docs/src/content/docs/reference/hooks.mdx +++ b/docs/src/content/docs/reference/hooks.mdx @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/packages/admin/src/lib/api/marketplace.ts b/packages/admin/src/lib/api/marketplace.ts index c6aae7bfb..3eed5dddf 100644 --- a/packages/admin/src/lib/api/marketplace.ts +++ b/packages/admin/src/lib/api/marketplace.ts @@ -206,23 +206,41 @@ export async function checkPluginUpdates(): Promise { // Helpers // --------------------------------------------------------------------------- -/** Human-readable labels for plugin capabilities */ +/** + * Human-readable labels for plugin capabilities. + * + * Canonical names are the keys; legacy names alias to the same labels so + * old manifests still render meaningful copy until they're republished. + */ export const CAPABILITY_LABELS: Record = { + // Canonical + "content:read": "Read your content", + "content:write": "Create, update, and delete content", + "media:read": "Access your media library", + "media:write": "Upload and manage media", + "users:read": "Read user accounts", + "network:request": "Make network requests", + "network:request:unrestricted": "Make network requests to any host (unrestricted)", + // Legacy aliases (still emitted by older installed manifests) "read:content": "Read your content", "write:content": "Create, update, and delete content", "read:media": "Access your media library", "write:media": "Upload and manage media", + "read:users": "Read user accounts", "network:fetch": "Make network requests", "network:fetch:any": "Make network requests to any host (unrestricted)", }; +/** Capability names that grant scoped network access (legacy + canonical). */ +const NETWORK_REQUEST_CAPABILITIES = new Set(["network:request", "network:fetch"]); + /** * Get a human-readable description for a capability. - * For network:fetch, appends the allowed hosts if provided. + * For scoped network capabilities, appends the allowed hosts if provided. */ export function describeCapability(capability: string, allowedHosts?: string[]): string { const base = CAPABILITY_LABELS[capability] ?? capability; - if (capability === "network:fetch" && allowedHosts && allowedHosts.length > 0) { + if (NETWORK_REQUEST_CAPABILITIES.has(capability) && allowedHosts && allowedHosts.length > 0) { return `${base} to: ${allowedHosts.join(", ")}`; } return base; diff --git a/packages/admin/tests/lib/marketplace.test.ts b/packages/admin/tests/lib/marketplace.test.ts index a93249da6..2e47d2c2d 100644 --- a/packages/admin/tests/lib/marketplace.test.ts +++ b/packages/admin/tests/lib/marketplace.test.ts @@ -256,10 +256,20 @@ describe("describeCapability", () => { describe("CAPABILITY_LABELS", () => { it("has entries for all known capabilities", () => { expect(Object.keys(CAPABILITY_LABELS)).toEqual([ + // Canonical + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "network:request", + "network:request:unrestricted", + // Legacy aliases "read:content", "write:content", "read:media", "write:media", + "read:users", "network:fetch", "network:fetch:any", ]); diff --git a/packages/cloudflare/src/plugins/vectorize-search.ts b/packages/cloudflare/src/plugins/vectorize-search.ts index 586981dea..836845232 100644 --- a/packages/cloudflare/src/plugins/vectorize-search.ts +++ b/packages/cloudflare/src/plugins/vectorize-search.ts @@ -139,7 +139,7 @@ export function vectorizeSearch(config: VectorizeSearchConfig = {}): PluginDefin return { id: "vectorize-search", version: "1.0.0", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { /** diff --git a/packages/cloudflare/src/sandbox/bridge-http.ts b/packages/cloudflare/src/sandbox/bridge-http.ts index 9ad93e900..563584374 100644 --- a/packages/cloudflare/src/sandbox/bridge-http.ts +++ b/packages/cloudflare/src/sandbox/bridge-http.ts @@ -5,14 +5,14 @@ * testable without standing up a real WorkerEntrypoint. * * Responsibilities: - * - Enforce the `network:fetch` / `network:fetch:any` capability. + * - Enforce the `network:request` / `network:request:unrestricted` capability. * - Enforce the allowedHosts list, including on every redirect hop. The * native `fetch` follows 3xx responses automatically; without manual * redirect handling an allowed host that 302s to a disallowed host * would bypass the allowlist. * - Strip credential headers (Authorization, Cookie, Proxy-Authorization) * on cross-origin redirects so tokens don't leak to attacker hosts. - * - For `network:fetch:any`, apply a minimal SSRF check on every hop so + * - For `network:request:unrestricted`, apply a minimal SSRF check on every hop so * plugins can't be tricked into reaching cloud-metadata endpoints or * literal private IPs even without an explicit allowlist. */ @@ -209,10 +209,10 @@ export async function sandboxHttpFetch( const { capabilities, allowedHosts } = options; const fetchImpl = options.fetchImpl ?? globalThis.fetch; - const hasUnrestricted = capabilities.includes("network:fetch:any"); - const hasFetch = capabilities.includes("network:fetch") || hasUnrestricted; + const hasUnrestricted = capabilities.includes("network:request:unrestricted"); + const hasFetch = capabilities.includes("network:request") || hasUnrestricted; if (!hasFetch) { - throw new Error("Missing capability: network:fetch"); + throw new Error("Missing capability: network:request"); } if (!hasUnrestricted && allowedHosts.length === 0) { diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 005bd067a..22ccadec3 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -359,8 +359,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:content")) { - throw new Error("Missing capability: read:content"); + if (!capabilities.includes("content:read")) { + throw new Error("Missing capability: content:read"); } // Validate collection name to prevent SQL injection if (!COLLECTION_NAME_REGEX.test(collection)) { @@ -396,8 +396,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:content")) { - throw new Error("Missing capability: read:content"); + if (!capabilities.includes("content:read")) { + throw new Error("Missing capability: content:read"); } // Validate collection name to prevent SQL injection if (!COLLECTION_NAME_REGEX.test(collection)) { @@ -449,8 +449,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("write:content")) { - throw new Error("Missing capability: write:content"); + if (!capabilities.includes("content:write")) { + throw new Error("Missing capability: content:write"); } if (!COLLECTION_NAME_REGEX.test(collection)) { throw new Error(`Invalid collection name: ${collection}`); @@ -521,8 +521,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("write:content")) { - throw new Error("Missing capability: write:content"); + if (!capabilities.includes("content:write")) { + throw new Error("Missing capability: content:write"); } if (!COLLECTION_NAME_REGEX.test(collection)) { throw new Error(`Invalid collection name: ${collection}`); @@ -579,8 +579,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("write:content")) { - throw new Error("Missing capability: write:content"); + if (!capabilities.includes("content:write")) { + throw new Error("Missing capability: content:write"); } if (!COLLECTION_NAME_REGEX.test(collection)) { throw new Error(`Invalid collection name: ${collection}`); @@ -609,8 +609,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:media")) { - throw new Error("Missing capability: read:media"); + if (!capabilities.includes("media:read")) { + throw new Error("Missing capability: media:read"); } const result = await this.env.DB.prepare("SELECT * FROM media WHERE id = ?").bind(id).first<{ id: string; @@ -644,8 +644,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:media")) { - throw new Error("Missing capability: read:media"); + if (!capabilities.includes("media:read")) { + throw new Error("Missing capability: media:read"); } const limit = Math.min(opts.limit ?? 50, 100); // Only return ready items (matching core's MediaRepository.findMany default) @@ -711,8 +711,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("write:media")) { - throw new Error("Missing capability: write:media"); + if (!capabilities.includes("media:write")) { + throw new Error("Missing capability: media:write"); } if (!this.env.MEDIA) { @@ -771,8 +771,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("write:media")) { - throw new Error("Missing capability: write:media"); + if (!capabilities.includes("media:write")) { + throw new Error("Missing capability: media:write"); } // Look up the storage key before deleting @@ -815,7 +815,7 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:users")) { - throw new Error("Missing capability: read:users"); + if (!capabilities.includes("users:read")) { + throw new Error("Missing capability: users:read"); } const result = await this.env.DB.prepare( "SELECT id, email, name, role, created_at FROM users WHERE id = ?", @@ -858,8 +858,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:users")) { - throw new Error("Missing capability: read:users"); + if (!capabilities.includes("users:read")) { + throw new Error("Missing capability: users:read"); } const result = await this.env.DB.prepare( "SELECT id, email, name, role, created_at FROM users WHERE email = ?", @@ -893,8 +893,8 @@ export class PluginBridge extends WorkerEntrypoint { const { capabilities } = this.ctx.props; - if (!capabilities.includes("read:users")) { - throw new Error("Missing capability: read:users"); + if (!capabilities.includes("users:read")) { + throw new Error("Missing capability: users:read"); } const limit = Math.max(1, Math.min(opts?.limit ?? 50, 100)); let sql = "SELECT id, email, name, role, created_at FROM users"; diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index b26de3822..1ac0f7ea2 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -12,14 +12,15 @@ */ import { env, exports } from "cloudflare:workers"; -import type { - SandboxRunner, - SandboxedPlugin, - SandboxEmailSendCallback, - SandboxOptions, - SandboxRunnerFactory, - SerializedRequest, - PluginManifest, +import { + normalizeCapabilities, + type SandboxRunner, + type SandboxedPlugin, + type SandboxEmailSendCallback, + type SandboxOptions, + type SandboxRunnerFactory, + type SerializedRequest, + type PluginManifest, } from "emdash"; import { setEmailSendCallback } from "./bridge.js"; @@ -230,12 +231,18 @@ class CloudflareSandboxedPlugin implements SandboxedPlugin { }); } - // Create fresh bridge binding for THIS request + // Create fresh bridge binding for THIS request. + // + // Capabilities are normalized to canonical names here so the bridge + // only ever sees the current vocabulary. Manifests installed before + // the rename (or sites still using the legacy alias layer) keep + // working — `normalizeCapabilities` rewrites legacy names like + // `read:content` → `content:read` and `network:fetch` → `network:request`. const bridgeBinding = this.createBridge({ props: { pluginId: this.manifest.id, pluginVersion: this.manifest.version || "0.0.0", - capabilities: this.manifest.capabilities || [], + capabilities: normalizeCapabilities(this.manifest.capabilities || []), allowedHosts: this.manifest.allowedHosts || [], storageCollections: Object.keys(this.manifest.storage || {}), }, diff --git a/packages/cloudflare/src/sandbox/wrapper.ts b/packages/cloudflare/src/sandbox/wrapper.ts index 3f7410453..967dabd3d 100644 --- a/packages/cloudflare/src/sandbox/wrapper.ts +++ b/packages/cloudflare/src/sandbox/wrapper.ts @@ -11,7 +11,7 @@ * */ -import type { PluginManifest } from "emdash"; +import { normalizeCapabilities, type PluginManifest } from "emdash"; const TRAILING_SLASH_RE = /\/$/; const NEWLINE_RE = /[\n\r]/g; @@ -33,8 +33,11 @@ export interface WrapperOptions { export function generatePluginWrapper(manifest: PluginManifest, options?: WrapperOptions): string { const storageCollections = Object.keys(manifest.storage || {}); const site = options?.site ?? { name: "", url: "", locale: "en" }; - const hasReadUsers = manifest.capabilities.includes("read:users"); - const hasEmailSend = manifest.capabilities.includes("email:send"); + // Normalize so manifests that still declare legacy names (`read:users`) + // expose the same APIs as canonical names (`users:read`). + const capabilities = normalizeCapabilities(manifest.capabilities ?? []); + const hasReadUsers = capabilities.includes("users:read"); + const hasEmailSend = capabilities.includes("email:send"); return ` // ============================================================================= diff --git a/packages/cloudflare/tests/sandbox/bridge-http.test.ts b/packages/cloudflare/tests/sandbox/bridge-http.test.ts index 1e3e4d20a..4b9e87a07 100644 --- a/packages/cloudflare/tests/sandbox/bridge-http.test.ts +++ b/packages/cloudflare/tests/sandbox/bridge-http.test.ts @@ -9,7 +9,7 @@ * - Credential headers (Authorization, Cookie, Proxy-Authorization) must * be stripped on cross-origin hops so they don't leak to attacker * destinations. - * - With `network:fetch:any` (no allowlist), requests targeting literal + * - With `network:request:unrestricted` (no allowlist), requests targeting literal * private IPs or known internal hostnames must still be rejected. */ @@ -46,28 +46,28 @@ afterEach(() => { // --------------------------------------------------------------------------- describe("sandboxHttpFetch — capability enforcement", () => { - it("rejects when neither network:fetch nor network:fetch:any is held", async () => { + it("rejects when neither network:request nor network:request:unrestricted is held", async () => { await expect( sandboxHttpFetch("https://a.example.com/", undefined, { capabilities: [], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }), - ).rejects.toThrow(/network:fetch/); + ).rejects.toThrow(/network:request/); }); - it("allows when network:fetch is held and host is on the list", async () => { + it("allows when network:request is held and host is on the list", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); expect(res.status).toBe(200); }); - it("allows when network:fetch:any is held and skips the allowlist for public hosts", async () => { + it("allows when network:request:unrestricted is held and skips the allowlist for public hosts", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -83,7 +83,7 @@ describe("sandboxHttpFetch — redirect allowlist enforcement", () => { it("rejects a redirect to a host not on the allowlist", async () => { await expect( sandboxHttpFetch("https://a.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([redirectResponse("https://evil.example.com/"), okResponse()]), }), @@ -92,7 +92,7 @@ describe("sandboxHttpFetch — redirect allowlist enforcement", () => { it("follows a redirect to a host that IS on the allowlist", async () => { const res = await sandboxHttpFetch("https://a.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl: mockFetchSequence([ redirectResponse("https://b.example.com/next"), @@ -117,7 +117,7 @@ describe("sandboxHttpFetch — redirect allowlist enforcement", () => { await expect( sandboxHttpFetch("https://a.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl, }), @@ -142,7 +142,7 @@ describe("sandboxHttpFetch — credential header stripping", () => { headers: { Authorization: "Bearer secret-token" }, }, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl, }, @@ -167,7 +167,7 @@ describe("sandboxHttpFetch — credential header stripping", () => { headers: { Authorization: "Bearer secret-token" }, }, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl, }, @@ -195,7 +195,7 @@ describe("sandboxHttpFetch — credential header stripping", () => { }, }, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com", "b.example.com"], fetchImpl, }, @@ -211,14 +211,14 @@ describe("sandboxHttpFetch — credential header stripping", () => { }); // --------------------------------------------------------------------------- -// SSRF defence for network:fetch:any +// SSRF defence for network:request:unrestricted // --------------------------------------------------------------------------- -describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { +describe("sandboxHttpFetch — SSRF defence with network:request:unrestricted", () => { it("rejects literal loopback IPv4", async () => { await expect( sandboxHttpFetch("http://127.0.0.1/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -234,7 +234,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { ]) { await expect( sandboxHttpFetch(url, undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -246,7 +246,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { for (const url of ["http://localhost/", "http://metadata.google.internal/"]) { await expect( sandboxHttpFetch(url, undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -257,7 +257,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { it("rejects IPv6 loopback", async () => { await expect( sandboxHttpFetch("http://[::1]/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -268,7 +268,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { // Public host redirects to a private IP — must be blocked. await expect( sandboxHttpFetch("https://public.example.com/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([ redirectResponse("http://169.254.169.254/latest/meta-data/"), @@ -287,7 +287,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { it("rejects IPv4-mapped IPv6 loopback in hex form", async () => { await expect( sandboxHttpFetch("http://[::ffff:7f00:1]/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -297,7 +297,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { it("rejects IPv4-mapped IPv6 metadata address in hex form", async () => { await expect( sandboxHttpFetch("http://[::ffff:a9fe:a9fe]/latest/meta-data/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -312,7 +312,7 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { ]) { await expect( sandboxHttpFetch(url, undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -326,14 +326,14 @@ describe("sandboxHttpFetch — SSRF defence with network:fetch:any", () => { // --------------------------------------------------------------------------- describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => { - // A plugin with { capabilities: ["network:fetch"], allowedHosts: ["*"] } + // A plugin with { capabilities: ["network:request"], allowedHosts: ["*"] } // gets full egress with zero SSRF protection unless we apply the literal // check on the restricted path too. The allowlist describes scope, not // safety. it("rejects literal private IPv4 even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://127.0.0.1/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -343,7 +343,7 @@ describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => { it("rejects cloud-metadata IP even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://169.254.169.254/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -353,7 +353,7 @@ describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => { it("rejects localhost even with allowedHosts=['*']", async () => { await expect( sandboxHttpFetch("http://localhost/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -362,7 +362,7 @@ describe('sandboxHttpFetch — SSRF defence with allowedHosts=["*"]', () => { it("still allows public hosts with allowedHosts=['*']", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["*"], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -378,7 +378,7 @@ describe("sandboxHttpFetch — scheme enforcement", () => { it("rejects file: scheme", async () => { await expect( sandboxHttpFetch("file:///etc/passwd", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -388,7 +388,7 @@ describe("sandboxHttpFetch — scheme enforcement", () => { it("rejects data: scheme", async () => { await expect( sandboxHttpFetch("data:text/plain,secret", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -398,7 +398,7 @@ describe("sandboxHttpFetch — scheme enforcement", () => { it("rejects ftp: scheme", async () => { await expect( sandboxHttpFetch("ftp://example.com/file", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -408,7 +408,7 @@ describe("sandboxHttpFetch — scheme enforcement", () => { it("accepts http: and https:", async () => { for (const url of ["http://a.example.com/", "https://a.example.com/"]) { const res = await sandboxHttpFetch(url, undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["a.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -424,7 +424,7 @@ describe("sandboxHttpFetch — scheme enforcement", () => { describe("sandboxHttpFetch — allowlist normalisation", () => { it("matches when the manifest uses mixed case", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["API.Example.COM"], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -433,7 +433,7 @@ describe("sandboxHttpFetch — allowlist normalisation", () => { it("matches when the request uses a trailing dot FQDN", async () => { const res = await sandboxHttpFetch("https://api.example.com./", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["api.example.com"], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -442,7 +442,7 @@ describe("sandboxHttpFetch — allowlist normalisation", () => { it("matches wildcard patterns case-insensitively", async () => { const res = await sandboxHttpFetch("https://api.example.com/", undefined, { - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["*.Example.COM"], fetchImpl: mockFetchSequence([okResponse()]), }); @@ -460,7 +460,7 @@ describe("sandboxHttpFetch — *.localhost", () => { it("rejects app.localhost", async () => { await expect( sandboxHttpFetch("http://app.localhost/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), @@ -470,7 +470,7 @@ describe("sandboxHttpFetch — *.localhost", () => { it("rejects nested *.localhost subdomains", async () => { await expect( sandboxHttpFetch("http://admin.app.localhost/", undefined, { - capabilities: ["network:fetch:any"], + capabilities: ["network:request:unrestricted"], allowedHosts: [], fetchImpl: mockFetchSequence([okResponse()]), }), diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index a63a5f572..e3fdd870c 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -24,6 +24,7 @@ import { } from "../../plugins/marketplace.js"; import type { SandboxRunner } from "../../plugins/sandbox/types.js"; import { PluginStateRepository } from "../../plugins/state.js"; +import { normalizeCapabilities } from "../../plugins/types.js"; import type { PluginManifest } from "../../plugins/types.js"; import { EmDashStorageError } from "../../storage/types.js"; import type { Storage } from "../../storage/types.js"; @@ -95,11 +96,17 @@ function diffCapabilities( oldCaps: string[], newCaps: string[], ): { added: string[]; removed: string[] } { - const oldSet = new Set(oldCaps); - const newSet = new Set(newCaps); + // Normalize both sides before diffing so that an installed v1 manifest + // declaring `read:content` and an upgrade v2 manifest declaring + // `content:read` produces an empty diff — users should not see a + // spurious "capability changed" prompt for a pure rename. + const oldNorm = normalizeCapabilities(oldCaps); + const newNorm = normalizeCapabilities(newCaps); + const oldSet = new Set(oldNorm); + const newSet = new Set(newNorm); return { - added: newCaps.filter((c) => !oldSet.has(c)), - removed: oldCaps.filter((c) => !newSet.has(c)), + added: newNorm.filter((c) => !oldSet.has(c)), + removed: oldNorm.filter((c) => !newSet.has(c)), }; } diff --git a/packages/core/src/cli/commands/bundle.ts b/packages/core/src/cli/commands/bundle.ts index b1d6f88b9..76bc51f8e 100644 --- a/packages/core/src/cli/commands/bundle.ts +++ b/packages/core/src/cli/commands/bundle.ts @@ -20,6 +20,7 @@ import { resolve, join, extname, basename } from "node:path"; import { defineCommand } from "citty"; import consola from "consola"; +import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; import { fileExists, @@ -524,20 +525,39 @@ export const bundleCommand = defineCommand({ } } - // Check capabilities warnings - if (manifest.capabilities.includes("network:fetch:any")) { + // Check capabilities warnings — use canonical names. Deprecated + // names are accepted (and warned about separately below) so we + // also check the legacy aliases here for the duration of the + // deprecation window. + const declaresUnrestricted = + manifest.capabilities.includes("network:request:unrestricted") || + manifest.capabilities.includes("network:fetch:any"); + const declaresHostRestricted = + manifest.capabilities.includes("network:request") || + manifest.capabilities.includes("network:fetch"); + if (declaresUnrestricted) { consola.warn( - "Plugin declares unrestricted network access (network:fetch:any) — it can make requests to any host", + "Plugin declares unrestricted network access (network:request:unrestricted) — it can make requests to any host", ); - } else if ( - manifest.capabilities.includes("network:fetch") && - manifest.allowedHosts.length === 0 - ) { + } else if (declaresHostRestricted && manifest.allowedHosts.length === 0) { consola.warn( - "Plugin declares network:fetch capability but no allowedHosts — all fetch requests will be blocked", + "Plugin declares network:request capability but no allowedHosts — all requests will be blocked", ); } + // Warn for each deprecated capability used. The warning points + // to the new name so the fix is mechanical. We continue (not + // error) here — the hard fail lives in `publish` so authors + // can still build and test locally. + const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); + if (deprecatedCaps.length > 0) { + consola.warn("Plugin uses deprecated capability names. Rename them before publishing:"); + for (const cap of deprecatedCaps) { + const replacement = CAPABILITY_RENAMES[cap]; + consola.warn(` ${cap} → ${replacement}`); + } + } + // Check for features that won't work in sandboxed mode if ( resolvedPlugin.admin?.portableTextBlocks && diff --git a/packages/core/src/cli/commands/publish.ts b/packages/core/src/cli/commands/publish.ts index 959654d13..6d54e6dc1 100644 --- a/packages/core/src/cli/commands/publish.ts +++ b/packages/core/src/cli/commands/publish.ts @@ -21,6 +21,7 @@ import { createGzipDecoder, unpackTar } from "modern-tar"; import pc from "picocolors"; import { pluginManifestSchema } from "../../plugins/manifest-schema.js"; +import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js"; import { getMarketplaceCredential, saveMarketplaceCredential, @@ -440,6 +441,29 @@ export const publishCommand = defineCommand({ } console.log(); + // ── Step 2.5: Hard-fail on deprecated capability names ── + // + // Refusing to publish manifests that use deprecated capability names + // keeps the marketplace clean while the deprecation window is open. + // The fix is mechanical and entirely in the author's hands — they + // rename, re-bundle, and republish. Better to refuse 5 publishes + // than ship 500 deprecated manifests. We check before authentication + // so authors don't burn a device-flow login on a doomed publish. + const deprecatedCaps = manifest.capabilities.filter(isDeprecatedCapability); + if (deprecatedCaps.length > 0) { + consola.error( + "Plugin declares deprecated capability names. Rename them and re-bundle before publishing:", + ); + for (const cap of deprecatedCaps) { + const replacement = CAPABILITY_RENAMES[cap]; + consola.error(` ${cap} → ${replacement}`); + } + consola.error( + "See https://emdashcms.com/docs/plugins/overview#capabilities for the full rename table.", + ); + process.exit(1); + } + // ── Step 3: Authenticate ── // // Priority: EMDASH_MARKETPLACE_TOKEN env var > stored credential > interactive device flow. diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 31a96edf1..2fc1cded5 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -697,7 +697,7 @@ export class EmDashRuntime { const devConsolePlugin = definePlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, version: "0.0.0", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": { exclusive: true, @@ -720,7 +720,7 @@ export class EmDashRuntime { const defaultModeratorPlugin = definePlugin({ id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID, version: "0.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:moderate": { exclusive: true, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 9dc38757f..db6626c2b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -262,6 +262,15 @@ export type { SerializedRequest, } from "./plugins/index.js"; +// Capability normalization (legacy → canonical alias layer) +export { + CAPABILITY_RENAMES, + isDeprecatedCapability, + normalizeCapability, + normalizeCapabilities, +} from "./plugins/index.js"; +export type { CurrentPluginCapability, DeprecatedPluginCapability } from "./plugins/index.js"; + // Plugin descriptor (for astro.config.mjs) export type { PluginDescriptor } from "./astro/integration/runtime.js"; diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index 5a2da9475..8535febd8 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -12,6 +12,7 @@ import type { PluginDescriptor } from "../astro/integration/runtime.js"; import { PLUGIN_CAPABILITIES, HOOK_NAMES } from "./manifest-schema.js"; +import { normalizeCapabilities } from "./types.js"; import type { StandardPluginDefinition, StandardHookEntry, @@ -147,7 +148,10 @@ export function adaptSandboxEntry( } // Build capabilities from descriptor. - // Validate against the known set (same as defineNativePlugin). + // Validate against the known set (same as defineNativePlugin). Both + // current and deprecated names are accepted; deprecated names are + // silently normalized to current names below so the runtime only ever + // sees the canonical form. const rawCapabilities = descriptor.capabilities ?? []; for (const cap of rawCapabilities) { if (!VALID_CAPABILITIES_SET.has(cap)) { @@ -157,20 +161,28 @@ export function adaptSandboxEntry( ); } } - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated against VALID_CAPABILITIES_SET above; descriptor uses string[] for flexibility - const capabilities = [...rawCapabilities] as PluginCapability[]; + + // Silent normalization: rewrite deprecated names to current names. + // Safe assertion — `normalizeCapabilities` only emits validated input + // plus current names from the rename map, all of which are in the union. + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- validated above; normalizeCapabilities only returns capabilities from the union + const capabilities = normalizeCapabilities(rawCapabilities) as PluginCapability[]; const allowedHosts = descriptor.allowedHosts ?? []; // Capability implications: broader capabilities imply narrower ones - // (mirrors the normalization in define-plugin.ts for native format) - if (capabilities.includes("write:content") && !capabilities.includes("read:content")) { - capabilities.push("read:content"); + // (mirrors the normalization in define-plugin.ts for native format). + // Operates on canonical names only. + if (capabilities.includes("content:write") && !capabilities.includes("content:read")) { + capabilities.push("content:read"); } - if (capabilities.includes("write:media") && !capabilities.includes("read:media")) { - capabilities.push("read:media"); + if (capabilities.includes("media:write") && !capabilities.includes("media:read")) { + capabilities.push("media:read"); } - if (capabilities.includes("network:fetch:any") && !capabilities.includes("network:fetch")) { - capabilities.push("network:fetch"); + if ( + capabilities.includes("network:request:unrestricted") && + !capabilities.includes("network:request") + ) { + capabilities.push("network:request"); } // Build storage config from descriptor. diff --git a/packages/core/src/plugins/context.ts b/packages/core/src/plugins/context.ts index 8bd2e0075..553c3e7f3 100644 --- a/packages/core/src/plugins/context.ts +++ b/packages/core/src/plugins/context.ts @@ -647,14 +647,14 @@ export function createUnrestrictedHttpAccess(pluginId: string): HttpAccess { } /** - * Create blocked HTTP access (for plugins without network:fetch capability) + * Create blocked HTTP access (for plugins without network:request capability) */ export function createBlockedHttpAccess(pluginId: string): HttpAccess { return { async fetch(): Promise { throw new Error( - `Plugin "${pluginId}" does not have the "network:fetch" capability. ` + - `Add "network:fetch" to the plugin's capabilities to enable HTTP requests.`, + `Plugin "${pluginId}" does not have the "network:request" capability. ` + + `Add "network:request" to the plugin's capabilities to enable HTTP requests.`, ); }, }; @@ -902,32 +902,35 @@ export class PluginContextFactory { const storage = createStorageAccess(this.db, plugin.id, plugin.storage); // Capability-gated: content + // Note: capabilities reach this point already normalized to the + // canonical names by definePlugin / adaptSandboxEntry. Deprecated + // names ("read:content", "write:content") never appear here. let content: ContentAccess | ContentAccessWithWrite | undefined; - if (capabilities.has("write:content")) { + if (capabilities.has("content:write")) { content = createContentAccessWithWrite(this.db); - } else if (capabilities.has("read:content")) { + } else if (capabilities.has("content:read")) { content = createContentAccess(this.db); } // Capability-gated: media let media: MediaAccess | MediaAccessWithWrite | undefined; - if (capabilities.has("write:media") && this.getUploadUrl) { + if (capabilities.has("media:write") && this.getUploadUrl) { media = createMediaAccessWithWrite(this.db, this.getUploadUrl, this.storage); - } else if (capabilities.has("read:media")) { + } else if (capabilities.has("media:read")) { media = createMediaAccess(this.db); } // Capability-gated: http let http: HttpAccess | undefined; - if (capabilities.has("network:fetch:any")) { + if (capabilities.has("network:request:unrestricted")) { http = createUnrestrictedHttpAccess(plugin.id); - } else if (capabilities.has("network:fetch")) { + } else if (capabilities.has("network:request")) { http = createHttpAccess(plugin.id, plugin.allowedHosts); } // Capability-gated: users let users: UserAccess | undefined; - if (capabilities.has("read:users")) { + if (capabilities.has("users:read")) { users = createUserAccess(this.db); } diff --git a/packages/core/src/plugins/define-plugin.ts b/packages/core/src/plugins/define-plugin.ts index aebc266d7..308647215 100644 --- a/packages/core/src/plugins/define-plugin.ts +++ b/packages/core/src/plugins/define-plugin.ts @@ -13,6 +13,7 @@ * */ +import { normalizeCapabilities } from "./types.js"; import type { PluginDefinition, ResolvedPlugin, @@ -20,6 +21,7 @@ import type { ResolvedPluginHooks, ResolvedHook, HookConfig, + PluginCapability, PluginStorageConfig, StandardPluginDefinition, } from "./types.js"; @@ -65,7 +67,7 @@ const SEMVER_PATTERN = /^\d+\.\d+\.\d+/; * export default definePlugin({ * id: "my-plugin", * version: "1.0.0", - * capabilities: ["read:content"], + * capabilities: ["content:read"], * hooks: { * "content:beforeSave": async (event, ctx) => { * ctx.log.info("Saving content", { collection: event.collection }); @@ -143,8 +145,24 @@ function defineNativePlugin( throw new Error(`Invalid plugin version "${version}". Must be semver format (e.g., "1.0.0").`); } - // Validate capabilities - const validCapabilities = new Set([ + // Validate capabilities. Both current names and deprecated aliases are + // accepted; aliases are silently rewritten to current names below so the + // runtime only ever sees the canonical form. Authors are warned at + // bundle/validate and hard-failed at publish. + const validCapabilities = new Set([ + // Current names + "network:request", + "network:request:unrestricted", + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", + // Deprecated aliases "network:fetch", "network:fetch:any", "read:content", @@ -152,7 +170,6 @@ function defineNativePlugin( "read:media", "write:media", "read:users", - "email:send", "email:provide", "email:intercept", "page:inject", @@ -163,16 +180,27 @@ function defineNativePlugin( } } - // Capability implications: broader capabilities imply narrower ones - const normalizedCapabilities = [...capabilities]; - if (capabilities.includes("write:content") && !capabilities.includes("read:content")) { - normalizedCapabilities.push("read:content"); + // Silent normalization: rewrite deprecated names to current names. Done + // before the implication pass so implications work on canonical names. + // `as PluginCapability[]` is safe because `normalizeCapabilities` only + // returns strings from the validated input plus current names from the + // rename map, all of which are in the union. + const canonical = normalizeCapabilities(capabilities) as PluginCapability[]; + + // Capability implications: broader capabilities imply narrower ones. + // Operates on canonical names only. + const normalizedCapabilities: PluginCapability[] = [...canonical]; + if (canonical.includes("content:write") && !canonical.includes("content:read")) { + normalizedCapabilities.push("content:read"); } - if (capabilities.includes("write:media") && !capabilities.includes("read:media")) { - normalizedCapabilities.push("read:media"); + if (canonical.includes("media:write") && !canonical.includes("media:read")) { + normalizedCapabilities.push("media:read"); } - if (capabilities.includes("network:fetch:any") && !capabilities.includes("network:fetch")) { - normalizedCapabilities.push("network:fetch"); + if ( + canonical.includes("network:request:unrestricted") && + !canonical.includes("network:request") + ) { + normalizedCapabilities.push("network:request"); } // Normalize hooks diff --git a/packages/core/src/plugins/hooks.ts b/packages/core/src/plugins/hooks.ts index 01a29ffc5..35cbd24db 100644 --- a/packages/core/src/plugins/hooks.ts +++ b/packages/core/src/plugins/hooks.ts @@ -248,28 +248,32 @@ export class HookPipeline { * capability will have that hook silently skipped at registration time. */ private static readonly HOOK_REQUIRED_CAPABILITY: ReadonlyMap = new Map([ - // Email - ["email:beforeSend", "email:intercept"], - ["email:afterSend", "email:intercept"], - ["email:deliver", "email:provide"], - // Content — beforeSave can mutate content, so requires write:content. - // afterSave is read-only notification, so read:content suffices. - ["content:beforeSave", "write:content"], - ["content:afterSave", "read:content"], - ["content:beforeDelete", "read:content"], - ["content:afterDelete", "read:content"], - ["content:afterPublish", "read:content"], - ["content:afterUnpublish", "read:content"], + // Email — registering email:beforeSend/afterSend/deliver requires the + // matching `hooks.email-*:register` capability. These are distinct + // from `email:send` (which gates ctx.email) so that "this plugin + // reads/writes email events" is visible separately from "this + // plugin can send email". + ["email:beforeSend", "hooks.email-events:register"], + ["email:afterSend", "hooks.email-events:register"], + ["email:deliver", "hooks.email-transport:register"], + // Content — beforeSave can mutate content, so requires content:write. + // afterSave is read-only notification, so content:read suffices. + ["content:beforeSave", "content:write"], + ["content:afterSave", "content:read"], + ["content:beforeDelete", "content:read"], + ["content:afterDelete", "content:read"], + ["content:afterPublish", "content:read"], + ["content:afterUnpublish", "content:read"], // Media - ["media:beforeUpload", "write:media"], - ["media:afterUpload", "read:media"], + ["media:beforeUpload", "media:write"], + ["media:afterUpload", "media:read"], // Comments — hooks expose author email, IP hash, user agent - ["comment:beforeCreate", "read:users"], - ["comment:moderate", "read:users"], - ["comment:afterCreate", "read:users"], - ["comment:afterModerate", "read:users"], + ["comment:beforeCreate", "users:read"], + ["comment:moderate", "users:read"], + ["comment:afterCreate", "users:read"], + ["comment:afterModerate", "users:read"], // Page fragments — can inject arbitrary scripts into every public page - ["page:fragments", "page:inject"], + ["page:fragments", "hooks.page-fragments:register"], ]); /** diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 5881e80aa..0b015f9f9 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -192,3 +192,12 @@ export type { StandardRouteEntry, } from "./types.js"; export { isStandardPluginDefinition } from "./types.js"; + +// Capability normalization (legacy → canonical alias layer) +export { + CAPABILITY_RENAMES, + isDeprecatedCapability, + normalizeCapability, + normalizeCapabilities, +} from "./types.js"; +export type { CurrentPluginCapability, DeprecatedPluginCapability } from "./types.js"; diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index 25adf1f64..7640fd0c7 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -12,7 +12,31 @@ import { z } from "zod"; // ── Enum values (must stay in sync with types.ts) ─────────────── -export const PLUGIN_CAPABILITIES = [ +/** + * Current capability names — the ones authors should use going forward. + * See `PluginCapability` in `types.ts` for documentation of each. + */ +export const CURRENT_PLUGIN_CAPABILITIES = [ + "network:request", + "network:request:unrestricted", + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", +] as const; + +/** + * Legacy capability names accepted during the deprecation window. + * Normalized to current names via `normalizeCapability()` in types.ts + * before reaching the runtime. Plugin authors are warned at bundle/validate + * and hard-failed at publish. + */ +export const DEPRECATED_PLUGIN_CAPABILITIES = [ "network:fetch", "network:fetch:any", "read:content", @@ -20,12 +44,23 @@ export const PLUGIN_CAPABILITIES = [ "read:media", "write:media", "read:users", - "email:send", "email:provide", "email:intercept", "page:inject", ] as const; +/** + * Full set of accepted capability strings — current + deprecated. + * + * The manifest schema accepts both during the transition. The runtime only + * ever sees current names because `normalizeCapability()` rewrites legacy + * names at every external boundary (definePlugin, adaptSandboxEntry). + */ +export const PLUGIN_CAPABILITIES = [ + ...CURRENT_PLUGIN_CAPABILITIES, + ...DEPRECATED_PLUGIN_CAPABILITIES, +] as const; + /** Must stay in sync with FieldType in schema/types.ts */ const FIELD_TYPES = [ "string", diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index c8d25d1fe..435804972 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -20,20 +20,151 @@ import type { FieldType } from "../schema/types.js"; // ============================================================================= /** - * Plugin capabilities determine what APIs are available in context + * Plugin capabilities determine what APIs are available in context. + * + * Capabilities follow the formula `[.]:[:]` + * — resource first, verb second, matching RBAC. The `unrestricted` qualifier + * (used by `network:request:unrestricted`) is intentionally verbose so that + * granting it stands out in manifest review. + * + * Hook-registration capabilities (`hooks.:register`) are a distinct + * audit category from data-access capabilities — they gate which hooks a + * plugin is allowed to register, not which context APIs it gets. + * + * @see CAPABILITY_RENAMES for the legacy → current mapping, and + * `normalizeCapability()` for the runtime alias layer. */ export type PluginCapability = - | "network:fetch" // ctx.http is available (host-restricted via allowedHosts) - | "network:fetch:any" // ctx.http is available (unrestricted outbound — use for user-configured URLs) - | "read:content" // ctx.content.get/list available - | "write:content" // ctx.content.create/update/delete available - | "read:media" // ctx.media.get/list available - | "write:media" // ctx.media.getUploadUrl/delete available - | "read:users" // ctx.users is available + // ── Network ───────────────────────────────────────────────── + | "network:request" // ctx.http is available (host-restricted via allowedHosts) + | "network:request:unrestricted" // ctx.http is available (unrestricted outbound — use for user-configured URLs) + // ── Content ───────────────────────────────────────────────── + | "content:read" // ctx.content.get/list available + | "content:write" // ctx.content.create/update/delete available + // ── Media ─────────────────────────────────────────────────── + | "media:read" // ctx.media.get/list available + | "media:write" // ctx.media.getUploadUrl/delete available + // ── Users ─────────────────────────────────────────────────── + | "users:read" // ctx.users is available + // ── Email ─────────────────────────────────────────────────── | "email:send" // ctx.email is available (when a provider is configured) - | "email:provide" // can register email:deliver exclusive hook (transport provider) - | "email:intercept" // can register email:beforeSend / email:afterSend hooks - | "page:inject"; // can register page:fragments hook (inject scripts/styles into pages) + // ── Hook registration ─────────────────────────────────────── + | "hooks.email-transport:register" // can register email:deliver exclusive hook (transport provider) + | "hooks.email-events:register" // can register email:beforeSend / email:afterSend hooks + | "hooks.page-fragments:register" // can register page:fragments hook (inject scripts/styles into pages) + // ── Deprecated (legacy aliases) ───────────────────────────── + // Kept in the union for one minor with @deprecated tags so existing + // plugins typecheck during migration. Normalized to current names at + // definition time via normalizeCapability(). Will be removed in the + // following minor. + /** @deprecated Use `network:request` instead. */ + | "network:fetch" + /** @deprecated Use `network:request:unrestricted` instead. */ + | "network:fetch:any" + /** @deprecated Use `content:read` instead. */ + | "read:content" + /** @deprecated Use `content:write` instead. */ + | "write:content" + /** @deprecated Use `media:read` instead. */ + | "read:media" + /** @deprecated Use `media:write` instead. */ + | "write:media" + /** @deprecated Use `users:read` instead. */ + | "read:users" + /** @deprecated Use `hooks.email-transport:register` instead. */ + | "email:provide" + /** @deprecated Use `hooks.email-events:register` instead. */ + | "email:intercept" + /** @deprecated Use `hooks.page-fragments:register` instead. */ + | "page:inject"; + +/** + * Deprecated capability names that map to current names. + * + * These are accepted at every external boundary (manifest parse, definePlugin, + * adaptSandboxEntry) and silently normalized to the new names before reaching + * the runtime. The runtime never sees deprecated names. + * + * Authors are warned at `bundle` / `validate`, and hard-failed at `publish`. + */ +export type DeprecatedPluginCapability = + | "network:fetch" + | "network:fetch:any" + | "read:content" + | "write:content" + | "read:media" + | "write:media" + | "read:users" + | "email:provide" + | "email:intercept" + | "page:inject"; + +/** + * Current (non-deprecated) capability names. + */ +export type CurrentPluginCapability = Exclude; + +/** + * Mapping from deprecated capability names to their current replacements. + * + * Used by `normalizeCapability()` and the marketplace `diffCapabilities` + * helper to compare manifests across the rename without flagging spurious + * "capability changed" prompts on upgrade. + */ +export const CAPABILITY_RENAMES: Readonly< + Record +> = Object.freeze({ + "network:fetch": "network:request", + "network:fetch:any": "network:request:unrestricted", + "read:content": "content:read", + "write:content": "content:write", + "read:media": "media:read", + "write:media": "media:write", + "read:users": "users:read", + "email:provide": "hooks.email-transport:register", + "email:intercept": "hooks.email-events:register", + "page:inject": "hooks.page-fragments:register", +}); + +/** + * Type guard: is this capability one of the deprecated legacy names? + * + * Uses an own-property check so that prototype keys like "toString" or + * "constructor" don't accidentally pass. + */ +export function isDeprecatedCapability(cap: string): cap is DeprecatedPluginCapability { + return Object.hasOwn(CAPABILITY_RENAMES, cap); +} + +/** + * Normalize a capability string — deprecated names map to current names, + * current names pass through unchanged. Unknown strings are returned as-is + * so that downstream validators can produce a precise error. + */ +export function normalizeCapability(cap: string): string { + if (isDeprecatedCapability(cap)) { + return CAPABILITY_RENAMES[cap]; + } + return cap; +} + +/** + * Normalize an array of capabilities. Deduplicates by normalized name so + * that a plugin declaring both `read:content` and `content:read` ends up + * with a single `content:read` entry. + */ +export function normalizeCapabilities(caps: readonly string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const cap of caps) { + const normalized = normalizeCapability(cap); + if (!seen.has(normalized)) { + seen.add(normalized); + out.push(normalized); + } + } + return out; +} // ============================================================================= // Storage Types diff --git a/packages/core/tests/integration/comments/hooks.test.ts b/packages/core/tests/integration/comments/hooks.test.ts index bd7e2e0b5..fde9ff345 100644 --- a/packages/core/tests/integration/comments/hooks.test.ts +++ b/packages/core/tests/integration/comments/hooks.test.ts @@ -308,7 +308,7 @@ describe("Comment Hooks with HookPipeline", () => { const plugin = definePlugin({ id: "test-enricher", version: "1.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:beforeCreate": spy, }, @@ -347,7 +347,7 @@ describe("Comment Hooks with HookPipeline", () => { const plugin = definePlugin({ id: "test-moderator", version: "1.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:moderate": { exclusive: true, @@ -398,7 +398,7 @@ describe("Comment Hooks with HookPipeline", () => { const plugin = definePlugin({ id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID, version: "0.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:moderate": { exclusive: true, @@ -449,7 +449,7 @@ describe("Comment Hooks with HookPipeline", () => { const plugin = definePlugin({ id: "test-after-create", version: "1.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:afterCreate": spy, }, @@ -485,7 +485,7 @@ describe("Comment Hooks with HookPipeline", () => { const plugin = definePlugin({ id: "test-after-moderate", version: "1.0.0", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:afterModerate": spy, }, diff --git a/packages/core/tests/integration/plugins/capabilities.test.ts b/packages/core/tests/integration/plugins/capabilities.test.ts index 6d6ff0238..ddd05dc85 100644 --- a/packages/core/tests/integration/plugins/capabilities.test.ts +++ b/packages/core/tests/integration/plugins/capabilities.test.ts @@ -33,7 +33,7 @@ import type { ResolvedPlugin } from "../../../src/plugins/types.js"; // Test regex patterns const NOT_ALLOWED_FETCH_REGEX = /not allowed to fetch from host/; const NO_ALLOWED_FETCH_REGEX = /not allowed to fetch/; -const NO_NETWORK_FETCH_REGEX = /does not have the "network:fetch" capability/; +const NO_NETWORK_FETCH_REGEX = /does not have the "network:request" capability/; const SEO_NOT_ENABLED_REGEX = /does not have SEO enabled/; /** @@ -555,7 +555,7 @@ describe("Capability Enforcement Integration (v2)", () => { const readOnlyPlugin = createTestPlugin({ id: "reader", - capabilities: ["read:content"], + capabilities: ["content:read"], }); const ctx = factory.createContext(readOnlyPlugin); @@ -572,7 +572,7 @@ describe("Capability Enforcement Integration (v2)", () => { const noContentPlugin = createTestPlugin({ id: "no-content", - capabilities: ["network:fetch"], + capabilities: ["network:request"], }); const ctx = factory.createContext(noContentPlugin); @@ -584,7 +584,7 @@ describe("Capability Enforcement Integration (v2)", () => { const networkPlugin = createTestPlugin({ id: "network", - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["api.example.com"], }); @@ -610,7 +610,7 @@ describe("Capability Enforcement Integration (v2)", () => { const plugin = createTestPlugin({ id: "unrestricted-network", - capabilities: ["network:fetch:any", "network:fetch"], + capabilities: ["network:request:unrestricted", "network:request"], }); const ctx = factory.createContext(plugin); @@ -623,7 +623,7 @@ describe("Capability Enforcement Integration (v2)", () => { const plugin = createTestPlugin({ id: "both-fetch", - capabilities: ["network:fetch", "network:fetch:any"], + capabilities: ["network:request", "network:request:unrestricted"], allowedHosts: ["restricted.example.com"], }); @@ -659,7 +659,7 @@ describe("Capability Enforcement Integration (v2)", () => { const writePlugin = createTestPlugin({ id: "writer", - capabilities: ["write:content"], + capabilities: ["content:write"], }); const ctx = factory.createContext(writePlugin); @@ -700,7 +700,7 @@ describe("Capability Enforcement Integration (v2)", () => { const plugin = createTestPlugin({ id: "user-reader", - capabilities: ["read:users"], + capabilities: ["users:read"], }); const ctx = factory.createContext(plugin); diff --git a/packages/core/tests/unit/api/marketplace-handlers.test.ts b/packages/core/tests/unit/api/marketplace-handlers.test.ts index 0e77cc8b5..1bb5d418e 100644 --- a/packages/core/tests/unit/api/marketplace-handlers.test.ts +++ b/packages/core/tests/unit/api/marketplace-handlers.test.ts @@ -129,7 +129,7 @@ function mockManifest(id = "test-seo", version = "1.0.0"): PluginManifest { return { id, version, - capabilities: ["read:content"], + capabilities: ["content:read"], allowedHosts: [], storage: {}, hooks: [], @@ -339,7 +339,7 @@ describe("Marketplace handlers", () => { expect(result.success).toBe(true); expect(result.data?.pluginId).toBe("test-seo"); expect(result.data?.version).toBe("1.0.0"); - expect(result.data?.capabilities).toEqual(["read:content"]); + expect(result.data?.capabilities).toEqual(["content:read"]); // Verify state was written const repo = new PluginStateRepository(db); @@ -571,7 +571,7 @@ describe("Marketplace handlers", () => { // New version has additional capability const newManifest = { ...mockManifest("test-seo", "2.0.0"), - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], }; const bundleBytes = await createMockBundle(newManifest as PluginManifest); @@ -618,7 +618,7 @@ describe("Marketplace handlers", () => { const newManifest = { ...mockManifest("test-seo", "2.0.0"), - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], }; const bundleBytes = await createMockBundle(newManifest as PluginManifest); @@ -639,7 +639,60 @@ describe("Marketplace handlers", () => { expect(result.success).toBe(true); expect(result.data?.oldVersion).toBe("1.0.0"); expect(result.data?.newVersion).toBe("2.0.0"); - expect(result.data?.capabilityChanges.added).toContain("network:fetch"); + expect(result.data?.capabilityChanges.added).toContain("network:request"); + }); + + it("treats deprecated → current capability rename as no change", async () => { + // Installed version declared the legacy name; new version + // declares the canonical name. diffCapabilities normalizes + // both sides, so the diff should be empty — no spurious + // "capability changed" prompt for a pure rename. + const repo = new PluginStateRepository(db); + await repo.upsert("test-seo", "1.0.0", "active", { + source: "marketplace", + marketplaceVersion: "1.0.0", + }); + + const encoder = new TextEncoder(); + const oldManifest = { + ...mockManifest("test-seo", "1.0.0"), + capabilities: ["read:content"], + }; + await storage.upload({ + key: "marketplace/test-seo/1.0.0/manifest.json", + body: encoder.encode(JSON.stringify(oldManifest)), + contentType: "application/json", + }); + await storage.upload({ + key: "marketplace/test-seo/1.0.0/backend.js", + body: encoder.encode("export default {};"), + contentType: "application/javascript", + }); + + const newManifest = { + ...mockManifest("test-seo", "2.0.0"), + capabilities: ["content:read"], + }; + const bundleBytes = await createMockBundle(newManifest as PluginManifest); + + const detail = mockPluginDetail("test-seo", "2.0.0"); + detail.latestVersion!.checksum = ""; + fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(detail), { status: 200 })); + fetchSpy.mockResolvedValueOnce(new Response(bundleBytes, { status: 200 })); + + // No `confirmCapabilityChanges` — if the diff were non-empty, + // this would fail with CAPABILITY_ESCALATION. + const result = await handleMarketplaceUpdate( + db, + storage, + sandboxRunner, + MARKETPLACE_URL, + "test-seo", + ); + + expect(result.success).toBe(true); + expect(result.data?.capabilityChanges.added).toEqual([]); + expect(result.data?.capabilityChanges.removed).toEqual([]); }); }); diff --git a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts index e0c808ba8..deea7a995 100644 --- a/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts +++ b/packages/core/tests/unit/plugins/adapt-sandbox-entry.test.ts @@ -58,12 +58,12 @@ describe("adaptSandboxEntry", () => { it("carries capabilities from descriptor", () => { const def: StandardPluginDefinition = {}; const descriptor = createDescriptor({ - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], }); const result = adaptSandboxEntry(def, descriptor); - expect(result.capabilities).toEqual(["read:content", "network:fetch"]); + expect(result.capabilities).toEqual(["content:read", "network:request"]); }); it("carries allowedHosts from descriptor", () => { @@ -336,45 +336,45 @@ describe("adaptSandboxEntry", () => { }); describe("capability normalization", () => { - it("normalizes write:content to include read:content", () => { + it("normalizes content:write to include content:read", () => { const def: StandardPluginDefinition = {}; - const descriptor = createDescriptor({ capabilities: ["write:content"] }); + const descriptor = createDescriptor({ capabilities: ["content:write"] }); const result = adaptSandboxEntry(def, descriptor); - expect(result.capabilities).toContain("write:content"); - expect(result.capabilities).toContain("read:content"); + expect(result.capabilities).toContain("content:write"); + expect(result.capabilities).toContain("content:read"); }); - it("normalizes write:media to include read:media", () => { + it("normalizes media:write to include media:read", () => { const def: StandardPluginDefinition = {}; - const descriptor = createDescriptor({ capabilities: ["write:media"] }); + const descriptor = createDescriptor({ capabilities: ["media:write"] }); const result = adaptSandboxEntry(def, descriptor); - expect(result.capabilities).toContain("write:media"); - expect(result.capabilities).toContain("read:media"); + expect(result.capabilities).toContain("media:write"); + expect(result.capabilities).toContain("media:read"); }); - it("normalizes network:fetch:any to include network:fetch", () => { + it("normalizes network:request:unrestricted to include network:request", () => { const def: StandardPluginDefinition = {}; - const descriptor = createDescriptor({ capabilities: ["network:fetch:any"] }); + const descriptor = createDescriptor({ capabilities: ["network:request:unrestricted"] }); const result = adaptSandboxEntry(def, descriptor); - expect(result.capabilities).toContain("network:fetch:any"); - expect(result.capabilities).toContain("network:fetch"); + expect(result.capabilities).toContain("network:request:unrestricted"); + expect(result.capabilities).toContain("network:request"); }); it("does not duplicate implied capabilities", () => { const def: StandardPluginDefinition = {}; const descriptor = createDescriptor({ - capabilities: ["read:content", "write:content"], + capabilities: ["content:read", "content:write"], }); const result = adaptSandboxEntry(def, descriptor); - const readCount = result.capabilities.filter((c) => c === "read:content").length; + const readCount = result.capabilities.filter((c) => c === "content:read").length; expect(readCount).toBe(1); }); @@ -386,6 +386,72 @@ describe("adaptSandboxEntry", () => { expect(() => adaptSandboxEntry(def, descriptor)).toThrow("Invalid capability"); }); + + // ── Deprecation alias layer ──────────────────────────────── + // Sandboxed plugins arrive via descriptors generated by older + // builds (or older bundle versions). The adapter must accept + // deprecated names and silently rewrite to canonical names so + // the runtime only sees the new shape. + + it("rewrites all deprecated capability names to current names", () => { + const def: StandardPluginDefinition = {}; + const descriptor = createDescriptor({ + capabilities: [ + "read:content", + "write:content", + "read:media", + "write:media", + "read:users", + "network:fetch", + "network:fetch:any", + "email:provide", + "email:intercept", + "page:inject", + ], + }); + + const result = adaptSandboxEntry(def, descriptor); + + // Canonical names present + expect(result.capabilities).toContain("content:read"); + expect(result.capabilities).toContain("content:write"); + expect(result.capabilities).toContain("media:read"); + expect(result.capabilities).toContain("media:write"); + expect(result.capabilities).toContain("users:read"); + expect(result.capabilities).toContain("network:request"); + expect(result.capabilities).toContain("network:request:unrestricted"); + expect(result.capabilities).toContain("hooks.email-transport:register"); + expect(result.capabilities).toContain("hooks.email-events:register"); + expect(result.capabilities).toContain("hooks.page-fragments:register"); + + // Deprecated names absent + for (const old of [ + "read:content", + "write:content", + "read:media", + "write:media", + "read:users", + "network:fetch", + "network:fetch:any", + "email:provide", + "email:intercept", + "page:inject", + ]) { + expect(result.capabilities).not.toContain(old); + } + }); + + it("deduplicates when both deprecated and current names are present", () => { + const def: StandardPluginDefinition = {}; + const descriptor = createDescriptor({ + capabilities: ["read:content", "content:read"], + }); + + const result = adaptSandboxEntry(def, descriptor); + + const readCount = result.capabilities.filter((c) => c === "content:read").length; + expect(readCount).toBe(1); + }); }); describe("integration with HookPipeline", () => { diff --git a/packages/core/tests/unit/plugins/capability-normalization.test.ts b/packages/core/tests/unit/plugins/capability-normalization.test.ts new file mode 100644 index 000000000..b467d6227 --- /dev/null +++ b/packages/core/tests/unit/plugins/capability-normalization.test.ts @@ -0,0 +1,184 @@ +/** + * Capability Normalization Tests + * + * Tests the deprecation alias layer for plugin capability names. The runtime + * never sees deprecated names — `normalizeCapability()` rewrites them at + * every external boundary (definePlugin, adaptSandboxEntry, marketplace + * diff). These tests pin the rename map and the normalization helpers so + * that the alias layer keeps working until the deprecated names are + * removed in the next minor. + * + * @see Issue: "Plugin capability names are inconsistent" + */ + +import { describe, it, expect } from "vitest"; + +import { + CAPABILITY_RENAMES, + isDeprecatedCapability, + normalizeCapabilities, + normalizeCapability, +} from "../../../src/plugins/types.js"; +import type { DeprecatedPluginCapability } from "../../../src/plugins/types.js"; + +describe("CAPABILITY_RENAMES", () => { + it("maps every deprecated name to its current replacement", () => { + // Pin the rename table — if the issue's table changes, this test + // catches the drift. Anyone adding a deprecation should update + // this case explicitly. + expect(CAPABILITY_RENAMES).toEqual({ + "network:fetch": "network:request", + "network:fetch:any": "network:request:unrestricted", + "read:content": "content:read", + "write:content": "content:write", + "read:media": "media:read", + "write:media": "media:write", + "read:users": "users:read", + "email:provide": "hooks.email-transport:register", + "email:intercept": "hooks.email-events:register", + "page:inject": "hooks.page-fragments:register", + }); + }); + + it("is frozen — cannot be mutated at runtime", () => { + // `Object.freeze` makes the rename table tamper-proof. + expect(Object.isFrozen(CAPABILITY_RENAMES)).toBe(true); + }); +}); + +describe("isDeprecatedCapability", () => { + it("returns true for every deprecated name in the rename table", () => { + for (const cap of Object.keys(CAPABILITY_RENAMES) as DeprecatedPluginCapability[]) { + expect(isDeprecatedCapability(cap)).toBe(true); + } + }); + + it("returns false for current capability names", () => { + const current = [ + "content:read", + "content:write", + "media:read", + "media:write", + "users:read", + "network:request", + "network:request:unrestricted", + "email:send", + "hooks.email-transport:register", + "hooks.email-events:register", + "hooks.page-fragments:register", + ]; + for (const cap of current) { + expect(isDeprecatedCapability(cap)).toBe(false); + } + }); + + it("returns false for unknown strings", () => { + expect(isDeprecatedCapability("not:a:capability")).toBe(false); + expect(isDeprecatedCapability("")).toBe(false); + expect(isDeprecatedCapability("content")).toBe(false); + }); + + it("does not match Object.prototype keys", () => { + // Regression: an `in` check against CAPABILITY_RENAMES would + // also match inherited properties. Using `Object.prototype.hasOwnProperty` + // (or `Object.hasOwn`) keeps the check scoped to own properties. + // Without the guard, `normalizeCapability("toString")` would return + // the prototype function reference, breaking the contract that + // unknown strings are returned as-is. + expect(isDeprecatedCapability("toString")).toBe(false); + expect(isDeprecatedCapability("constructor")).toBe(false); + expect(isDeprecatedCapability("hasOwnProperty")).toBe(false); + expect(isDeprecatedCapability("__proto__")).toBe(false); + expect(isDeprecatedCapability("valueOf")).toBe(false); + }); +}); + +describe("normalizeCapability", () => { + it("rewrites deprecated names to current names", () => { + expect(normalizeCapability("read:content")).toBe("content:read"); + expect(normalizeCapability("write:content")).toBe("content:write"); + expect(normalizeCapability("read:media")).toBe("media:read"); + expect(normalizeCapability("write:media")).toBe("media:write"); + expect(normalizeCapability("read:users")).toBe("users:read"); + expect(normalizeCapability("network:fetch")).toBe("network:request"); + expect(normalizeCapability("network:fetch:any")).toBe("network:request:unrestricted"); + expect(normalizeCapability("email:provide")).toBe("hooks.email-transport:register"); + expect(normalizeCapability("email:intercept")).toBe("hooks.email-events:register"); + expect(normalizeCapability("page:inject")).toBe("hooks.page-fragments:register"); + }); + + it("leaves current names unchanged", () => { + expect(normalizeCapability("content:read")).toBe("content:read"); + expect(normalizeCapability("network:request")).toBe("network:request"); + expect(normalizeCapability("hooks.email-transport:register")).toBe( + "hooks.email-transport:register", + ); + }); + + it("passes through unknown strings unchanged", () => { + // Downstream validators throw on unknown capabilities; the + // normalizer's job is purely to translate the alias map. + expect(normalizeCapability("invalid:capability")).toBe("invalid:capability"); + expect(normalizeCapability("")).toBe(""); + }); + + it("returns Object.prototype keys as-is (does not return prototype values)", () => { + // Regression: with an `in` check, `normalizeCapability("toString")` + // would have returned `Object.prototype.toString` (a function). + // The own-property guard ensures we always return a string. + expect(normalizeCapability("toString")).toBe("toString"); + expect(normalizeCapability("constructor")).toBe("constructor"); + expect(normalizeCapability("__proto__")).toBe("__proto__"); + }); +}); + +describe("normalizeCapabilities", () => { + it("rewrites every deprecated name in an array", () => { + const input = ["read:content", "write:content", "network:fetch"]; + const result = normalizeCapabilities(input); + + expect(result).toEqual(["content:read", "content:write", "network:request"]); + }); + + it("preserves order of first occurrence", () => { + const result = normalizeCapabilities(["network:request", "read:content", "write:media"]); + + expect(result).toEqual(["network:request", "content:read", "media:write"]); + }); + + it("deduplicates by canonical name when both old and new are present", () => { + // A plugin migrating from old to new might transiently declare + // both — the normalizer must not produce duplicates. + const result = normalizeCapabilities(["read:content", "content:read"]); + + expect(result).toEqual(["content:read"]); + }); + + it("deduplicates two deprecated names that map to the same current name", () => { + // Defensive: if someone declares the same alias twice, the result + // must still contain it only once. + const result = normalizeCapabilities(["read:content", "read:content"]); + + expect(result).toEqual(["content:read"]); + }); + + it("returns empty array for empty input", () => { + expect(normalizeCapabilities([])).toEqual([]); + }); + + it("does not mutate the input array", () => { + const input = ["read:content", "write:content"]; + const snapshot = [...input]; + normalizeCapabilities(input); + + expect(input).toEqual(snapshot); + }); + + it("is idempotent — normalizing twice gives the same result", () => { + const input = ["read:content", "write:media", "page:inject"]; + const once = normalizeCapabilities(input); + const twice = normalizeCapabilities(once); + + expect(twice).toEqual(once); + }); +}); diff --git a/packages/core/tests/unit/plugins/define-plugin.test.ts b/packages/core/tests/unit/plugins/define-plugin.test.ts index 901f0f265..d50bbf013 100644 --- a/packages/core/tests/unit/plugins/define-plugin.test.ts +++ b/packages/core/tests/unit/plugins/define-plugin.test.ts @@ -163,23 +163,23 @@ describe("definePlugin", () => { const plugin = definePlugin({ id: "test", version: "1.0.0", - capabilities: ["read:content", "write:content", "network:fetch"], + capabilities: ["content:read", "content:write", "network:request"], }); - expect(plugin.capabilities).toContain("read:content"); - expect(plugin.capabilities).toContain("write:content"); - expect(plugin.capabilities).toContain("network:fetch"); + expect(plugin.capabilities).toContain("content:read"); + expect(plugin.capabilities).toContain("content:write"); + expect(plugin.capabilities).toContain("network:request"); }); - it("accepts read:media and write:media", () => { + it("accepts media:read and media:write", () => { const plugin = definePlugin({ id: "test", version: "1.0.0", - capabilities: ["read:media", "write:media"], + capabilities: ["media:read", "media:write"], }); - expect(plugin.capabilities).toContain("read:media"); - expect(plugin.capabilities).toContain("write:media"); + expect(plugin.capabilities).toContain("media:read"); + expect(plugin.capabilities).toContain("media:write"); }); it("rejects invalid capability", () => { @@ -192,36 +192,47 @@ describe("definePlugin", () => { ).toThrow(INVALID_CAPABILITY_PATTERN); }); - it("normalizes write:content to include read:content", () => { + it("normalizes content:write to include content:read", () => { const plugin = definePlugin({ id: "test", version: "1.0.0", - capabilities: ["write:content"], + capabilities: ["content:write"], + }); + + expect(plugin.capabilities).toContain("content:write"); + expect(plugin.capabilities).toContain("content:read"); + }); + + it("normalizes media:write to include media:read", () => { + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["media:write"], }); - expect(plugin.capabilities).toContain("write:content"); - expect(plugin.capabilities).toContain("read:content"); + expect(plugin.capabilities).toContain("media:write"); + expect(plugin.capabilities).toContain("media:read"); }); - it("normalizes write:media to include read:media", () => { + it("normalizes network:request:unrestricted to include network:request", () => { const plugin = definePlugin({ id: "test", version: "1.0.0", - capabilities: ["write:media"], + capabilities: ["network:request:unrestricted"], }); - expect(plugin.capabilities).toContain("write:media"); - expect(plugin.capabilities).toContain("read:media"); + expect(plugin.capabilities).toContain("network:request:unrestricted"); + expect(plugin.capabilities).toContain("network:request"); }); it("does not duplicate read when already present", () => { const plugin = definePlugin({ id: "test", version: "1.0.0", - capabilities: ["read:content", "write:content"], + capabilities: ["content:read", "content:write"], }); - const readCount = plugin.capabilities.filter((c) => c === "read:content").length; + const readCount = plugin.capabilities.filter((c) => c === "content:read").length; expect(readCount).toBe(1); }); @@ -233,6 +244,78 @@ describe("definePlugin", () => { expect(plugin.capabilities).toEqual([]); }); + + // ── Deprecation alias layer ──────────────────────────────── + // During the deprecation window we accept the old names and + // silently rewrite them to the new names. The runtime should + // only ever see canonical (new) names. + + it("accepts and normalizes deprecated capability names", () => { + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + capabilities: [ + "read:content", + "write:content", + "read:media", + "write:media", + "read:users", + "network:fetch", + "network:fetch:any", + "email:provide", + "email:intercept", + "page:inject", + ], + }); + + // Normalized to current names + expect(plugin.capabilities).toContain("content:read"); + expect(plugin.capabilities).toContain("content:write"); + expect(plugin.capabilities).toContain("media:read"); + expect(plugin.capabilities).toContain("media:write"); + expect(plugin.capabilities).toContain("users:read"); + expect(plugin.capabilities).toContain("network:request"); + expect(plugin.capabilities).toContain("network:request:unrestricted"); + expect(plugin.capabilities).toContain("hooks.email-transport:register"); + expect(plugin.capabilities).toContain("hooks.email-events:register"); + expect(plugin.capabilities).toContain("hooks.page-fragments:register"); + + // And the deprecated names do NOT appear in the resolved capabilities + expect(plugin.capabilities).not.toContain("read:content"); + expect(plugin.capabilities).not.toContain("write:content"); + expect(plugin.capabilities).not.toContain("network:fetch"); + expect(plugin.capabilities).not.toContain("network:fetch:any"); + expect(plugin.capabilities).not.toContain("email:provide"); + expect(plugin.capabilities).not.toContain("email:intercept"); + expect(plugin.capabilities).not.toContain("page:inject"); + }); + + it("deduplicates when both deprecated and current names are passed", () => { + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + // Same capability, both spellings + capabilities: ["read:content", "content:read"], + }); + + const readCount = plugin.capabilities.filter((c) => c === "content:read").length; + expect(readCount).toBe(1); + }); + + it("normalizes deprecated names before applying implications", () => { + // `write:content` (deprecated) should still imply `content:read` + // after rewrite, not `read:content`. + const plugin = definePlugin({ + id: "test", + version: "1.0.0", + capabilities: ["write:content"], + }); + + expect(plugin.capabilities).toContain("content:write"); + expect(plugin.capabilities).toContain("content:read"); + expect(plugin.capabilities).not.toContain("write:content"); + expect(plugin.capabilities).not.toContain("read:content"); + }); }); describe("hook resolution", () => { diff --git a/packages/core/tests/unit/plugins/email-pipeline.test.ts b/packages/core/tests/unit/plugins/email-pipeline.test.ts index 74af7ebb4..8c1f86186 100644 --- a/packages/core/tests/unit/plugins/email-pipeline.test.ts +++ b/packages/core/tests/unit/plugins/email-pipeline.test.ts @@ -119,14 +119,14 @@ describe("HookPipeline — email:beforeSend", () => { const plugin1 = createTestPlugin({ id: "plugin-first", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("plugin-first", handler1, { priority: 50 }), }, }); const plugin2 = createTestPlugin({ id: "plugin-second", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("plugin-second", handler2, { priority: 150 }), }, @@ -150,14 +150,14 @@ describe("HookPipeline — email:beforeSend", () => { const plugin1 = createTestPlugin({ id: "modifier-1", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("modifier-1", handler1, { priority: 50 }), }, }); const plugin2 = createTestPlugin({ id: "modifier-2", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("modifier-2", handler2, { priority: 150 }), }, @@ -181,14 +181,14 @@ describe("HookPipeline — email:beforeSend", () => { const plugin1 = createTestPlugin({ id: "canceller", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("canceller", handler1, { priority: 50 }), }, }); const plugin2 = createTestPlugin({ id: "after-cancel", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("after-cancel", handler2, { priority: 150 }), }, @@ -211,7 +211,7 @@ describe("HookPipeline — email:beforeSend", () => { const plugin = createTestPlugin({ id: "source-checker", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("source-checker", handler), }, @@ -256,14 +256,14 @@ describe("HookPipeline — email:afterSend", () => { const plugin1 = createTestPlugin({ id: "logger-a", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("logger-a", handler1), }, }); const plugin2 = createTestPlugin({ id: "logger-b", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("logger-b", handler2), }, @@ -285,14 +285,14 @@ describe("HookPipeline — email:afterSend", () => { const plugin1 = createTestPlugin({ id: "broken-logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("broken-logger", errorHandler, { priority: 50 }), }, }); const plugin2 = createTestPlugin({ id: "good-logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("good-logger", successHandler, { priority: 150 }), }, @@ -343,7 +343,7 @@ describe("EmailPipeline", () => { const provider = createTestPlugin({ id: "test-provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("test-provider", deliverHandler, { exclusive: true }), }, @@ -385,7 +385,7 @@ describe("EmailPipeline", () => { const middlewarePlugin = createTestPlugin({ id: "middleware", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("middleware", beforeSendHandler), }, @@ -393,7 +393,7 @@ describe("EmailPipeline", () => { const providerPlugin = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -401,7 +401,7 @@ describe("EmailPipeline", () => { const loggerPlugin = createTestPlugin({ id: "logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("logger", afterSendHandler), }, @@ -431,7 +431,7 @@ describe("EmailPipeline", () => { const cancellerPlugin = createTestPlugin({ id: "canceller", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook( "canceller", @@ -442,7 +442,7 @@ describe("EmailPipeline", () => { const providerPlugin = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -450,7 +450,7 @@ describe("EmailPipeline", () => { const loggerPlugin = createTestPlugin({ id: "logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("logger", afterSendHandler), }, @@ -479,7 +479,7 @@ describe("EmailPipeline", () => { const provider = createTestPlugin({ id: "broken-provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("broken-provider", deliverHandler, { exclusive: true }), }, @@ -502,14 +502,14 @@ describe("EmailPipeline", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, }); const logger = createTestPlugin({ id: "broken-logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": createTestHook("broken-logger", afterSendHandler), }, @@ -624,10 +624,10 @@ describe("definePlugin — email capabilities", () => { const plugin = definePlugin({ id: "email-provider", version: "1.0.0", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], }); - expect(plugin.capabilities).toContain("email:provide"); + expect(plugin.capabilities).toContain("hooks.email-transport:register"); }); it("accepts email:intercept as a valid capability", async () => { @@ -636,10 +636,10 @@ describe("definePlugin — email capabilities", () => { const plugin = definePlugin({ id: "email-interceptor", version: "1.0.0", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], }); - expect(plugin.capabilities).toContain("email:intercept"); + expect(plugin.capabilities).toContain("hooks.email-events:register"); }); }); @@ -762,7 +762,7 @@ describe("ctx.email gating", () => { const deliverHandler: EmailDeliverHandler = async () => {}; const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -788,7 +788,7 @@ describe("ctx.email gating", () => { const deliverHandler: EmailDeliverHandler = async () => {}; const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -835,7 +835,7 @@ describe("ctx.email gating", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -887,7 +887,7 @@ describe("Email Pipeline — full integration with PluginManager", () => { manager.register({ id: "email-resend", version: "1.0.0", - capabilities: ["network:fetch", "email:provide"], + capabilities: ["network:request", "hooks.email-transport:register"], allowedHosts: ["api.resend.com"], hooks: { "email:deliver": { @@ -930,7 +930,7 @@ describe("Dev Console — as pipeline provider", () => { it("sends email through dev console provider end-to-end", async () => { const devProvider = createTestPlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook(DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver, { exclusive: true, @@ -959,7 +959,7 @@ describe("Dev Console — as pipeline provider", () => { it("beforeSend middleware modifies message before dev console receives it", async () => { const footerMiddleware = createTestPlugin({ id: "footer-middleware", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("footer-middleware", (async ( event: EmailBeforeSendEvent, @@ -974,7 +974,7 @@ describe("Dev Console — as pipeline provider", () => { const devProvider = createTestPlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook(DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver, { exclusive: true, @@ -1028,7 +1028,7 @@ describe("EmailPipeline — recursion guard", () => { const plugin = createTestPlugin({ id: "recursive-plugin", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("recursive-plugin", recursiveHandler, { errorPolicy: "abort", @@ -1038,7 +1038,7 @@ describe("EmailPipeline — recursion guard", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1072,7 +1072,7 @@ describe("EmailPipeline — recursion guard", () => { const plugin = createTestPlugin({ id: "recursive-plugin", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("recursive-plugin", recursiveHandler, { errorPolicy: "abort", @@ -1082,7 +1082,7 @@ describe("EmailPipeline — recursion guard", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1129,7 +1129,7 @@ describe("EmailPipeline — system email protection", () => { const interceptor = createTestPlugin({ id: "evil-interceptor", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("evil-interceptor", interceptorHandler), }, @@ -1137,7 +1137,7 @@ describe("EmailPipeline — system email protection", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1178,7 +1178,7 @@ describe("EmailPipeline — system email protection", () => { const canceller = createTestPlugin({ id: "canceller", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("canceller", cancelHandler), }, @@ -1186,7 +1186,7 @@ describe("EmailPipeline — system email protection", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1211,7 +1211,7 @@ describe("EmailPipeline — system email protection", () => { const interceptor = createTestPlugin({ id: "interceptor", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("interceptor", handler), }, @@ -1219,7 +1219,7 @@ describe("EmailPipeline — system email protection", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1245,7 +1245,7 @@ describe("EmailPipeline — system email protection", () => { const interceptor = createTestPlugin({ id: "redirector", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("redirector", redirectHandler), }, @@ -1253,7 +1253,7 @@ describe("EmailPipeline — system email protection", () => { const provider = createTestPlugin({ id: "provider", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider", deliverHandler, { exclusive: true }), }, @@ -1293,7 +1293,7 @@ describe("EmailPipeline — cancellation audit", () => { const plugin = createTestPlugin({ id: "filter-plugin", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("filter-plugin", cancelHandler), }, diff --git a/packages/core/tests/unit/plugins/exclusive-hooks.test.ts b/packages/core/tests/unit/plugins/exclusive-hooks.test.ts index b45f5a677..34747b1d0 100644 --- a/packages/core/tests/unit/plugins/exclusive-hooks.test.ts +++ b/packages/core/tests/unit/plugins/exclusive-hooks.test.ts @@ -35,7 +35,7 @@ function createTestPlugin(overrides: Partial = {}): ResolvedPlug return { id: overrides.id ?? "test-plugin", version: "1.0.0", - capabilities: ["write:content", "read:content"], + capabilities: ["content:write", "content:read"], allowedHosts: [], storage: {}, admin: { @@ -73,7 +73,7 @@ function createTestDefinition(overrides: Partial = {}): Plugin return { id: overrides.id ?? "test-plugin", version: "1.0.0", - capabilities: ["write:content", "read:content"], + capabilities: ["content:write", "content:read"], ...overrides, }; } @@ -365,14 +365,14 @@ describe("HookPipeline — getHookProviders", () => { it("returns non-exclusive providers registered for a hook", () => { const plugin1 = createTestPlugin({ id: "middleware-a", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("middleware-a", vi.fn()), }, }); const plugin2 = createTestPlugin({ id: "middleware-b", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": createTestHook("middleware-b", vi.fn()), }, diff --git a/packages/core/tests/unit/plugins/hooks.test.ts b/packages/core/tests/unit/plugins/hooks.test.ts index cb0525ce3..46aa2ee1c 100644 --- a/packages/core/tests/unit/plugins/hooks.test.ts +++ b/packages/core/tests/unit/plugins/hooks.test.ts @@ -66,7 +66,7 @@ describe("HookPipeline", () => { it("registers hooks from plugins", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["write:content", "read:content"], + capabilities: ["content:write", "content:read"], hooks: { "content:beforeSave": createTestHook("test", vi.fn()), "content:afterSave": createTestHook("test", vi.fn()), @@ -83,7 +83,7 @@ describe("HookPipeline", () => { it("tracks registered hook names", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["write:content", "read:media"], + capabilities: ["content:write", "media:read"], hooks: { "content:beforeSave": createTestHook("test", vi.fn()), "media:afterUpload": createTestHook("test", vi.fn()), @@ -107,7 +107,7 @@ describe("HookPipeline", () => { const plugin1 = createTestPlugin({ id: "plugin-1", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-1", handler1, { priority: 200, @@ -117,7 +117,7 @@ describe("HookPipeline", () => { const plugin2 = createTestPlugin({ id: "plugin-2", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-2", handler2, { priority: 50, @@ -127,7 +127,7 @@ describe("HookPipeline", () => { const plugin3 = createTestPlugin({ id: "plugin-3", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-3", handler3, { priority: 100, @@ -147,7 +147,7 @@ describe("HookPipeline", () => { const plugin1 = createTestPlugin({ id: "plugin-1", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-1", handler1, { priority: 50, // Lower priority but... @@ -158,7 +158,7 @@ describe("HookPipeline", () => { const plugin2 = createTestPlugin({ id: "plugin-2", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-2", handler2, { priority: 100, // Higher priority @@ -183,7 +183,7 @@ describe("HookPipeline", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("test", handler), }, @@ -210,7 +210,7 @@ describe("HookPipeline", () => { const plugin1 = createTestPlugin({ id: "plugin-1", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-1", handler1, { priority: 1, @@ -220,7 +220,7 @@ describe("HookPipeline", () => { const plugin2 = createTestPlugin({ id: "plugin-2", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-2", handler2, { priority: 2, @@ -239,7 +239,7 @@ describe("HookPipeline", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { "content:beforeDelete": createTestHook("test", handler), }, @@ -314,7 +314,7 @@ describe("HookPipeline", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["write:media"], + capabilities: ["media:write"], hooks: { "media:beforeUpload": createTestHook("test", handler), }, @@ -329,7 +329,7 @@ describe("HookPipeline", () => { const plugin = createTestPlugin({ id: "test", - capabilities: ["read:media"], + capabilities: ["media:read"], hooks: { "media:afterUpload": createTestHook("test", handler), }, @@ -354,7 +354,7 @@ describe("HookPipeline", () => { // ========================================================================= describe("capability enforcement — content hooks", () => { - it("skips content:beforeSave without write:content capability", () => { + it("skips content:beforeSave without content:write capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -367,10 +367,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:beforeSave")).toBe(false); }); - it("skips content:beforeSave with only read:content (requires write:content)", () => { + it("skips content:beforeSave with only content:read (requires content:write)", () => { const plugin = createTestPlugin({ id: "read-only", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { "content:beforeSave": createTestHook("read-only", vi.fn()), }, @@ -380,10 +380,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:beforeSave")).toBe(false); }); - it("registers content:beforeSave with write:content capability", () => { + it("registers content:beforeSave with content:write capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("has-cap", vi.fn()), }, @@ -393,7 +393,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:beforeSave")).toBe(true); }); - it("skips content:afterSave without read:content capability", () => { + it("skips content:afterSave without content:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -406,10 +406,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterSave")).toBe(false); }); - it("registers content:afterSave with read:content capability (read-only notification)", () => { + it("registers content:afterSave with content:read capability (read-only notification)", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { "content:afterSave": createTestHook("has-cap", vi.fn()), }, @@ -419,7 +419,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterSave")).toBe(true); }); - it("skips content:beforeDelete without read:content capability", () => { + it("skips content:beforeDelete without content:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -432,7 +432,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:beforeDelete")).toBe(false); }); - it("skips content:afterDelete without read:content capability", () => { + it("skips content:afterDelete without content:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -445,10 +445,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterDelete")).toBe(false); }); - it("registers all content hooks with write:content + read:content", () => { + it("registers all content hooks with content:write + content:read", () => { const plugin = createTestPlugin({ id: "writer", - capabilities: ["write:content", "read:content"], + capabilities: ["content:write", "content:read"], hooks: { "content:beforeSave": createTestHook("writer", vi.fn()), "content:afterSave": createTestHook("writer", vi.fn()), @@ -468,7 +468,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterUnpublish")).toBe(true); }); - it("skips content:afterPublish without read:content capability", () => { + it("skips content:afterPublish without content:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -481,10 +481,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterPublish")).toBe(false); }); - it("registers content:afterPublish with read:content capability", () => { + it("registers content:afterPublish with content:read capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { "content:afterPublish": createTestHook("has-cap", vi.fn()), }, @@ -494,7 +494,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterPublish")).toBe(true); }); - it("skips content:afterUnpublish without read:content capability", () => { + it("skips content:afterUnpublish without content:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -507,10 +507,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("content:afterUnpublish")).toBe(false); }); - it("registers content:afterUnpublish with read:content capability", () => { + it("registers content:afterUnpublish with content:read capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["read:content"], + capabilities: ["content:read"], hooks: { "content:afterUnpublish": createTestHook("has-cap", vi.fn()), }, @@ -522,7 +522,7 @@ describe("HookPipeline", () => { }); describe("capability enforcement — media hooks", () => { - it("skips media:beforeUpload without write:media capability", () => { + it("skips media:beforeUpload without media:write capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -535,10 +535,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("media:beforeUpload")).toBe(false); }); - it("registers media:beforeUpload with write:media capability", () => { + it("registers media:beforeUpload with media:write capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["write:media"], + capabilities: ["media:write"], hooks: { "media:beforeUpload": createTestHook("has-cap", vi.fn()), }, @@ -548,7 +548,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("media:beforeUpload")).toBe(true); }); - it("skips media:afterUpload without read:media capability", () => { + it("skips media:afterUpload without media:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -561,10 +561,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("media:afterUpload")).toBe(false); }); - it("registers media:afterUpload with read:media capability", () => { + it("registers media:afterUpload with media:read capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["read:media"], + capabilities: ["media:read"], hooks: { "media:afterUpload": createTestHook("has-cap", vi.fn()), }, @@ -576,7 +576,7 @@ describe("HookPipeline", () => { }); describe("capability enforcement — comment hooks", () => { - it("skips comment:beforeCreate without read:users capability", () => { + it("skips comment:beforeCreate without users:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -589,10 +589,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("comment:beforeCreate")).toBe(false); }); - it("registers comment:beforeCreate with read:users capability", () => { + it("registers comment:beforeCreate with users:read capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["read:users"], + capabilities: ["users:read"], hooks: { "comment:beforeCreate": createTestHook("has-cap", vi.fn()), }, @@ -602,7 +602,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("comment:beforeCreate")).toBe(true); }); - it("skips comment:moderate without read:users capability", () => { + it("skips comment:moderate without users:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -615,7 +615,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("comment:moderate")).toBe(false); }); - it("skips comment:afterCreate without read:users capability", () => { + it("skips comment:afterCreate without users:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -628,7 +628,7 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("comment:afterCreate")).toBe(false); }); - it("skips comment:afterModerate without read:users capability", () => { + it("skips comment:afterModerate without users:read capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -643,7 +643,7 @@ describe("HookPipeline", () => { }); describe("capability enforcement — page:fragments", () => { - it("skips page:fragments without page:inject capability", () => { + it("skips page:fragments without hooks.page-fragments:register capability", () => { const plugin = createTestPlugin({ id: "no-cap", capabilities: [], @@ -656,10 +656,10 @@ describe("HookPipeline", () => { expect(pipeline.hasHooks("page:fragments")).toBe(false); }); - it("registers page:fragments with page:inject capability", () => { + it("registers page:fragments with hooks.page-fragments:register capability", () => { const plugin = createTestPlugin({ id: "has-cap", - capabilities: ["page:inject"], + capabilities: ["hooks.page-fragments:register"], hooks: { "page:fragments": createTestHook("has-cap", vi.fn()), }, diff --git a/packages/core/tests/unit/plugins/manager.test.ts b/packages/core/tests/unit/plugins/manager.test.ts index 73835d2de..eec619d0b 100644 --- a/packages/core/tests/unit/plugins/manager.test.ts +++ b/packages/core/tests/unit/plugins/manager.test.ts @@ -73,13 +73,13 @@ describe("PluginManager", () => { const resolved = manager.register( createTestDefinition({ id: "test", - capabilities: ["write:content"], + capabilities: ["content:write"], }), ); - // write:content should add read:content - expect(resolved.capabilities).toContain("write:content"); - expect(resolved.capabilities).toContain("read:content"); + // content:write should add content:read + expect(resolved.capabilities).toContain("content:write"); + expect(resolved.capabilities).toContain("content:read"); }); it("throws on duplicate registration", () => { diff --git a/packages/core/tests/unit/plugins/marketplace-client.test.ts b/packages/core/tests/unit/plugins/marketplace-client.test.ts index 32de1527d..186efd4b6 100644 --- a/packages/core/tests/unit/plugins/marketplace-client.test.ts +++ b/packages/core/tests/unit/plugins/marketplace-client.test.ts @@ -293,7 +293,7 @@ describe("MarketplaceClient", () => { const manifest = { id: "test-seo", version: "1.0.0", - capabilities: ["read:content"], + capabilities: ["content:read"], allowedHosts: [], storage: {}, hooks: [], diff --git a/packages/core/tests/unit/plugins/page-hooks-execution.test.ts b/packages/core/tests/unit/plugins/page-hooks-execution.test.ts index 4bcb357b7..5d028c8d6 100644 --- a/packages/core/tests/unit/plugins/page-hooks-execution.test.ts +++ b/packages/core/tests/unit/plugins/page-hooks-execution.test.ts @@ -248,7 +248,7 @@ describe("page:fragments hook execution", () => { const plugin = createTestPlugin({ id: "test-fragment", - capabilities: ["page:inject"], + capabilities: ["hooks.page-fragments:register"], hooks: { "page:fragments": createTestHook("test-fragment", fragmentHandler), }, @@ -270,7 +270,7 @@ describe("page:fragments hook execution", () => { ]); }); - it("requires page:inject capability for page:fragments", () => { + it("requires hooks.page-fragments:register capability for page:fragments", () => { const handler: PageFragmentHandler = vi.fn(async () => null); const pluginWithoutCap = createTestPlugin({ @@ -296,7 +296,7 @@ describe("page:fragments hook execution", () => { const plugin = createTestPlugin({ id: "analytics", - capabilities: ["page:inject"], + capabilities: ["hooks.page-fragments:register"], hooks: { "page:fragments": createTestHook("analytics", fragmentHandler), }, diff --git a/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts b/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts index 53dba3abb..b91237cef 100644 --- a/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts +++ b/packages/core/tests/unit/plugins/pipeline-rebuild.test.ts @@ -85,7 +85,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginA = createTestPlugin({ id: "plugin-a", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-a", handlerA), }, @@ -93,7 +93,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginB = createTestPlugin({ id: "plugin-b", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-b", handlerB), }, @@ -140,7 +140,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginA = createTestPlugin({ id: "plugin-a", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-a", handlerA), }, @@ -148,7 +148,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginB = createTestPlugin({ id: "plugin-b", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("plugin-b", handlerB), }, @@ -179,7 +179,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginA = createTestPlugin({ id: "provider-a", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider-a", handlerA, { exclusive: true }), }, @@ -187,7 +187,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const pluginB = createTestPlugin({ id: "provider-b", - capabilities: ["email:provide"], + capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": createTestHook("provider-b", handlerB, { exclusive: true }), }, @@ -227,7 +227,7 @@ describe("HookPipeline rebuild on plugin disable/enable (#105)", () => { const plugin = createTestPlugin({ id: "only-plugin", - capabilities: ["write:content"], + capabilities: ["content:write"], hooks: { "content:beforeSave": createTestHook("only-plugin", handler), }, diff --git a/packages/core/tests/unit/plugins/standard-format.test.ts b/packages/core/tests/unit/plugins/standard-format.test.ts index 842ff1a62..3ee95d36d 100644 --- a/packages/core/tests/unit/plugins/standard-format.test.ts +++ b/packages/core/tests/unit/plugins/standard-format.test.ts @@ -179,7 +179,7 @@ describe("generatePluginsModule() standard format", () => { version: "2.0.0", entrypoint: "@my/standard-plugin", format: "standard", - capabilities: ["read:content"], + capabilities: ["content:read"], }, ]; @@ -232,7 +232,7 @@ describe("generatePluginsModule() standard format", () => { version: "1.0.0", entrypoint: "@my/plugin", format: "standard", - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], allowedHosts: ["api.example.com"], storage: { events: { indexes: ["timestamp"] } }, adminPages: [{ path: "/settings", label: "Settings" }], @@ -244,7 +244,7 @@ describe("generatePluginsModule() standard format", () => { // The descriptor metadata should be serialized into the adapter call expect(code).toContain('"id":"my-plugin"'); expect(code).toContain('"version":"1.0.0"'); - expect(code).toContain('"capabilities":["read:content","network:fetch"]'); + expect(code).toContain('"capabilities":["content:read","network:request"]'); expect(code).toContain('"allowedHosts":["api.example.com"]'); expect(code).toContain('"storage":{"events":{"indexes":["timestamp"]}}'); }); diff --git a/skills/creating-plugins/SKILL.md b/skills/creating-plugins/SKILL.md index a9680dc29..35ddd9e87 100644 --- a/skills/creating-plugins/SKILL.md +++ b/skills/creating-plugins/SKILL.md @@ -132,14 +132,14 @@ EmDash has two execution modes. Plugin code is identical in both — only the en Trusted plugins are npm packages or local files added in `astro.config.mjs`. They run in-process with your Astro site. -- **Capabilities are documentation only.** Declaring `["read:content"]` documents intent but isn't enforced — the plugin has full process access. +- **Capabilities are documentation only.** Declaring `["content:read"]` documents intent but isn't enforced — the plugin has full process access. - Only install from sources you trust. A malicious trusted plugin has the same access as your application code. ### Sandboxed Mode Sandboxed plugins run in isolated V8 isolates on Cloudflare Workers via [Dynamic Worker Loader](https://developers.cloudflare.com/workers/runtime-apis/bindings/worker-loader/). Each plugin gets its own isolate. -- **Capabilities are enforced.** If a plugin declares `["read:content"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error. +- **Capabilities are enforced.** If a plugin declares `["content:read"]`, it can only call `ctx.content.get()` and `ctx.content.list()`. Attempting `ctx.content.create()` throws a permission error. - **Network is blocked by default.** Direct `fetch()` calls fail. Plugins must use `ctx.http.fetch()`, which validates against `allowedHosts`. - **Storage is scoped.** A plugin can only access its own KV and storage collections. - **Admin UI uses Block Kit.** Sandboxed plugins describe their UI as JSON blocks -- no plugin JavaScript runs in the browser. See [Block Kit reference](./references/block-kit.md). @@ -161,7 +161,7 @@ export default definePlugin({ hooks: { "content:afterSave": { handler: async (event: any, ctx: PluginContext) => { - // Trusted: ctx.http present because descriptor declares network:fetch + // Trusted: ctx.http present because descriptor declares network:request // Sandboxed: ctx.http present and enforced via RPC bridge if (!ctx.http) return; await ctx.http.fetch("https://api.analytics.example.com/track", { @@ -180,25 +180,27 @@ Key constraint for sandbox compatibility: **no Node.js built-ins** (`fs`, `path` Capabilities control what APIs are available on `ctx`. Always declare what your plugin needs — even in trusted mode, they document intent and are required for sandboxed execution. -| Capability | Grants | `ctx` property | -| ----------------- | ---------------------------------------------------------------------- | -------------- | -| `read:content` | `ctx.content.get()`, `ctx.content.list()` | `content` | -| `write:content` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` | -| `read:media` | `ctx.media.get()`, `ctx.media.list()` | `media` | -| `write:media` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` | -| `network:fetch` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` | -| `read:users` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` | -| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` | -| `email:provide` | Can register `email:deliver` exclusive hook (transport provider) | — | -| `email:intercept` | Can register `email:beforeSend` / `email:afterSend` hooks | — | +| Capability | Grants | `ctx` property | +| -------------------------------- | ---------------------------------------------------------------------- | -------------- | +| `content:read` | `ctx.content.get()`, `ctx.content.list()` | `content` | +| `content:write` | `ctx.content.create()`, `ctx.content.update()`, `ctx.content.delete()` | `content` | +| `media:read` | `ctx.media.get()`, `ctx.media.list()` | `media` | +| `media:write` | `ctx.media.getUploadUrl()`, `ctx.media.delete()` | `media` | +| `network:request` | `ctx.http.fetch()` (restricted to `allowedHosts`) | `http` | +| `network:request:unrestricted` | `ctx.http.fetch()` (unrestricted — for user-configured URLs) | `http` | +| `users:read` | `ctx.users.get()`, `ctx.users.list()`, `ctx.users.getByEmail()` | `users` | +| `email:send` | `ctx.email.send()` — send email through the pipeline | `email` | +| `hooks.email-transport:register` | Can register `email:deliver` exclusive hook (transport provider) | — | +| `hooks.email-events:register` | Can register `email:beforeSend` / `email:afterSend` hooks | — | +| `hooks.page-fragments:register` | Can register `page:fragments` hook (inject scripts/styles into pages) | — | Storage (`ctx.storage`) and KV (`ctx.kv`) are **always available** — no capability needed. They're automatically scoped to the plugin. **Email capabilities are distinct:** - `email:send` — for plugins that _consume_ email (call `ctx.email.send()`) -- `email:provide` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP) -- `email:intercept` — for plugins that _observe or transform_ email (middleware hooks) +- `hooks.email-transport:register` — for plugins that _deliver_ email (implement the transport, e.g. Resend, SMTP) +- `hooks.email-events:register` — for plugins that _observe or transform_ email (middleware hooks) ```typescript // In the descriptor (index.ts) @@ -209,7 +211,7 @@ export function myPlugin(): PluginDescriptor { format: "standard", entrypoint: "@my-org/my-plugin/sandbox", options: {}, - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], allowedHosts: ["api.example.com", "*.googleapis.com"], // Wildcards supported }; } @@ -296,7 +298,7 @@ export function submissionsPlugin(): PluginDescriptor { format: "standard", entrypoint: "@my-org/plugin-submissions/sandbox", options: {}, - capabilities: ["read:content"], + capabilities: ["content:read"], storage: { submissions: { indexes: ["formId", "status", "createdAt"], @@ -415,10 +417,10 @@ interface PluginContext { storage: Record; // Declared collections kv: KVAccess; // Key-value store log: LogAccess; // Structured logger - content?: ContentAccess; // If "read:content" capability - media?: MediaAccess; // If "read:media" capability - http?: HttpAccess; // If "network:fetch" capability - users?: UserAccess; // If "read:users" capability + content?: ContentAccess; // If "content:read" capability + media?: MediaAccess; // If "media:read" capability + http?: HttpAccess; // If "network:request" capability + users?: UserAccess; // If "users:read" capability cron?: CronAccess; // Always available — scoped to plugin email?: EmailAccess; // If "email:send" capability AND a provider is configured } @@ -435,7 +437,7 @@ export function myPlugin(): PluginDescriptor { format: "standard", entrypoint: "@my-org/my-plugin/sandbox", options: {}, - capabilities: ["read:content", "network:fetch"], + capabilities: ["content:read", "network:request"], allowedHosts: ["api.example.com"], storage: { events: { indexes: ["timestamp"] } }, }; diff --git a/skills/creating-plugins/references/api-routes.md b/skills/creating-plugins/references/api-routes.md index c836fd64b..c4d2419a7 100644 --- a/skills/creating-plugins/references/api-routes.md +++ b/skills/creating-plugins/references/api-routes.md @@ -215,11 +215,11 @@ routes: { ### External API Proxy -Requires `network:fetch` capability and `allowedHosts`: +Requires `network:request` capability and `allowedHosts`: ```typescript definePlugin({ - capabilities: ["network:fetch"], + capabilities: ["network:request"], allowedHosts: ["api.weather.example.com"], routes: { diff --git a/skills/creating-plugins/references/hooks.md b/skills/creating-plugins/references/hooks.md index ee33697e3..7833e4e21 100644 --- a/skills/creating-plugins/references/hooks.md +++ b/skills/creating-plugins/references/hooks.md @@ -233,14 +233,14 @@ Email hooks require specific capabilities. Without the required capability, hook ### `email:beforeSend` -**Requires:** `email:intercept` capability. +**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 definePlugin({ id: "email-footer", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:beforeSend": async (event, ctx) => { return { ...event.message, text: event.message.text + "\n\n-- Sent via EmDash" }; @@ -254,14 +254,14 @@ Returns: `EmailMessage | false` ### `email:deliver` -**Requires:** `email:provide` capability. **Exclusive hook** — exactly one provider is active. +**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 definePlugin({ id: "emdash-resend", - capabilities: ["email:provide", "network:fetch"], + capabilities: ["hooks.email-transport:register", "network:request"], allowedHosts: ["api.resend.com"], hooks: { "email:deliver": { @@ -284,14 +284,14 @@ Returns: `void` ### `email:afterSend` -**Requires:** `email:intercept` capability. +**Requires:** `hooks.email-events:register` capability. Runs after successful delivery. Fire-and-forget — errors are logged but don't propagate. ```typescript definePlugin({ id: "email-logger", - capabilities: ["email:intercept"], + capabilities: ["hooks.email-events:register"], hooks: { "email:afterSend": async (event, ctx) => { ctx.log.info(`Email sent to ${event.message.to}`, { source: event.source }); @@ -418,23 +418,23 @@ Use `"continue"` for non-critical operations (analytics, notifications, external ## 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 | — | Modified content or `void` | -| `content:afterSave` | After save | — | `void` | -| `content:beforeDelete` | Before delete | — | `false` to cancel | -| `content:afterDelete` | After delete | — | `void` | -| `content:afterPublish` | After publish | — | `void` | -| `content:afterUnpublish` | After unpublish | — | `void` | -| `media:beforeUpload` | Before upload | — | Modified file info or `void` | -| `media:afterUpload` | After upload | — | `void` | -| `email:beforeSend` | Before email send | `email:intercept` | Modified message or `false` | -| `email:deliver` | Email delivery | `email:provide` | `void` (exclusive) | -| `email:afterSend` | After email send | `email:intercept` | `void` | -| `cron` | Scheduled task fires | — | `void` | -| `page:metadata` | Page render | — | Metadata contributions | -| `page:fragments` | Page render | — (trusted only) | Fragment contributions | +| 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 | — | Modified content or `void` | +| `content:afterSave` | After save | — | `void` | +| `content:beforeDelete` | Before delete | — | `false` to cancel | +| `content:afterDelete` | After delete | — | `void` | +| `content:afterPublish` | After publish | — | `void` | +| `content:afterUnpublish` | After unpublish | — | `void` | +| `media:beforeUpload` | Before upload | — | Modified file info or `void` | +| `media:afterUpload` | After upload | — | `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` | +| `cron` | Scheduled task fires | — | `void` | +| `page:metadata` | Page render | — | Metadata contributions | +| `page:fragments` | Page render | — (trusted only) | Fragment contributions |