From d75e29970c13a5375d986d5d14ada35e76d36da8 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 17 Sep 2026 22:06:34 +0100 Subject: [PATCH 01/10] feat(plugins): add sandboxed editor extensions --- .changeset/bright-panels-edit.md | 14 + .../plugins/creating-plugins/block-kit.mdx | 55 ++- .../docs/plugins/creating-plugins/testing.mdx | 6 +- e2e/fixture/astro.config.mjs | 38 +- e2e/fixture/src/editor-extensions-plugin.ts | 40 +++ e2e/tests/plugin-editor-extensions.spec.ts | 57 +++ .../admin/src/components/ConfirmDialog.tsx | 5 +- .../admin/src/components/ContentEditor.tsx | 21 ++ .../src/components/ContentSettingsPanel.tsx | 67 +++- .../SandboxedContentEditorActions.tsx | 238 +++++++++++++ .../SandboxedContentEditorPanel.tsx | 158 +++++++++ packages/admin/src/lib/api/client.ts | 16 + .../src/lib/sandboxed-editor-extensions.ts | 106 ++++++ packages/admin/src/router.tsx | 8 + .../SandboxedContentEditorExtensions.test.tsx | 154 +++++++++ .../lib/sandboxed-editor-extensions.test.ts | 60 ++++ packages/blocks/src/index.ts | 3 + packages/blocks/src/server.ts | 5 + packages/blocks/src/types.ts | 42 ++- packages/blocks/src/validation.ts | 109 +++++- packages/blocks/tests/validation.test.ts | 64 +++- packages/core/src/astro/integration/routes.ts | 8 + .../core/src/astro/integration/runtime.ts | 6 + .../src/astro/integration/virtual-modules.ts | 4 + packages/core/src/astro/middleware.ts | 1 + .../[pluginId]/[kind]/[extensionId].ts | 28 ++ packages/core/src/astro/types.ts | 13 + .../core/src/cli/commands/bundle-utils.ts | 2 + packages/core/src/cli/commands/publish.ts | 4 +- packages/core/src/emdash-runtime.ts | 182 +++++++++- packages/core/src/index.ts | 2 + packages/core/src/plugin-test-runtime.ts | 5 +- .../core/src/plugins/adapt-sandbox-entry.ts | 6 + .../core/src/plugins/http-route-dispatch.ts | 135 +++++++- packages/core/src/plugins/index.ts | 2 + packages/core/src/plugins/manifest-schema.ts | 158 +++++++-- packages/core/src/plugins/sandbox/proxy.ts | 2 + packages/core/src/plugins/types.ts | 24 +- .../unit/astro/middleware-prerender.test.ts | 2 + .../plugin-editor-extension-auth.test.ts | 116 +++++++ packages/core/tests/unit/astro/routes.test.ts | 12 + .../unit/plugins/manifest-schema.test.ts | 58 ++++ .../tests/unit/runtime/manifest-build.test.ts | 40 ++- packages/marketplace/src/routes/author.ts | 143 ++++++-- .../schemas/emdash-plugin.schema.json | 327 +++++++++++++----- packages/plugin-cli/src/api.ts | 2 + packages/plugin-cli/src/build/api.ts | 4 + packages/plugin-cli/src/build/pipeline.ts | 26 ++ packages/plugin-cli/src/bundle/utils.ts | 2 + packages/plugin-cli/src/init/templates.ts | 3 +- packages/plugin-cli/src/manifest/schema.ts | 81 ++++- packages/plugin-cli/src/manifest/translate.ts | 4 + .../tests/build-metadata-roundtrip.test.ts | 29 +- .../plugin-cli/tests/bundle-utils.test.ts | 11 + .../plugin-cli/tests/init-templates.test.ts | 2 + .../plugin-cli/tests/manifest-schema.test.ts | 60 ++++ .../plugin-cli/tests/probe-schema.test.ts | 37 +- packages/plugin-test/src/runtime-host.ts | 126 ++++++- .../test/fixture/emdash-plugin.jsonc | 33 ++ .../plugin-test/test/fixture/src/plugin.ts | 50 +++ packages/plugin-test/test/host.test.ts | 101 ++++++ packages/plugin-types/src/index.ts | 30 ++ packages/plugin-types/src/manifest-schema.ts | 158 +++++++-- .../tests/manifest-schema.test.ts | 49 ++- skills/creating-plugins/SKILL.md | 4 +- .../creating-plugins/references/admin-ui.md | 40 +++ .../creating-plugins/references/block-kit.md | 16 + .../references/sandbox-boundaries.md | 2 +- 68 files changed, 3184 insertions(+), 232 deletions(-) create mode 100644 .changeset/bright-panels-edit.md create mode 100644 e2e/fixture/src/editor-extensions-plugin.ts create mode 100644 e2e/tests/plugin-editor-extensions.spec.ts create mode 100644 packages/admin/src/components/SandboxedContentEditorActions.tsx create mode 100644 packages/admin/src/components/SandboxedContentEditorPanel.tsx create mode 100644 packages/admin/src/lib/sandboxed-editor-extensions.ts create mode 100644 packages/admin/tests/components/SandboxedContentEditorExtensions.test.tsx create mode 100644 packages/admin/tests/lib/sandboxed-editor-extensions.test.ts create mode 100644 packages/core/src/astro/routes/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].ts create mode 100644 packages/core/tests/unit/astro/plugin-editor-extension-auth.test.ts diff --git a/.changeset/bright-panels-edit.md b/.changeset/bright-panels-edit.md new file mode 100644 index 0000000000..941e0b99c4 --- /dev/null +++ b/.changeset/bright-panels-edit.md @@ -0,0 +1,14 @@ +--- +"@emdash-cms/admin": minor +"@emdash-cms/blocks": minor +"@emdash-cms/plugin-cli": minor +"@emdash-cms/plugin-test": minor +"@emdash-cms/plugin-types": minor +"emdash": minor +--- + +Adds saved-entry panels and actions for sandboxed plugins. Declare collection-filtered `admin.editorPanels` and `admin.editorActions` entries that point to private plugin routes. + +Panels load Block Kit only when an editor opens them. Actions support confirmation and can return a toast, request an entry refresh, or navigate through a structured link target. EmDash reloads and ownership-authorizes the saved entry before invocation, then exposes only its canonical identity, locale, and version through `routeCtx.ui`; unsaved editor values never cross the sandbox boundary. + +`createPluginRuntimeTestHost()` includes panel and action helpers that exercise the production authorization, response-validation, and Worker Loader path. diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx index 9eca121e8f..e85ee83c96 100644 --- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx @@ -1,6 +1,6 @@ --- title: Block Kit -description: Declarative UI blocks for sandboxed plugin admin pages and widgets. +description: Declarative UI blocks for sandboxed plugin pages, widgets, and saved-entry extensions. --- import { Aside } from "@astrojs/starlight/components"; @@ -140,7 +140,7 @@ const plugin: SandboxedPlugin = { export default plugin; ``` -`routeCtx.ui` contains the surface (`admin-page` or `dashboard-widget`), locale, and text direction. The admin locale is separate from `ctx.site.locale`, which describes the site's default content locale. Manifest page labels remain static strings. +`routeCtx.ui` contains the surface, locale, and text direction. The admin locale is separate from `ctx.site.locale`, which describes the site's default content locale. Manifest labels remain static strings. ## Navigation links @@ -180,6 +180,57 @@ External links open in a new tab with `noopener noreferrer`. Link elements do no Block images use the same browser-resource policy. Root-relative image URLs are allowed. An external image must use HTTPS and its hostname must appear in the plugin's `allowedHosts`. A plugin with `network:request:unrestricted` can load an HTTPS image from any hostname. Other external images cause the complete Block Kit response to be rejected. +## Saved-entry panels and actions + +Declare an editor panel when a plugin needs to show information beside a saved entry. Panels start collapsed and call their private route only when an editor opens them. + +The following manifest adds a panel for posts and a confirmed repair action: + +```jsonc title="emdash-plugin.jsonc" +"admin": { + "editorPanels": [ + { + "id": "content-health", + "title": "Content health", + "route": "editor/content-health", + "collections": ["posts"], + }, + ], + "editorActions": [ + { + "id": "repair-metadata", + "label": "Repair metadata", + "route": "editor/repair-metadata", + "placement": "overflow", + "style": "danger", + "confirm": { + "title": "Repair metadata?", + "text": "This changes the saved entry.", + "confirm": "Repair", + "deny": "Cancel", + }, + }, + ], +} +``` + +Each referenced route must be private. Its `permission` controls which editors can invoke the extension. The host also reloads the saved entry and checks its owner before calling the plugin. + +Editor extension routes receive an attested `routeCtx.ui` value. For `content-editor-panel` and `content-editor-action` surfaces, `routeCtx.ui.entry` contains the collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the selected declaration. The context does not contain field values or unsaved editor state. Use `ctx.content` with the `content:read` capability when the plugin needs the saved content. + +A panel receives `{ type: "panel_load" }` when it opens. Its later button and form interactions use the usual `block_action` and `form_submit` shapes. Return a validated `BlockResponse` from every panel interaction. + +An editor action receives `{ type: "editor_action" }`. Return an object containing an optional toast and at most one terminal effect: + +```typescript title="src/plugin.ts" +return { + toast: { type: "success", message: "Metadata repaired" }, + refresh: true, +}; +``` + +Use `refresh: true` to reload the entry or `navigate` with a structured link target. A response cannot request both. EmDash rejects unknown commands, unsafe navigation, and responses over the Block Kit limits before applying an effect. + ## Block types | Type | Description | diff --git a/docs/src/content/docs/plugins/creating-plugins/testing.mdx b/docs/src/content/docs/plugins/creating-plugins/testing.mdx index 9508fa9615..0e3a0d709c 100644 --- a/docs/src/content/docs/plugins/creating-plugins/testing.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/testing.mdx @@ -205,4 +205,8 @@ The default Vitest configuration uses Worker Loader because it is the fastest pr Neither host renders the EmDash admin application or reproduces Cloudflare's deployed CPU, memory, and subrequest limits. Use a disposable EmDash site for browser journeys, and verify limit-sensitive behavior on a Cloudflare preview or staging deployment. -For Block Kit handlers, use `host.admin.loadPage()` or `loadWidget()` to exercise the private route, host-attested locale context, response validation, and Worker Loader isolate. Use `admin.act()` and `admin.submit()` for interactions. These helpers do not render React; use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) or a browser journey to verify Kumo rendering, keyboard operation, and right-to-left layout. +For Block Kit handlers, use `host.admin.loadPage()` or `loadWidget()` to exercise the private route, host-attested locale context, response validation, and Worker Loader isolate. Use `admin.act()` and `admin.submit()` for page interactions. + +Saved-entry extensions use the production ownership and route-permission boundary. Create the collection, user, and content with fixtures, then call `admin.loadEditorPanel()`, `actEditorPanel()`, `submitEditorPanel()`, or `invokeEditorAction()`. Pass `locale` for the admin UI language and `contentLocale` when selecting a translated entry. These helpers reload the saved entry before invoking the isolate and never send unsaved field values. + +The admin helpers do not render React. Use the [Block Playground](https://emdash-blocks.cto.cloudflare.dev/) or a browser journey to verify Kumo rendering, confirmation dialogs, keyboard operation, and right-to-left layout. diff --git a/e2e/fixture/astro.config.mjs b/e2e/fixture/astro.config.mjs index bf759718ce..049f41910e 100644 --- a/e2e/fixture/astro.config.mjs +++ b/e2e/fixture/astro.config.mjs @@ -4,6 +4,8 @@ * Uses env vars for the database path and optional marketplace URL * so each test run gets an isolated database. */ +import { fileURLToPath } from "node:url"; + import node from "@astrojs/node"; import react from "@astrojs/react"; import { colorPlugin } from "@emdash-cms/plugin-color"; @@ -13,6 +15,40 @@ import { sqlite } from "emdash/db"; const dbUrl = process.env.EMDASH_TEST_DB || "file:./test.db"; const marketplaceUrl = process.env.EMDASH_MARKETPLACE_URL || undefined; +const editorExtensionsPlugin = { + id: "editor-extensions-test", + version: "1.0.0", + format: "standard", + entrypoint: fileURLToPath(new URL("./src/editor-extensions-plugin.ts", import.meta.url)), + capabilities: [], + allowedHosts: [], + storage: {}, + editorPanels: [ + { + id: "entry-health", + title: "Plugin content health", + route: "entry-health", + collections: ["posts"], + order: 20, + }, + ], + editorActions: [ + { + id: "entry-recheck", + label: "Recheck saved entry", + route: "entry-recheck", + placement: "toolbar", + collections: ["posts"], + style: "danger", + confirm: { + title: "Recheck saved entry?", + text: "The plugin will inspect the latest saved version.", + confirm: "Recheck", + deny: "Cancel", + }, + }, + ], +}; export default defineConfig({ output: "server", @@ -22,7 +58,7 @@ export default defineConfig({ emdash({ database: sqlite({ url: dbUrl }), middleware: { outer: "./src/outer-middleware.ts" }, - plugins: [colorPlugin()], + plugins: [colorPlugin(), editorExtensionsPlugin], marketplace: marketplaceUrl, sandboxRunner: marketplaceUrl ? "./noop-sandbox.mjs" : undefined, }), diff --git a/e2e/fixture/src/editor-extensions-plugin.ts b/e2e/fixture/src/editor-extensions-plugin.ts new file mode 100644 index 0000000000..27d5c7dd34 --- /dev/null +++ b/e2e/fixture/src/editor-extensions-plugin.ts @@ -0,0 +1,40 @@ +import type { SandboxedPlugin } from "emdash/plugin"; + +const plugin: SandboxedPlugin = { + routes: { + "entry-health": { + permission: "content:edit_own", + handler: async (route) => { + if (route.ui?.surface !== "content-editor-panel") return { blocks: [] }; + return { + blocks: [ + { + type: "fields", + fields: [ + { label: "Collection", value: route.ui.entry.collection }, + { label: "Saved entry", value: route.ui.entry.id }, + { label: "Content locale", value: route.ui.entry.locale ?? "Default" }, + { label: "Saved version", value: String(route.ui.entry.version) }, + ], + }, + { + type: "banner", + variant: "default", + title: "Saved content only", + description: "Unsaved editor changes are not sent to this plugin.", + }, + ], + }; + }, + }, + "entry-recheck": { + permission: "content:edit_own", + handler: async () => ({ + refresh: true, + toast: { type: "success", message: "Saved entry rechecked" }, + }), + }, + }, +}; + +export default plugin; diff --git a/e2e/tests/plugin-editor-extensions.spec.ts b/e2e/tests/plugin-editor-extensions.spec.ts new file mode 100644 index 0000000000..d4ca677dd7 --- /dev/null +++ b/e2e/tests/plugin-editor-extensions.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from "../fixtures"; + +test.describe("Sandboxed plugin editor extensions", () => { + test.skip( + process.env.EMDASH_E2E_TARGET === "cloudflare", + "The Node fixture owns the configured standard-plugin UI journey", + ); + + test.beforeEach(async ({ admin }) => { + await admin.devBypassAuth(); + }); + + test("loads a saved-entry panel lazily and confirms an action in RTL", async ({ + admin, + serverInfo, + }) => { + const entryId = serverInfo.contentIds.posts[0]!; + await admin.page + .context() + .addCookies([{ name: "emdash-locale", value: "ar", domain: "localhost", path: "/" }]); + await admin.goto(`/content/posts/${entryId}?locale=en`); + await admin.waitForLoading(); + await expect(admin.page.locator("html")).toHaveAttribute("dir", "rtl"); + + const panelResponse = admin.page.waitForResponse( + (response) => + response.url().includes("/plugin-extensions/editor-extensions-test/panel/entry-health") && + response.request().method() === "POST", + ); + await admin.page.getByRole("button", { name: "Plugin content health", exact: true }).click(); + const loadedPanelResponse = await panelResponse; + expect(loadedPanelResponse.status(), await loadedPanelResponse.text()).toBe(200); + await expect(admin.page.getByText("Saved content only", { exact: true })).toBeVisible(); + await expect(admin.page.getByText(entryId, { exact: true })).toBeVisible(); + + await admin.page.getByRole("button", { name: "Recheck saved entry" }).click(); + const dialog = admin.page.getByRole("alertdialog", { name: "Recheck saved entry?" }); + await expect(dialog).toBeVisible(); + const actionResponse = admin.page.waitForResponse( + (response) => + response.url().includes("/plugin-extensions/editor-extensions-test/action/entry-recheck") && + response.request().method() === "POST", + ); + await dialog.getByRole("button", { name: "Recheck", exact: true }).click(); + expect((await actionResponse).status()).toBe(200); + await expect(admin.page.getByText("Saved entry rechecked", { exact: true })).toBeVisible(); + }); + + test("does not expose saved-entry extensions while creating content", async ({ admin }) => { + await admin.goto("/content/posts/new"); + await admin.waitForLoading(); + await expect( + admin.page.getByRole("button", { name: "Plugin content health", exact: true }), + ).toHaveCount(0); + await expect(admin.page.getByRole("button", { name: "Recheck saved entry" })).toHaveCount(0); + }); +}); diff --git a/packages/admin/src/components/ConfirmDialog.tsx b/packages/admin/src/components/ConfirmDialog.tsx index 2e1ca1483c..6e973d144e 100644 --- a/packages/admin/src/components/ConfirmDialog.tsx +++ b/packages/admin/src/components/ConfirmDialog.tsx @@ -25,6 +25,8 @@ export interface ConfirmDialogProps { descriptionClassName?: string; /** Label for the confirm button (e.g. "Delete", "Disable User") */ confirmLabel: string; + /** Optional cancel label; defaults to the localized host label. */ + cancelLabel?: string; /** Label shown while the action is pending (e.g. "Deleting...") */ pendingLabel: string; /** Button variant — defaults to "destructive" */ @@ -52,6 +54,7 @@ export function ConfirmDialog({ description, descriptionClassName, confirmLabel, + cancelLabel, pendingLabel, variant = "destructive", compact = false, @@ -96,7 +99,7 @@ export function ConfirmDialog({
+ ); + })} + {overflowActions.length > 0 ? ( + + + ) : null} + {confirming?.extension.confirm ? ( + { + if (!confirmingKey || !pending.has(confirmingKey)) { + setConfirming(null); + setConfirmError(null); + } + }} + role={confirming.extension.style === "danger" ? "alertdialog" : "dialog"} + title={confirming.extension.confirm.title} + description={confirming.extension.confirm.text} + confirmLabel={confirming.extension.confirm.confirm} + cancelLabel={confirming.extension.confirm.deny} + pendingLabel={t`Working…`} + variant={confirming.extension.style === "danger" ? "destructive" : "primary"} + preventCloseWhilePending + isPending={confirmingKey ? pending.has(confirmingKey) : false} + error={confirmError} + onConfirm={() => void invoke(confirming)} + /> + ) : null} + + ); +} diff --git a/packages/admin/src/components/SandboxedContentEditorPanel.tsx b/packages/admin/src/components/SandboxedContentEditorPanel.tsx new file mode 100644 index 0000000000..3eb3542ad1 --- /dev/null +++ b/packages/admin/src/components/SandboxedContentEditorPanel.tsx @@ -0,0 +1,158 @@ +import { Button, Collapsible, SkeletonLine, Toast } from "@cloudflare/kumo"; +import { BlockRenderer } from "@emdash-cms/blocks"; +import type { + Block, + BlockInteraction, + BlockResponse, + ContentEditorPanelInteraction, +} from "@emdash-cms/blocks"; +import { useLingui } from "@lingui/react/macro"; +import * as React from "react"; + +import { apiFetch } from "../lib/api/client.js"; +import { resolvePluginLinkTarget } from "../lib/plugin-links.js"; +import { editorExtensionUrl } from "../lib/sandboxed-editor-extensions.js"; + +interface SandboxedContentEditorPanelProps { + pluginId: string; + panelId: string; + title: string; + collection: string; + entryId: string; + locale?: string | null; +} + +export function SandboxedContentEditorPanel({ + pluginId, + panelId, + title, + collection, + entryId, + locale, +}: SandboxedContentEditorPanelProps) { + const { t } = useLingui(); + const toastManager = Toast.useToastManager(); + const [open, setOpen] = React.useState(false); + const [loaded, setLoaded] = React.useState(false); + const [loading, setLoading] = React.useState(false); + const [error, setError] = React.useState(false); + const [blocks, setBlocks] = React.useState([]); + const generation = React.useRef(0); + const abortController = React.useRef(null); + const identity = `${pluginId}:${panelId}:${collection}:${entryId}:${locale ?? ""}`; + const identityRef = React.useRef(identity); + identityRef.current = identity; + + React.useEffect(() => { + setOpen(false); + setLoaded(false); + setLoading(false); + setError(false); + setBlocks([]); + generation.current++; + abortController.current?.abort(); + return () => { + generation.current++; + abortController.current?.abort(); + }; + }, [identity]); + + const sendInteraction = React.useCallback( + async (interaction: ContentEditorPanelInteraction) => { + const requestIdentity = identity; + const requestGeneration = ++generation.current; + abortController.current?.abort(); + const controller = new AbortController(); + abortController.current = controller; + setLoading(true); + setError(false); + try { + const response = await apiFetch( + editorExtensionUrl(collection, entryId, pluginId, "panel", panelId, locale), + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(interaction), + signal: controller.signal, + }, + ); + if (requestIdentity !== identityRef.current || requestGeneration !== generation.current) + return; + if (!response.ok) throw new Error("Plugin panel request failed"); + const body = (await response.json()) as { data: BlockResponse }; + if (requestIdentity !== identityRef.current || requestGeneration !== generation.current) + return; + setBlocks(body.data.blocks); + setLoaded(true); + if (body.data.toast) { + toastManager.add({ title: body.data.toast.message, type: body.data.toast.type }); + } + } catch { + if ( + controller.signal.aborted || + requestIdentity !== identityRef.current || + requestGeneration !== generation.current + ) { + return; + } + setError(true); + } finally { + if (requestIdentity === identityRef.current && requestGeneration === generation.current) { + setLoading(false); + } + } + }, + [collection, entryId, identity, locale, panelId, pluginId, toastManager], + ); + + const handleOpenChange = React.useCallback( + (nextOpen: boolean) => { + setOpen(nextOpen); + if (nextOpen && !loaded && !loading) void sendInteraction({ type: "panel_load" }); + }, + [loaded, loading, sendInteraction], + ); + const handleAction = React.useCallback( + (interaction: BlockInteraction) => { + if (interaction.type === "page_load") return; + const { page: _page, ...panelInteraction } = interaction; + void sendInteraction(panelInteraction); + }, + [sendInteraction], + ); + + return ( + + {title} + +
+ {loading && !loaded ? ( +
+ + +
+ ) : error ? ( +
+

{t`Plugin panel unavailable.`}

+ +
+ ) : loaded ? ( + resolvePluginLinkTarget(pluginId, target)} + /> + ) : null} +
+
+
+ ); +} diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index d80ccd00b2..caaf7aff35 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -197,6 +197,22 @@ export interface AdminManifest { title?: string; size?: "full" | "half" | "third"; }>; + editorPanels?: Array<{ + id: string; + title: string; + route: string; + collections?: string[]; + order?: number; + }>; + editorActions?: Array<{ + id: string; + label: string; + route: string; + placement: "toolbar" | "overflow"; + collections?: string[]; + style?: "default" | "danger"; + confirm?: import("@emdash-cms/blocks").ConfirmDialog; + }>; fieldWidgets?: Array<{ name: string; label: string; diff --git a/packages/admin/src/lib/sandboxed-editor-extensions.ts b/packages/admin/src/lib/sandboxed-editor-extensions.ts new file mode 100644 index 0000000000..90c5478e19 --- /dev/null +++ b/packages/admin/src/lib/sandboxed-editor-extensions.ts @@ -0,0 +1,106 @@ +import type { ConfirmDialog } from "@emdash-cms/blocks"; + +import type { AdminManifest } from "./api/client.js"; + +export interface SandboxedEditorPanelDeclaration { + id: string; + title: string; + route: string; + collections?: string[]; + order?: number; +} + +export interface SandboxedEditorActionDeclaration { + id: string; + label: string; + route: string; + placement: "toolbar" | "overflow"; + collections?: string[]; + style?: "default" | "danger"; + confirm?: ConfirmDialog; +} + +export interface ResolvedSandboxedEditorPanel { + pluginId: string; + extension: SandboxedEditorPanelDeclaration; +} + +export interface ResolvedSandboxedEditorAction { + pluginId: string; + extension: SandboxedEditorActionDeclaration; +} + +function appliesToCollection(collections: readonly string[] | undefined, collection: string) { + return collections === undefined || collections.includes(collection); +} + +export function resolveSandboxedEditorPanels( + plugins: AdminManifest["plugins"] | undefined, + collection: string, +): ResolvedSandboxedEditorPanel[] { + const resolved: ResolvedSandboxedEditorPanel[] = []; + const seen = new Set(); + for (const pluginId of Object.keys(plugins ?? {}).toSorted()) { + const plugin = plugins?.[pluginId]; + if (!plugin || plugin.enabled === false || plugin.adminMode !== "blocks") continue; + for (const extension of plugin.editorPanels ?? []) { + if (!extension.id || !extension.title || !extension.route) continue; + const identity = `${pluginId}:${extension.id}`; + if (seen.has(identity)) continue; + seen.add(identity); + if (!appliesToCollection(extension.collections, collection)) continue; + resolved.push({ pluginId, extension }); + } + } + return resolved.toSorted( + (a, b) => + (a.extension.order ?? 0) - (b.extension.order ?? 0) || + a.pluginId.localeCompare(b.pluginId) || + a.extension.id.localeCompare(b.extension.id), + ); +} + +export function resolveSandboxedEditorActions( + plugins: AdminManifest["plugins"] | undefined, + collection: string, +): ResolvedSandboxedEditorAction[] { + const resolved: ResolvedSandboxedEditorAction[] = []; + const seen = new Set(); + for (const pluginId of Object.keys(plugins ?? {}).toSorted()) { + const plugin = plugins?.[pluginId]; + if (!plugin || plugin.enabled === false || plugin.adminMode !== "blocks") continue; + for (const extension of plugin.editorActions ?? []) { + if ( + !extension.id || + !extension.label || + !extension.route || + (extension.style === "danger" && !extension.confirm) + ) { + continue; + } + const identity = `${pluginId}:${extension.id}`; + if (seen.has(identity)) continue; + seen.add(identity); + if (!appliesToCollection(extension.collections, collection)) continue; + resolved.push({ pluginId, extension }); + } + } + return resolved.toSorted( + (a, b) => a.pluginId.localeCompare(b.pluginId) || a.extension.id.localeCompare(b.extension.id), + ); +} + +export function editorExtensionUrl( + collection: string, + entryId: string, + pluginId: string, + kind: "panel" | "action", + extensionId: string, + locale?: string | null, +): string { + const path = [collection, entryId, "plugin-extensions", pluginId, kind, extensionId] + .map(encodeURIComponent) + .join("/"); + const search = locale ? `?locale=${encodeURIComponent(locale)}` : ""; + return `/_emdash/api/content/${path}${search}`; +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index 35344a98d5..87ce971fc8 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -1591,6 +1591,13 @@ function ContentEditPage() { }, [id], ); + const handlePluginEntryRefresh = React.useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["content", collection, id] }), + queryClient.invalidateQueries({ queryKey: ["revisions", collection, id] }), + queryClient.invalidateQueries({ queryKey: ["translations", collection, id] }), + ]); + }, [collection, id, queryClient]); if (!manifest) { return ; @@ -1655,6 +1662,7 @@ function ContentEditPage() { onQuickCreateByline={handleQuickCreateByline} onQuickEditByline={handleQuickEditByline} manifest={manifest ?? null} + onEntryRefresh={handlePluginEntryRefresh} readOnly={entryLock.readOnly} notice={ {children}; +} + +afterEach(() => vi.unstubAllGlobals()); + +describe("SandboxedContentEditorPanel", () => { + it("loads lazily once and sends only the panel interaction", async () => { + const fetchMock = vi.fn(async () => + Response.json({ data: { blocks: [{ type: "header", text: "Saved findings" }] } }), + ); + vi.stubGlobal("fetch", fetchMock); + const screen = await render( + , + { wrapper: Wrapper }, + ); + + expect(fetchMock).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + await expect.element(screen.getByRole("heading", { name: "Saved findings" })).toBeVisible(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({ + type: "panel_load", + }); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("?locale=ar"); + + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("isolates a failed load and retries without remounting the editor", async () => { + const fetchMock = vi + .fn<() => Promise>() + .mockResolvedValueOnce(new Response(null, { status: 502 })) + .mockResolvedValueOnce( + Response.json({ data: { blocks: [{ type: "header", text: "Recovered" }] } }), + ); + vi.stubGlobal("fetch", fetchMock); + const screen = await render( + , + { wrapper: Wrapper }, + ); + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + await expect.element(screen.getByRole("alert")).toBeVisible(); + await userEvent.click(screen.getByRole("button", { name: "Retry" })); + await expect.element(screen.getByRole("heading", { name: "Recovered" })).toBeVisible(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("ignores a panel response after the saved entry changes", async () => { + const first = Promise.withResolvers(); + const second = Promise.withResolvers(); + const fetchMock = vi + .fn<() => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + vi.stubGlobal("fetch", fetchMock); + const screen = await render( + , + { wrapper: Wrapper }, + ); + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + await screen.rerender( + , + ); + await userEvent.click(screen.getByRole("button", { name: "Findings" })); + second.resolve(Response.json({ data: { blocks: [{ type: "header", text: "Second" }] } })); + await expect.element(screen.getByRole("heading", { name: "Second" })).toBeVisible(); + first.resolve(Response.json({ data: { blocks: [{ type: "header", text: "First" }] } })); + await expect.element(screen.getByRole("heading", { name: "First" })).not.toBeInTheDocument(); + }); +}); + +describe("SandboxedContentEditorActions", () => { + it("requires manifest confirmation before invoking a danger action", async () => { + const fetchMock = vi.fn(async () => + Response.json({ + data: { refresh: true, toast: { type: "success", message: "Entry refreshed" } }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + const onEntryRefresh = vi.fn(); + const screen = await render( + , + { wrapper: Wrapper }, + ); + + await userEvent.click(screen.getByRole("button", { name: "Repair entry" })); + expect(fetchMock).not.toHaveBeenCalled(); + const dialog = screen.getByRole("alertdialog"); + await expect.element(dialog).toBeVisible(); + dialog.getByRole("button", { name: "Repair" }).element().click(); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await vi.waitFor(() => expect(onEntryRefresh).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/packages/admin/tests/lib/sandboxed-editor-extensions.test.ts b/packages/admin/tests/lib/sandboxed-editor-extensions.test.ts new file mode 100644 index 0000000000..f9dc3281f6 --- /dev/null +++ b/packages/admin/tests/lib/sandboxed-editor-extensions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; + +import type { AdminManifest } from "../../src/lib/api/client.js"; +import { + editorExtensionUrl, + resolveSandboxedEditorActions, + resolveSandboxedEditorPanels, +} from "../../src/lib/sandboxed-editor-extensions.js"; + +describe("sandboxed editor extension resolution", () => { + const plugins: AdminManifest["plugins"] = { + zeta: { + enabled: true, + adminMode: "blocks", + editorPanels: [ + { id: "later", title: "Later", route: "later", order: 10 }, + { id: "posts", title: "Posts", route: "posts", collections: ["posts"] }, + ], + editorActions: [{ id: "refresh", label: "Refresh", route: "refresh", placement: "toolbar" }], + }, + alpha: { + enabled: true, + adminMode: "blocks", + editorPanels: [{ id: "first", title: "First", route: "first" }], + editorActions: [{ id: "inspect", label: "Inspect", route: "inspect", placement: "overflow" }], + }, + disabled: { + enabled: false, + adminMode: "blocks", + editorPanels: [{ id: "hidden", title: "Hidden", route: "hidden" }], + }, + native: { + enabled: true, + adminMode: "react", + editorActions: [{ id: "hidden", label: "Hidden", route: "hidden", placement: "toolbar" }], + }, + }; + + it("filters by enabled Block Kit plugins and collection, then sorts deterministically", () => { + expect(resolveSandboxedEditorPanels(plugins, "pages")).toMatchObject([ + { pluginId: "alpha", extension: { id: "first" } }, + { pluginId: "zeta", extension: { id: "later" } }, + ]); + expect(resolveSandboxedEditorPanels(plugins, "posts")).toMatchObject([ + { pluginId: "alpha", extension: { id: "first" } }, + { pluginId: "zeta", extension: { id: "posts" } }, + { pluginId: "zeta", extension: { id: "later" } }, + ]); + expect(resolveSandboxedEditorActions(plugins, "posts")).toMatchObject([ + { pluginId: "alpha", extension: { id: "inspect" } }, + { pluginId: "zeta", extension: { id: "refresh" } }, + ]); + }); + + it("encodes every path selector and keeps content locale separate", () => { + expect(editorExtensionUrl("posts", "id/1", "publisher/plugin", "panel", "health", "ar")).toBe( + "/_emdash/api/content/posts/id%2F1/plugin-extensions/publisher%2Fplugin/panel/health?locale=ar", + ); + }); +}); diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index b10b4816bd..e54437503e 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -72,7 +72,10 @@ export type { FormSubmit, PageLoad, BlockInteraction, + ContentEditorPanelInteraction, + ContentEditorActionInvocation, PluginUiContext, + ContentEditorActionResponse, // Response BlockResponse, } from "./types.js"; diff --git a/packages/blocks/src/server.ts b/packages/blocks/src/server.ts index 845ccc02af..853571ff4c 100644 --- a/packages/blocks/src/server.ts +++ b/packages/blocks/src/server.ts @@ -13,6 +13,8 @@ export { normalizePluginPagePath, validateBlockResponse, validateBlocks, + validateContentEditorActionResponse, + validateContentEditorPanelInteraction, } from "./validation.js"; export type { BlockValidationPolicy, ValidationError } from "./validation.js"; @@ -55,7 +57,10 @@ export type { FormSubmit, PageLoad, BlockInteraction, + ContentEditorPanelInteraction, + ContentEditorActionInvocation, PluginUiContext, + ContentEditorActionResponse, // Response BlockResponse, } from "./types.js"; diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 303f7823c1..1dd8ce5ca2 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -427,21 +427,57 @@ export interface PageLoad { page: string; } -/** Context derived and attached by the EmDash host for Block Kit requests. */ -export interface PluginUiContext { - surface: "admin-page" | "dashboard-widget" | "content-editor-panel"; +interface PluginUiContextBase { locale: string; direction: "ltr" | "rtl"; contentLocale?: string; } +/** Context derived and attached by the EmDash host for Block Kit requests. */ +export type PluginUiContext = PluginUiContextBase & + ( + | { + surface: "admin-page" | "dashboard-widget"; + entry?: never; + extensionId?: never; + } + | { + surface: "content-editor-panel" | "content-editor-action"; + /** Saved entry identity resolved by the host for this invocation. */ + entry: { + collection: string; + id: string; + locale: string | null; + version: number; + }; + /** Manifest-declared panel or action selected by the host. */ + extensionId: string; + } + ); + export type LinkTargetResolver = (target: LinkTarget) => string | null; export type BlockInteraction = BlockAction | FormSubmit | PageLoad; +export type ContentEditorPanelInteraction = + | { type: "panel_load" } + | Omit + | Omit; + +export interface ContentEditorActionInvocation { + type: "editor_action"; +} + // ── Response ───────────────────────────────────────────────────────────────── export interface BlockResponse { blocks: Block[]; toast?: { message: string; type: "success" | "error" | "info" }; } + +/** Bounded host effects returned by a content-editor action route. */ +export interface ContentEditorActionResponse { + toast?: { message: string; type: "success" | "error" | "info" }; + refresh?: true; + navigate?: LinkTarget; +} diff --git a/packages/blocks/src/validation.ts b/packages/blocks/src/validation.ts index 2e24831240..3ba8a36c39 100644 --- a/packages/blocks/src/validation.ts +++ b/packages/blocks/src/validation.ts @@ -461,6 +461,22 @@ function validateConfirmDialog(value: unknown, path: string, errors: ValidationE } } +function validateToast(value: unknown, path: string, errors: ValidationError[]): void { + if (!isRecord(value)) { + errors.push({ path, message: "Toast must be an object" }); + return; + } + if (typeof value.message !== "string") { + errors.push({ path: `${path}.message`, message: "Toast message must be a string" }); + } + if (!new Set(["success", "error", "info"]).has(String(value.type))) { + errors.push({ + path: `${path}.type`, + message: "Toast type must be success, error, or info", + }); + } +} + function validateElement( value: unknown, path: string, @@ -1654,20 +1670,89 @@ export function validateBlockResponse( const errors: ValidationError[] = new ValidationErrors(); errors.push(...result.errors); if (response.toast !== undefined) { - if (!isRecord(response.toast)) { - errors.push({ path: "toast", message: "Toast must be an object" }); - } else { - if (typeof response.toast.message !== "string") { - errors.push({ path: "toast.message", message: "Toast message must be a string" }); - } - if (!new Set(["success", "error", "info"]).has(String(response.toast.type))) { - errors.push({ - path: "toast.type", - message: "Toast type must be success, error, or info", - }); - } + validateToast(response.toast, "toast", errors); + } + + return { valid: errors.length === 0, errors }; +} + +export function validateContentEditorActionResponse( + response: unknown, + policy: BlockValidationPolicy, +): { valid: boolean; errors: ValidationError[] } { + const boundErrors = validateResponseBounds(response); + if (boundErrors.length > 0) return { valid: false, errors: boundErrors }; + if (!isRecord(response)) { + return { valid: false, errors: [{ path: "response", message: "Response must be an object" }] }; + } + + const errors: ValidationError[] = new ValidationErrors(); + const allowedKeys = new Set(["toast", "refresh", "navigate"]); + for (const key of Object.keys(response)) { + if (!allowedKeys.has(key)) { + errors.push({ path: `response.${key}`, message: `Unknown action response field '${key}'` }); } } + if (response.toast !== undefined) validateToast(response.toast, "toast", errors); + if (response.refresh !== undefined && response.refresh !== true) { + errors.push({ path: "refresh", message: "Refresh must be true if provided" }); + } + if (response.navigate !== undefined) { + validateLinkTarget(response.navigate, "navigate", errors, policy); + } + if (response.refresh === true && response.navigate !== undefined) { + errors.push({ + path: "response", + message: "Action response cannot refresh and navigate at the same time", + }); + } + return { valid: errors.length === 0, errors }; +} +export function validateContentEditorPanelInteraction(interaction: unknown): { + valid: boolean; + errors: ValidationError[]; +} { + const boundErrors = validateResponseBounds(interaction); + if (boundErrors.length > 0) return { valid: false, errors: boundErrors }; + if (!isRecord(interaction) || typeof interaction.type !== "string") { + return { + valid: false, + errors: [{ path: "interaction", message: "Interaction must be an object with a type" }], + }; + } + + const errors: ValidationError[] = new ValidationErrors(); + const allowedKeys = + interaction.type === "panel_load" + ? new Set(["type"]) + : interaction.type === "block_action" + ? new Set(["type", "action_id", "block_id", "value"]) + : interaction.type === "form_submit" + ? new Set(["type", "action_id", "block_id", "values"]) + : null; + if (!allowedKeys) { + errors.push({ path: "interaction.type", message: "Unknown editor panel interaction type" }); + return { valid: false, errors }; + } + for (const key of Object.keys(interaction)) { + if (!allowedKeys.has(key)) { + errors.push({ path: `interaction.${key}`, message: `Unknown interaction field '${key}'` }); + } + } + if (interaction.type !== "panel_load") { + if (typeof interaction.action_id !== "string" || interaction.action_id.length === 0) { + errors.push({ + path: "interaction.action_id", + message: "Action id must be a non-empty string", + }); + } + if (interaction.block_id !== undefined && typeof interaction.block_id !== "string") { + errors.push({ path: "interaction.block_id", message: "Block id must be a string" }); + } + } + if (interaction.type === "form_submit" && !isRecord(interaction.values)) { + errors.push({ path: "interaction.values", message: "Form values must be an object" }); + } return { valid: errors.length === 0, errors }; } diff --git a/packages/blocks/tests/validation.test.ts b/packages/blocks/tests/validation.test.ts index 8f20d6a20d..96834afbb7 100644 --- a/packages/blocks/tests/validation.test.ts +++ b/packages/blocks/tests/validation.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from "vitest"; -import { validateBlockResponse, validateBlocks } from "../src/validation.js"; +import { + validateBlockResponse, + validateBlocks, + validateContentEditorActionResponse, + validateContentEditorPanelInteraction, +} from "../src/validation.js"; describe("validateBlocks", () => { // ── Valid blocks ───────────────────────────────────────────────────────── @@ -1116,3 +1121,60 @@ describe("validateBlocks", () => { }); }); }); + +describe("validateContentEditorActionResponse", () => { + const policy = { pluginPagePaths: ["/reports"] }; + + it("accepts one bounded host effect and an optional toast", () => { + expect( + validateContentEditorActionResponse( + { refresh: true, toast: { message: "Entry updated", type: "success" } }, + policy, + ), + ).toEqual({ valid: true, errors: [] }); + expect( + validateContentEditorActionResponse( + { navigate: { kind: "plugin-page", path: "/reports" } }, + policy, + ), + ).toEqual({ valid: true, errors: [] }); + }); + + it.each([ + ["false refresh", { refresh: false }], + ["multiple terminal effects", { refresh: true, navigate: { kind: "plugin-settings" } }], + ["unknown command", { reload: true }], + ["undeclared plugin page", { navigate: { kind: "plugin-page", path: "/secret" } }], + ["active external URL", { navigate: { kind: "external", url: "javascript:alert(1)" } }], + ])("rejects %s", (_label, response) => { + expect(validateContentEditorActionResponse(response, policy).valid).toBe(false); + }); + + it("applies the shared response bounds", () => { + const result = validateContentEditorActionResponse( + { toast: { message: "x".repeat(64 * 1024 + 1), type: "info" } }, + policy, + ); + expect(result.valid).toBe(false); + expect(result.errors[0]?.message).toContain("maximum size"); + }); +}); + +describe("validateContentEditorPanelInteraction", () => { + it.each([ + { type: "panel_load" }, + { type: "block_action", action_id: "refresh", value: 1 }, + { type: "form_submit", action_id: "save", values: { enabled: true } }, + ])("accepts bounded editor interactions", (interaction) => { + expect(validateContentEditorPanelInteraction(interaction)).toEqual({ valid: true, errors: [] }); + }); + + it.each([ + { type: "panel_load", entry: { id: "forged" } }, + { type: "block_action", action_id: "" }, + { type: "form_submit", action_id: "save", values: [] }, + { type: "page_load", page: "/forged" }, + ])("rejects malformed or host-owned fields", (interaction) => { + expect(validateContentEditorPanelInteraction(interaction).valid).toBe(false); + }); +}); diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index b4c373caf1..1b258484de 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -136,6 +136,14 @@ export function injectCoreRoutes( entrypoint: resolveRoute("api/content/[collection]/[id].ts"), }); + injectRoute({ + pattern: + "/_emdash/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId]", + entrypoint: resolveRoute( + "api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].ts", + ), + }); + injectRoute({ pattern: "/_emdash/api/content/[collection]/[id]/revisions", entrypoint: resolveRoute("api/content/[collection]/[id]/revisions.ts"), diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 056d364c37..68c15f2c3c 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -16,6 +16,8 @@ import type { MediaProviderDescriptor } from "../../media/types.js"; import type { ObjectCacheDescriptor } from "../../object-cache/types.js"; import type { FieldWidgetConfig, + PluginEditorAction, + PluginEditorPanel, PluginMcpManifestConfig, PortableTextBlockConfig, ResolvedPlugin, @@ -112,6 +114,10 @@ export interface PluginDescriptor> { adminPages?: PluginAdminPage[]; /** Dashboard widgets */ adminWidgets?: PluginDashboardWidget[]; + /** Saved-entry Block Kit panels. */ + editorPanels?: PluginEditorPanel[]; + /** Saved-entry host-rendered actions. */ + editorActions?: PluginEditorAction[]; /** Settings schema for the auto-generated admin settings form */ settingsSchema?: Record; /** diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 201141ef0f..164fccdaa4 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -290,6 +290,8 @@ export function generatePluginsModule(descriptors: PluginDescriptor[]): string { storage: descriptor.storage, adminPages: descriptor.adminPages, adminWidgets: descriptor.adminWidgets, + editorPanels: descriptor.editorPanels, + editorActions: descriptor.editorActions, settingsSchema: descriptor.settingsSchema, portableTextBlocks: descriptor.portableTextBlocks, fieldWidgets: descriptor.fieldWidgets, @@ -701,6 +703,8 @@ export const sandboxedPlugins = []; hooks: ${JSON.stringify(descriptor.hooks ?? [])}, adminPages: ${JSON.stringify(descriptor.adminPages ?? [])}, adminWidgets: ${JSON.stringify(descriptor.adminWidgets ?? [])}, + editorPanels: ${JSON.stringify(descriptor.editorPanels ?? [])}, + editorActions: ${JSON.stringify(descriptor.editorActions ?? [])}, settingsSchema: ${JSON.stringify(descriptor.settingsSchema)}, portableTextBlocks: ${JSON.stringify(descriptor.portableTextBlocks ?? [])}, fieldWidgets: ${JSON.stringify(descriptor.fieldWidgets ?? [])}, diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 6a73d7fc40..18ea7f5be8 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -946,6 +946,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Plugin routes handlePluginApiRoute: runtime.handlePluginApiRoute.bind(runtime), + getPluginEditorExtension: runtime.getPluginEditorExtension.bind(runtime), handlePublicPluginApiRoute: createPublicPluginApiRouteHandler(runtime), getPluginRouteMeta: runtime.getPluginRouteMeta.bind(runtime), getPluginMcpTools: runtime.getPluginMcpTools.bind(runtime), diff --git a/packages/core/src/astro/routes/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].ts b/packages/core/src/astro/routes/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].ts new file mode 100644 index 0000000000..7e7d3426f1 --- /dev/null +++ b/packages/core/src/astro/routes/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].ts @@ -0,0 +1,28 @@ +import type { APIRoute } from "astro"; + +import { apiError } from "#api/error.js"; +import { dispatchPluginEditorExtensionApiRequest } from "#plugins/http-route-dispatch.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ params, request, locals }) => { + const { emdash, user } = locals; + if (!emdash) return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + + const kind = params.kind; + if (kind !== "panel" && kind !== "action") { + return apiError("NOT_FOUND", "Plugin editor extension not found", 404); + } + + return dispatchPluginEditorExtensionApiRequest({ + runtime: emdash, + pluginId: params.pluginId!, + kind, + extensionId: params.extensionId!, + collection: params.collection!, + entryId: params.id!, + request, + user, + tokenScopes: locals.tokenScopes, + }); +}; diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index d5ff558aa0..9a0d72589e 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -9,6 +9,10 @@ import type { Element } from "@emdash-cms/blocks"; import type { Kysely } from "kysely"; import type { ContentFieldFilters } from "../content-list-query.js"; +import type { + PluginEditorExtensionDispatch, + ResolvedPluginEditorExtension, +} from "../emdash-runtime.js"; import type { PluginContentCacheInvalidator, RouteCallerInput, @@ -103,6 +107,8 @@ export interface ManifestPlugin { title?: string; size?: string; }>; + editorPanels?: import("../plugins/types.js").PluginEditorPanel[]; + editorActions?: import("../plugins/types.js").PluginEditorAction[]; fieldWidgets?: Array<{ name: string; label: string; @@ -504,8 +510,15 @@ export interface EmDashHandlers { path: string, request: Request, user?: RouteCallerInput | null, + editorDispatch?: PluginEditorExtensionDispatch, invalidateContentCache?: PluginContentCacheInvalidator, ) => Promise; + getPluginEditorExtension: ( + pluginId: string, + kind: "panel" | "action", + extensionId: string, + collection: string, + ) => ResolvedPluginEditorExtension | null; // Public-only plugin API route handler for SSR page components. handlePublicPluginApiRoute: ( diff --git a/packages/core/src/cli/commands/bundle-utils.ts b/packages/core/src/cli/commands/bundle-utils.ts index ff30b64a44..ed5b4c31ce 100644 --- a/packages/core/src/cli/commands/bundle-utils.ts +++ b/packages/core/src/cli/commands/bundle-utils.ts @@ -214,6 +214,8 @@ export function extractManifest(plugin: ResolvedPlugin): PluginManifest { settingsSchema: plugin.admin.settingsSchema, pages: plugin.admin.pages, widgets: plugin.admin.widgets, + editorPanels: plugin.admin.editorPanels, + editorActions: plugin.admin.editorActions, }, }; } diff --git a/packages/core/src/cli/commands/publish.ts b/packages/core/src/cli/commands/publish.ts index 6d54e6dc15..ac8531b2d5 100644 --- a/packages/core/src/cli/commands/publish.ts +++ b/packages/core/src/cli/commands/publish.ts @@ -20,7 +20,7 @@ import consola from "consola"; import { createGzipDecoder, unpackTar } from "modern-tar"; import pc from "picocolors"; -import { pluginManifestSchema } from "../../plugins/manifest-schema.js"; +import { pluginManifestBaseSchema } from "../../plugins/manifest-schema.js"; import { CAPABILITY_RENAMES, isDeprecatedCapability } from "../../plugins/types.js"; import { getMarketplaceCredential, @@ -201,7 +201,7 @@ async function pollGitHubDeviceFlow( // ── Tarball reading ───────────────────────────────────────────── -const manifestSummarySchema = pluginManifestSchema.pick({ +const manifestSummarySchema = pluginManifestBaseSchema.pick({ id: true, version: true, capabilities: true, diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 17ce5d9b08..44d16d2bed 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -13,6 +13,7 @@ import type { Element } from "@emdash-cms/blocks"; import { normalizePluginPagePath, validateBlockResponse, + validateContentEditorActionResponse, type BlockValidationPolicy, type PluginUiContext, } from "@emdash-cms/blocks/server"; @@ -103,6 +104,8 @@ import type { PluginCapability, PluginStorageConfig, PluginMcpManifestConfig, + PluginEditorAction, + PluginEditorPanel, PublicPageContext, PageMetadataContribution, PageFragmentContribution, @@ -318,6 +321,10 @@ export interface SandboxedPluginEntry { adminPages?: Array<{ path: string; label?: string; icon?: string }>; /** Dashboard widgets */ adminWidgets?: Array<{ id: string; title?: string; size?: string }>; + /** Saved-entry Block Kit panels. */ + editorPanels?: PluginManifest["admin"]["editorPanels"]; + /** Saved-entry host-rendered actions. */ + editorActions?: PluginManifest["admin"]["editorActions"]; /** Settings schema for the auto-generated admin settings form */ settingsSchema?: Record; /** Portable Text block types contributed to the editor (declarative Block Kit) */ @@ -333,6 +340,24 @@ export interface SandboxedPluginEntry { preferred?: string[]; } +export type ResolvedPluginEditorExtension = + | { + kind: "panel"; + extension: PluginEditorPanel; + policy: BlockValidationPolicy; + } + | { + kind: "action"; + extension: PluginEditorAction; + policy: BlockValidationPolicy; + }; + +export interface PluginEditorExtensionDispatch { + ui: PluginUiContext; + kind: "panel" | "action"; + policy: BlockValidationPolicy; +} + /** * Media provider entry from virtual module */ @@ -639,6 +664,8 @@ const marketplaceManifestCache = new Map< pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[]; settingsSchema?: Record; + editorPanels?: PluginManifest["admin"]["editorPanels"]; + editorActions?: PluginManifest["admin"]["editorActions"]; }; mcp?: PluginMcpManifestConfig; storage?: PluginManifest["storage"]; @@ -1377,6 +1404,8 @@ export class EmDashRuntime { w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), settingsSchema: bundle.manifest.admin?.settingsSchema, + editorPanels: bundle.manifest.admin?.editorPanels, + editorActions: bundle.manifest.admin?.editorActions, }); newPlugins.push(adapted); this.allPipelinePlugins.push(adapted); @@ -2357,6 +2386,8 @@ export class EmDashRuntime { settingsSchema: entry.settingsSchema, portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, + editorPanels: entry.editorPanels, + editorActions: entry.editorActions, }); plugins.push(resolved); console.log( @@ -2503,6 +2534,8 @@ export class EmDashRuntime { settingsSchema: entry.settingsSchema, portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, + editorPanels: entry.editorPanels, + editorActions: entry.editorActions, }, mcp: entry.mcp, }; @@ -2745,6 +2778,8 @@ export class EmDashRuntime { w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), settingsSchema: bundle.manifest.admin?.settingsSchema, + editorPanels: bundle.manifest.admin?.editorPanels, + editorActions: bundle.manifest.admin?.editorActions, }); resolved.push(adapted); console.log( @@ -2878,6 +2913,8 @@ export class EmDashRuntime { fieldTypes: string[]; elements?: Element[]; }>; + editorPanels?: PluginManifest["admin"]["editorPanels"]; + editorActions?: PluginManifest["admin"]["editorActions"]; } > = {}; @@ -2889,10 +2926,13 @@ export class EmDashRuntime { const hasAdminEntry = !!plugin.admin?.entry; const hasAdminPages = (plugin.admin?.pages?.length ?? 0) > 0; const hasWidgets = (plugin.admin?.widgets?.length ?? 0) > 0; + const hasEditorExtensions = + (plugin.admin?.editorPanels?.length ?? 0) > 0 || + (plugin.admin?.editorActions?.length ?? 0) > 0; let adminMode: "react" | "blocks" | "none" = "none"; if (hasAdminEntry) { adminMode = "react"; - } else if (hasAdminPages || hasWidgets) { + } else if (hasAdminPages || hasWidgets || hasEditorExtensions) { adminMode = "blocks"; } @@ -2904,6 +2944,8 @@ export class EmDashRuntime { dashboardWidgets: plugin.admin?.widgets ?? [], portableTextBlocks: plugin.admin?.portableTextBlocks, fieldWidgets: plugin.admin?.fieldWidgets, + editorPanels: plugin.admin?.editorPanels, + editorActions: plugin.admin?.editorActions, }; } @@ -2914,6 +2956,8 @@ export class EmDashRuntime { const hasAdminPages = (entry.adminPages?.length ?? 0) > 0; const hasWidgets = (entry.adminWidgets?.length ?? 0) > 0; + const hasEditorExtensions = + (entry.editorPanels?.length ?? 0) > 0 || (entry.editorActions?.length ?? 0) > 0; manifestPlugins[entry.id] = { version: entry.version, @@ -2923,11 +2967,13 @@ export class EmDashRuntime { // contribute portableTextBlocks/fieldWidgets with adminMode "none" — // the admin reads those from the manifest regardless, so don't gate // admin contributions on `adminMode`. - adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", + adminMode: hasAdminPages || hasWidgets || hasEditorExtensions ? "blocks" : "none", adminPages: entry.adminPages ?? [], dashboardWidgets: entry.adminWidgets ?? [], portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, + editorPanels: entry.editorPanels, + editorActions: entry.editorActions, }; } @@ -2943,14 +2989,18 @@ export class EmDashRuntime { const widgets = meta.admin?.widgets; const hasAdminPages = (pages?.length ?? 0) > 0; const hasWidgets = (widgets?.length ?? 0) > 0; + const hasEditorExtensions = + (meta.admin?.editorPanels?.length ?? 0) > 0 || (meta.admin?.editorActions?.length ?? 0) > 0; manifestPlugins[pluginId] = { version: meta.version, enabled, sandboxed: true, - adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", + adminMode: hasAdminPages || hasWidgets || hasEditorExtensions ? "blocks" : "none", adminPages: pages ?? [], dashboardWidgets: widgets ?? [], + editorPanels: meta.admin?.editorPanels, + editorActions: meta.admin?.editorActions, }; } @@ -4919,6 +4969,108 @@ export class EmDashRuntime { }; } + getPluginEditorExtension( + pluginId: string, + kind: "panel" | "action", + extensionId: string, + collection: string, + ): ResolvedPluginEditorExtension | null { + if (!this.isPluginEnabled(pluginId)) return null; + + let panels: PluginEditorPanel[] | undefined; + let actions: PluginEditorAction[] | undefined; + let pages: string[] = []; + let capabilities: readonly PluginCapability[] = []; + let allowedHosts: readonly string[] = []; + + const configured = this.configuredPlugins.find((plugin) => plugin.id === pluginId); + if (configured) { + if (configured.admin.entry) return null; + panels = configured.admin.editorPanels; + actions = configured.admin.editorActions; + pages = (configured.admin.pages ?? []).map((page) => page.path); + capabilities = configured.capabilities; + allowedHosts = configured.allowedHosts; + } else { + const entry = this.sandboxedPluginEntries.find((candidate) => candidate.id === pluginId); + if (entry) { + panels = entry.editorPanels; + actions = entry.editorActions; + pages = (entry.adminPages ?? []).map((page) => page.path); + capabilities = entry.capabilities; + allowedHosts = entry.allowedHosts; + } else { + const manifest = marketplaceManifestCache.get(pluginId); + if (!manifest) return null; + panels = manifest.admin?.editorPanels; + actions = manifest.admin?.editorActions; + pages = (manifest.admin?.pages ?? []).map((page) => page.path); + capabilities = manifest.capabilities ?? []; + allowedHosts = manifest.allowedHosts ?? []; + } + } + + const policy = { + pluginPagePaths: pages, + allowedImageHosts: allowedBrowserImageHosts(capabilities, allowedHosts), + }; + if (kind === "panel") { + const matches = panels?.filter((panel) => panel.id === extensionId) ?? []; + const extension = matches[0]; + if ( + matches.length !== 1 || + !extension || + (extension.collections && !extension.collections.includes(collection)) + ) { + return null; + } + return { kind, extension, policy }; + } + const matches = actions?.filter((action) => action.id === extensionId) ?? []; + const extension = matches[0]; + if ( + matches.length !== 1 || + !extension || + (extension.collections && !extension.collections.includes(collection)) || + (extension.style === "danger" && !extension.confirm) + ) { + return null; + } + return { kind, extension, policy }; + } + + private validatePluginEditorExtensionResponse( + pluginId: string, + dispatch: PluginEditorExtensionDispatch, + result: { + success: boolean; + data?: unknown; + error?: { code: string; message: string }; + status?: number; + }, + ) { + if (!result.success) return result; + const validation = + dispatch.kind === "panel" + ? validateBlockResponse(result.data, dispatch.policy) + : validateContentEditorActionResponse(result.data, dispatch.policy); + if (validation.valid) return result; + + console.error( + `EmDash: Plugin ${pluginId} returned an invalid content editor ${dispatch.kind} response:`, + validation.errors, + ); + return { + success: false, + status: 502, + error: { + code: + dispatch.kind === "panel" ? "INVALID_BLOCK_RESPONSE" : "INVALID_EDITOR_ACTION_RESPONSE", + message: "Plugin returned an invalid editor extension response", + }, + }; + } + /** * Get route metadata for a plugin route without invoking the handler. * Used by the catch-all route to decide auth before dispatch. @@ -4986,6 +5138,7 @@ export class EmDashRuntime { path: string, request: Request, user?: RouteCallerInput | null, + editorDispatch?: PluginEditorExtensionDispatch, invalidateContentCache?: PluginContentCacheInvalidator, ) { if (!this.isPluginEnabled(pluginId)) { @@ -5002,7 +5155,7 @@ export class EmDashRuntime { const body = await parseRouteInput(request); const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); const adminDefinition = - routeKey === "admin" ? this.getSandboxedAdminDefinition(pluginId) : null; + !editorDispatch && routeKey === "admin" ? this.getSandboxedAdminDefinition(pluginId) : null; const uiResult = adminDefinition ? this.resolvePluginUiContext(adminDefinition, body, request) : {}; @@ -5025,11 +5178,13 @@ export class EmDashRuntime { request, body, user: caller, - ui: uiResult.context, + ui: editorDispatch?.ui ?? uiResult.context, }); - return adminDefinition - ? this.validateSandboxedAdminResponse(pluginId, adminDefinition, result) - : result; + return editorDispatch + ? this.validatePluginEditorExtensionResponse(pluginId, editorDispatch, result) + : adminDefinition + ? this.validateSandboxedAdminResponse(pluginId, adminDefinition, result) + : result; } // Check sandboxed (marketplace) plugins second @@ -5041,12 +5196,14 @@ export class EmDashRuntime { request, body, caller, - uiResult.context, + editorDispatch?.ui ?? uiResult.context, invalidateContentCache, ); - return adminDefinition - ? this.validateSandboxedAdminResponse(pluginId, adminDefinition, result) - : result; + return editorDispatch + ? this.validatePluginEditorExtensionResponse(pluginId, editorDispatch, result) + : adminDefinition + ? this.validateSandboxedAdminResponse(pluginId, adminDefinition, result) + : result; } return { @@ -5214,6 +5371,7 @@ export class EmDashRuntime { route, internalRequest, caller, + undefined, invalidateContentCache, ); await audit.log({ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 54f4ca4fda..e1cd10c35b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -384,6 +384,8 @@ export type { RouteContext, PluginAdminConfig, PluginAdminPage, + PluginEditorPanel, + PluginEditorAction, PluginAdminExports, FieldWidgetConfig, PortableTextBlockConfig, diff --git a/packages/core/src/plugin-test-runtime.ts b/packages/core/src/plugin-test-runtime.ts index 9eb9061ce8..02ace3ab32 100644 --- a/packages/core/src/plugin-test-runtime.ts +++ b/packages/core/src/plugin-test-runtime.ts @@ -1,6 +1,9 @@ export { EmDashRuntime } from "./emdash-runtime.js"; export type { RuntimeDependencies } from "./emdash-runtime.js"; -export { dispatchPluginApiRequest } from "./plugins/http-route-dispatch.js"; +export { + dispatchPluginApiRequest, + dispatchPluginEditorExtensionApiRequest, +} from "./plugins/http-route-dispatch.js"; export { handlePluginSettingsGet, handlePluginSettingsUpdate, diff --git a/packages/core/src/plugins/adapt-sandbox-entry.ts b/packages/core/src/plugins/adapt-sandbox-entry.ts index c385844200..14e3f22262 100644 --- a/packages/core/src/plugins/adapt-sandbox-entry.ts +++ b/packages/core/src/plugins/adapt-sandbox-entry.ts @@ -336,6 +336,12 @@ export function adaptSandboxEntry( if (descriptor.adminWidgets) { admin.widgets = descriptor.adminWidgets; } + if (descriptor.editorPanels) { + admin.editorPanels = descriptor.editorPanels; + } + if (descriptor.editorActions) { + admin.editorActions = descriptor.editorActions; + } if (descriptor.settingsSchema) { admin.settingsSchema = descriptor.settingsSchema; } diff --git a/packages/core/src/plugins/http-route-dispatch.ts b/packages/core/src/plugins/http-route-dispatch.ts index fc2c07d48d..ea7b5ef67f 100644 --- a/packages/core/src/plugins/http-route-dispatch.ts +++ b/packages/core/src/plugins/http-route-dispatch.ts @@ -1,9 +1,11 @@ +import { getLocaleDir, resolveLocale } from "@emdash-cms/admin/locales"; import { Permissions, type Permission, type RoleLevel } from "@emdash-cms/auth"; +import { validateContentEditorPanelInteraction } from "@emdash-cms/blocks/server"; -import { requirePerm } from "../api/authorize.js"; +import { requirePerm, requireOwnerPerm } from "../api/authorize.js"; import { apiError, apiSuccess } from "../api/error.js"; import { requireScope } from "../auth/scopes.js"; -import type { EmDashRuntime } from "../emdash-runtime.js"; +import type { EmDashRuntime, PluginEditorExtensionDispatch } from "../emdash-runtime.js"; import type { PluginContentCacheInvalidator } from "./routes.js"; import type { UserInfo } from "./types.js"; @@ -26,6 +28,7 @@ export interface PluginApiRequestContext { user?: UserInfo | null; tokenScopes?: string[]; invalidateContentCache?: PluginContentCacheInvalidator; + editorDispatch?: PluginEditorExtensionDispatch; } /** Dispatch a request through the production plugin-route policy boundary. */ @@ -37,6 +40,7 @@ export async function dispatchPluginApiRequest({ user, tokenScopes, invalidateContentCache, + editorDispatch, }: PluginApiRequestContext): Promise { const method = request.method.toUpperCase(); const routeMeta = runtime.getPluginRouteMeta(pluginId, path); @@ -73,6 +77,7 @@ export async function dispatchPluginApiRequest({ path, request, caller, + editorDispatch, invalidateContentCache, ); if (!result.success) { @@ -91,3 +96,129 @@ export async function dispatchPluginApiRequest({ } return response; } + +export interface PluginEditorExtensionApiRequestContext { + runtime: EmDashRuntime; + pluginId: string; + kind: "panel" | "action"; + extensionId: string; + collection: string; + entryId: string; + request: Request; + user?: UserInfo | null; + tokenScopes?: string[]; +} + +/** Dispatch a saved-entry extension through ownership, route, and isolate policy. */ +export async function dispatchPluginEditorExtensionApiRequest({ + runtime, + pluginId, + kind, + extensionId, + collection, + entryId, + request, + user, + tokenScopes, +}: PluginEditorExtensionApiRequestContext): Promise { + const definition = runtime.getPluginEditorExtension(pluginId, kind, extensionId, collection); + if (!definition) return apiError("NOT_FOUND", "Plugin editor extension not found", 404); + + const routeMeta = runtime.getPluginRouteMeta(pluginId, definition.extension.route); + if (!routeMeta || routeMeta.public) { + return apiError( + "INVALID_PLUGIN_EDITOR_EXTENSION", + "Plugin editor extension must reference a private route", + 500, + ); + } + + const requestedLocale = new URL(request.url).searchParams.get("locale") || undefined; + const contentResult = await runtime.handleContentGet(collection, entryId, requestedLocale); + if (!contentResult.success) { + return contentResult.error?.code === "NOT_FOUND" + ? apiError("NOT_FOUND", "Content item not found", 404) + : apiError("CONTENT_GET_ERROR", "Failed to load content item", 500); + } + const contentData = contentResult.data; + const item = + typeof contentData === "object" && contentData !== null && "item" in contentData + ? contentData.item + : null; + if (typeof item !== "object" || item === null) { + return apiError("CONTENT_GET_ERROR", "Failed to load content item", 500); + } + const authorId = "authorId" in item && typeof item.authorId === "string" ? item.authorId : ""; + let ownershipUser: { id: string; role: RoleLevel } | null | undefined = + user == null ? user : undefined; + if (user) { + const ownerRole = toRoleLevel(user.role); + if (ownerRole === null) { + return apiError("INVALID_USER", "Authenticated user has an invalid role", 500); + } + ownershipUser = { id: user.id, role: ownerRole }; + } + const ownershipDenied = requireOwnerPerm( + ownershipUser, + authorId, + "content:edit_own", + "content:edit_any", + ); + if (ownershipDenied) return ownershipDenied; + + const canonicalId = "id" in item && typeof item.id === "string" ? item.id : null; + const canonicalLocale = "locale" in item && typeof item.locale === "string" ? item.locale : null; + const version = "version" in item && typeof item.version === "number" ? item.version : null; + if (!canonicalId || version === null) { + return apiError("CONTENT_GET_ERROR", "Content item has invalid identity", 500); + } + + let input: unknown; + if (kind === "panel") { + try { + input = await request.json(); + } catch { + return apiError("INVALID_REQUEST", "Editor panel interaction must be JSON", 400); + } + const validation = validateContentEditorPanelInteraction(input); + if (!validation.valid) { + return apiError("INVALID_PLUGIN_UI_CONTEXT", "Invalid editor panel interaction", 400); + } + } else { + input = { type: "editor_action" }; + } + + const headers = new Headers(request.headers); + headers.set("Content-Type", "application/json"); + const pluginRequest = new Request(request.url, { + method: "POST", + headers, + body: JSON.stringify(input), + }); + const locale = resolveLocale(request); + return dispatchPluginApiRequest({ + runtime, + pluginId, + path: definition.extension.route, + request: pluginRequest, + user, + tokenScopes, + editorDispatch: { + kind, + policy: definition.policy, + ui: { + surface: kind === "panel" ? "content-editor-panel" : "content-editor-action", + locale, + direction: getLocaleDir(locale), + contentLocale: canonicalLocale ?? undefined, + extensionId, + entry: { + collection, + id: canonicalId, + locale: canonicalLocale, + version, + }, + }, + }, + }); +} diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index 5d55985ff5..dcc2b71124 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -281,6 +281,8 @@ export type { PluginAdminConfig, PluginAdminPage, PluginDashboardWidget, + PluginEditorPanel, + PluginEditorAction, PluginAdminExports, FieldWidgetConfig, PortableTextBlockConfig, diff --git a/packages/core/src/plugins/manifest-schema.ts b/packages/core/src/plugins/manifest-schema.ts index e16598a223..777f24efd9 100644 --- a/packages/core/src/plugins/manifest-schema.ts +++ b/packages/core/src/plugins/manifest-schema.ts @@ -241,32 +241,98 @@ const dashboardWidgetSchema = z.object({ title: z.string().optional(), }); -const pluginAdminConfigSchema = z.object({ - entry: z.string().optional(), - settingsSchema: z.record(z.string(), settingFieldSchema).optional(), - pages: z.array(adminPageSchema).optional(), - widgets: z.array(dashboardWidgetSchema).optional(), - fieldWidgets: z - .array( - z.object({ - name: z.string().min(1), - label: z.string().min(1), - fieldTypes: z.array(z.enum(FIELD_TYPES)), - elements: z - .array( - z - .object({ - type: z.string(), - action_id: z.string(), - label: z.string().optional(), - }) - .loose(), - ) - .optional(), - }), - ) - .optional(), +const editorExtensionIdPattern = /^[a-z][a-z0-9_-]*$/; +const collectionSlugPattern = /^[a-z][a-z0-9_]*$/; +const editorCollectionsSchema = z + .array(z.string().max(63).regex(collectionSlugPattern, "Invalid collection slug")) + .max(64) + .refine((collections) => new Set(collections).size === collections.length, { + message: "Editor extension collections must be unique", + }); +const editorPanelSchema = z.object({ + id: z.string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor panel id"), + title: z.string().min(1).max(128), + route: z + .string() + .min(1) + .max(128) + .regex(routeNamePattern, "Route name must be a safe path segment"), + collections: editorCollectionsSchema.optional(), + order: z.number().int().min(-1_000).max(1_000).optional(), +}); +const editorActionConfirmSchema = z.object({ + title: z.string().min(1).max(128), + text: z.string().min(1).max(1_024), + confirm: z.string().min(1).max(64), + deny: z.string().min(1).max(64), + style: z.literal("danger").optional(), }); +const editorActionSchema = z + .object({ + id: z.string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor action id"), + label: z.string().min(1).max(128), + route: z + .string() + .min(1) + .max(128) + .regex(routeNamePattern, "Route name must be a safe path segment"), + placement: z.enum(["toolbar", "overflow"]), + collections: editorCollectionsSchema.optional(), + style: z.enum(["default", "danger"]).optional(), + confirm: editorActionConfirmSchema.optional(), + }) + .refine((action) => action.style !== "danger" || action.confirm !== undefined, { + message: "Danger editor actions require confirmation", + path: ["confirm"], + }); + +function uniqueExtensionIds( + items: readonly { id: string }[] | undefined, + ctx: z.RefinementCtx, + path: "editorPanels" | "editorActions", +): void { + const seen = new Set(); + for (const [index, item] of (items ?? []).entries()) { + if (seen.has(item.id)) { + ctx.addIssue({ code: "custom", message: `Duplicate ${path} id`, path: [path, index, "id"] }); + } + seen.add(item.id); + } +} + +const pluginAdminConfigSchema = z + .object({ + entry: z.string().optional(), + settingsSchema: z.record(z.string(), settingFieldSchema).optional(), + pages: z.array(adminPageSchema).optional(), + widgets: z.array(dashboardWidgetSchema).optional(), + editorPanels: z.array(editorPanelSchema).max(32).optional(), + editorActions: z.array(editorActionSchema).max(32).optional(), + fieldWidgets: z + .array( + z.object({ + name: z.string().min(1), + label: z.string().min(1), + fieldTypes: z.array(z.enum(FIELD_TYPES)), + elements: z + .array( + z + .object({ + type: z.string(), + action_id: z.string(), + label: z.string().optional(), + }) + .loose(), + ) + .optional(), + }), + ) + .optional(), + }) + .superRefine((admin, ctx) => { + uniqueExtensionIds(admin.editorPanels, ctx, "editorPanels"); + uniqueExtensionIds(admin.editorActions, ctx, "editorActions"); + }); // ── declaredAccess ────────────────────────────────────────────── @@ -340,7 +406,7 @@ const declaredAccessSchema = z.object({ * to make them consistent (declaredAccess authoritative when present). Kept a * plain object (no `.transform`) because callers `.pick()`/`.extend()` it. */ -export const pluginManifestSchema = z.object({ +export const pluginManifestBaseSchema = z.object({ id: z.string().min(1), version: z.string().min(1), declaredAccess: declaredAccessSchema.optional(), @@ -368,6 +434,46 @@ export const pluginManifestSchema = z.object({ admin: pluginAdminConfigSchema, }); +function validateEditorExtensionRoutes( + manifest: z.infer, + ctx: z.RefinementCtx, +): void { + for (const [kind, extensions] of [ + ["editorPanels", manifest.admin.editorPanels], + ["editorActions", manifest.admin.editorActions], + ] as const) { + for (const [index, extension] of (extensions ?? []).entries()) { + const matches = manifest.routes.filter( + (route) => (typeof route === "string" ? route : route.name) === extension.route, + ); + if (matches.length !== 1) { + ctx.addIssue({ + code: "custom", + message: + matches.length === 0 + ? "Editor extension route is not declared" + : "Editor extension route must be declared exactly once", + path: ["admin", kind, index, "route"], + }); + continue; + } + const route = matches[0]; + if (!route) continue; + if (typeof route !== "string" && route.public === true) { + ctx.addIssue({ + code: "custom", + message: "Editor extension routes must be private", + path: ["admin", kind, index, "route"], + }); + } + } + } +} + +export const pluginManifestSchema = pluginManifestBaseSchema.superRefine( + validateEditorExtensionRoutes, +); + export type ValidatedPluginManifest = z.infer; /** diff --git a/packages/core/src/plugins/sandbox/proxy.ts b/packages/core/src/plugins/sandbox/proxy.ts index 36ccb8238b..99633a47f2 100644 --- a/packages/core/src/plugins/sandbox/proxy.ts +++ b/packages/core/src/plugins/sandbox/proxy.ts @@ -119,6 +119,8 @@ export function createSandboxedPluginProxy( storage: manifest.storage as never, adminPages: manifest.admin.pages, adminWidgets: manifest.admin.widgets, + editorPanels: manifest.admin.editorPanels, + editorActions: manifest.admin.editorActions, settingsSchema: manifest.admin.settingsSchema, portableTextBlocks: manifest.admin.portableTextBlocks, fieldWidgets: manifest.admin.fieldWidgets, diff --git a/packages/core/src/plugins/types.ts b/packages/core/src/plugins/types.ts index 8df09cd70e..460e5b3a5b 100644 --- a/packages/core/src/plugins/types.ts +++ b/packages/core/src/plugins/types.ts @@ -10,7 +10,7 @@ */ import type { Permission } from "@emdash-cms/auth"; -import type { Element, PluginUiContext } from "@emdash-cms/blocks"; +import type { ConfirmDialog, Element, PluginUiContext } from "@emdash-cms/blocks"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @emdash-cms/plugin-types and shared between core // (the manifest reader at install/runtime) and @emdash-cms/plugin-cli (the @@ -1780,6 +1780,24 @@ export interface PluginDashboardWidget { title?: string; } +export interface PluginEditorPanel { + id: string; + title: string; + route: string; + collections?: string[]; + order?: number; +} + +export interface PluginEditorAction { + id: string; + label: string; + route: string; + placement: "toolbar" | "overflow"; + collections?: string[]; + style?: "default" | "danger"; + confirm?: ConfirmDialog; +} + /** * Settings field types (for admin UI generation) */ @@ -1909,6 +1927,10 @@ export interface PluginAdminConfig { pages?: PluginAdminPage[]; /** Dashboard widgets */ widgets?: PluginDashboardWidget[]; + /** Saved-entry Block Kit panels. */ + editorPanels?: PluginEditorPanel[]; + /** Saved-entry host-rendered actions. */ + editorActions?: PluginEditorAction[]; /** Portable Text block types this plugin provides */ portableTextBlocks?: PortableTextBlockConfig[]; /** Field widget types this plugin provides */ diff --git a/packages/core/tests/unit/astro/middleware-prerender.test.ts b/packages/core/tests/unit/astro/middleware-prerender.test.ts index 161dde9a26..7c4faaba04 100644 --- a/packages/core/tests/unit/astro/middleware-prerender.test.ts +++ b/packages/core/tests/unit/astro/middleware-prerender.test.ts @@ -72,6 +72,7 @@ const { handleRevisionGet: ok, handleRevisionRestore: ok, getPluginRouteMeta, + getPluginEditorExtension: () => null, handlePluginApiRoute, getPluginMcpTools: async () => [], getEnabledPluginMcpTools: async () => [], @@ -340,6 +341,7 @@ describe("astro middleware prerendered routes", () => { expect(response.status).toBe(200); const emdash = locals.emdash as Record; expect(typeof emdash.handlePluginApiRoute).toBe("function"); + expect(typeof emdash.getPluginEditorExtension).toBe("function"); expect(typeof emdash.handlePublicPluginApiRoute).toBe("function"); // Regression for #1462: the author filter route reads // `locals.emdash.handleContentAuthors`; it must be wired onto the diff --git a/packages/core/tests/unit/astro/plugin-editor-extension-auth.test.ts b/packages/core/tests/unit/astro/plugin-editor-extension-auth.test.ts new file mode 100644 index 0000000000..628ed5b3a2 --- /dev/null +++ b/packages/core/tests/unit/astro/plugin-editor-extension-auth.test.ts @@ -0,0 +1,116 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import type { APIRoute } from "astro"; +import { describe, expect, it, vi } from "vitest"; + +import { POST } from "../../../src/astro/routes/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId].js"; + +function createLocals( + role: RoleLevel, + userId = "owner", + routePublic = false, + permission = "content:edit_own", +) { + const handlePluginApiRoute = vi.fn(async () => ({ success: true, data: { blocks: [] } })); + const handleContentGet = vi.fn(async () => ({ + success: true, + data: { + item: { id: "entry-1", authorId: "owner", locale: "en", version: 7 }, + }, + })); + return { + user: { id: userId, role }, + emdash: { + getPluginEditorExtension: () => ({ + kind: "panel", + extension: { id: "health", title: "Health", route: "entry-health" }, + policy: {}, + }), + getPluginRouteMeta: () => ({ public: routePublic, permission }), + handleContentGet, + handlePluginApiRoute, + }, + handleContentGet, + handlePluginApiRoute, + }; +} + +function invoke( + locals: unknown, + body: unknown = { type: "panel_load" }, + options: { csrf?: boolean; kind?: "panel" | "action" } = {}, +) { + const headers = new Headers({ "Content-Type": "application/json" }); + if (options.csrf !== false) headers.set("X-EmDash-Request", "1"); + return (POST as APIRoute)({ + params: { + collection: "posts", + id: "entry-1", + pluginId: "content-guard", + kind: options.kind ?? "panel", + extensionId: "health", + }, + request: new Request( + "https://example.test/_emdash/api/content/posts/entry-1/plugin-extensions/content-guard/panel/health?locale=en", + { method: "POST", headers, body: JSON.stringify(body) }, + ), + locals, + } as never); +} + +describe("saved-entry plugin extension authorization", () => { + it("re-fetches the entry and passes only canonical identity to the plugin", async () => { + const locals = createLocals(Role.AUTHOR); + const response = await invoke(locals); + expect(response.status).toBe(200); + expect(locals.handleContentGet).toHaveBeenCalledWith("posts", "entry-1", "en"); + expect(locals.handlePluginApiRoute).toHaveBeenCalledWith( + "content-guard", + "POST", + "entry-health", + expect.any(Request), + expect.objectContaining({ id: "owner" }), + expect.objectContaining({ + kind: "panel", + ui: expect.objectContaining({ + surface: "content-editor-panel", + extensionId: "health", + entry: { collection: "posts", id: "entry-1", locale: "en", version: 7 }, + }), + }), + ); + const pluginRequest = locals.handlePluginApiRoute.mock.calls[0]?.[3]; + await expect(pluginRequest?.json()).resolves.toEqual({ type: "panel_load" }); + }); + + it("denies another author before plugin invocation", async () => { + const locals = createLocals(Role.AUTHOR, "other-author"); + const response = await invoke(locals); + expect(response.status).toBe(403); + expect(locals.handlePluginApiRoute).not.toHaveBeenCalled(); + }); + + it("retains CSRF and private-route enforcement", async () => { + const missingCsrf = createLocals(Role.AUTHOR); + expect((await invoke(missingCsrf, undefined, { csrf: false })).status).toBe(403); + expect(missingCsrf.handlePluginApiRoute).not.toHaveBeenCalled(); + + const publicRoute = createLocals(Role.AUTHOR, "owner", true); + expect((await invoke(publicRoute)).status).toBe(500); + expect(publicRoute.handlePluginApiRoute).not.toHaveBeenCalled(); + + const strongerPermission = createLocals(Role.AUTHOR, "owner", false, "plugins:manage"); + expect((await invoke(strongerPermission)).status).toBe(403); + expect(strongerPermission.handlePluginApiRoute).not.toHaveBeenCalled(); + }); + + it("rejects forged host context fields and creates action input itself", async () => { + const forged = createLocals(Role.AUTHOR); + expect((await invoke(forged, { type: "panel_load", entry: { id: "other" } })).status).toBe(400); + expect(forged.handlePluginApiRoute).not.toHaveBeenCalled(); + + const action = createLocals(Role.AUTHOR); + expect((await invoke(action, { entry: { id: "other" } }, { kind: "action" })).status).toBe(200); + const pluginRequest = action.handlePluginApiRoute.mock.calls[0]?.[3]; + await expect(pluginRequest?.json()).resolves.toEqual({ type: "editor_action" }); + }); +}); diff --git a/packages/core/tests/unit/astro/routes.test.ts b/packages/core/tests/unit/astro/routes.test.ts index 19b8b63f48..a1ba1f480c 100644 --- a/packages/core/tests/unit/astro/routes.test.ts +++ b/packages/core/tests/unit/astro/routes.test.ts @@ -108,6 +108,18 @@ describe("core media route injection", () => { expect(patterns).toContain("/_emdash/api/admin/scheduled-policy-rejections/[collection]/[id]"); }); + it("injects the saved-entry plugin extension route", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route)); + + expect(routes).toContainEqual( + expect.objectContaining({ + pattern: + "/_emdash/api/content/[collection]/[id]/plugin-extensions/[pluginId]/[kind]/[extensionId]", + }), + ); + }); + it("registers the media replacement route with PUT only", () => { const routes: Array<{ pattern: string; entrypoint: string }> = []; injectCoreRoutes((route) => routes.push(route)); diff --git a/packages/core/tests/unit/plugins/manifest-schema.test.ts b/packages/core/tests/unit/plugins/manifest-schema.test.ts index 86daf2eb08..d7971c60bc 100644 --- a/packages/core/tests/unit/plugins/manifest-schema.test.ts +++ b/packages/core/tests/unit/plugins/manifest-schema.test.ts @@ -159,6 +159,64 @@ describe("pluginManifestSchema — route entries", () => { }); }); +describe("pluginManifestSchema — editor extensions", () => { + it("accepts bounded declarations that reference private routes", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes: [ + { name: "entry-panel", permission: "content:edit_own" }, + { name: "entry-action", permission: "content:edit_own" }, + ], + admin: { + editorPanels: [ + { id: "health", title: "Health", route: "entry-panel", collections: ["posts"] }, + ], + editorActions: [ + { + id: "repair", + label: "Repair", + route: "entry-action", + placement: "toolbar", + }, + ], + }, + }); + expect(result.success).toBe(true); + }); + + it.each([ + ["missing", ["entry-panel"], "missing"], + ["public", [{ name: "entry-panel", public: true }], "entry-panel"], + ["ambiguous", ["entry-panel", { name: "entry-panel" }], "entry-panel"], + ])("rejects a %s editor extension route", (_label, routes, route) => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes, + admin: { editorPanels: [{ id: "health", title: "Health", route }] }, + }); + expect(result.success).toBe(false); + }); + + it("requires confirmation for danger actions", () => { + const result = pluginManifestSchema.safeParse({ + ...makeManifest({}), + routes: ["repair"], + admin: { + editorActions: [ + { + id: "repair", + label: "Repair", + route: "repair", + placement: "toolbar", + style: "danger", + }, + ], + }, + }); + expect(result.success).toBe(false); + }); +}); + describe("pluginManifestSchema — MCP tools", () => { it("keeps MCP declarations optional for backwards compatibility", () => { expect(pluginManifestSchema.safeParse(makeManifest({})).success).toBe(true); diff --git a/packages/core/tests/unit/runtime/manifest-build.test.ts b/packages/core/tests/unit/runtime/manifest-build.test.ts index 52d0537149..295745453e 100644 --- a/packages/core/tests/unit/runtime/manifest-build.test.ts +++ b/packages/core/tests/unit/runtime/manifest-build.test.ts @@ -22,7 +22,9 @@ import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; import type { Database } from "../../../src/database/types.js"; import { EmDashRuntime } from "../../../src/emdash-runtime.js"; import { setI18nConfig } from "../../../src/i18n/config.js"; +import { definePlugin } from "../../../src/plugins/define-plugin.js"; import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import type { ResolvedPlugin } from "../../../src/plugins/types.js"; import { SchemaRegistry } from "../../../src/schema/registry.js"; import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; @@ -57,13 +59,17 @@ const configCollections = { }, }; -function buildRuntime(db: Kysely, config: EmDashConfig = {}): EmDashRuntime { +function buildRuntime( + db: Kysely, + config: EmDashConfig = {}, + configuredPlugins: ResolvedPlugin[] = [], +): EmDashRuntime { const pipelineFactoryOptions = { db } as const; - const hooks = createHookPipeline([], pipelineFactoryOptions); + const hooks = createHookPipeline(configuredPlugins, pipelineFactoryOptions); const pipelineRef = { current: hooks }; const runtimeDeps = { config, - plugins: [], + plugins: configuredPlugins, // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature createDialect: (() => { throw new Error("createDialect not used in this test"); @@ -77,11 +83,11 @@ function buildRuntime(db: Kysely, config: EmDashConfig = {}): EmDashRu return new EmDashRuntime({ db, storage: null, - configuredPlugins: [], + configuredPlugins, sandboxedPlugins: new Map(), sandboxedPluginEntries: [], hooks, - enabledPlugins: new Set(), + enabledPlugins: new Set(configuredPlugins.map((plugin) => plugin.id)), pluginStates: new Map(), config, mediaProviders: new Map(), @@ -89,7 +95,7 @@ function buildRuntime(db: Kysely, config: EmDashConfig = {}): EmDashRu cronExecutor: null, cronScheduler: null, emailPipeline: null, - allPipelinePlugins: [], + allPipelinePlugins: [...configuredPlugins], pipelineFactoryOptions, runtimeDeps, pipelineRef, @@ -374,6 +380,28 @@ describe("EmDashRuntime.getManifest()", () => { expect(manifest.contentLocale).toEqual({ defaultLocale: "en", implicit: true }); }); + it("exposes configured saved-entry panels and actions to the admin", async () => { + const plugin = definePlugin({ + id: "content-guard", + version: "1.0.0", + routes: { + health: { permission: "content:edit_own", handler: async () => ({ blocks: [] }) }, + repair: { permission: "content:edit_own", handler: async () => ({ refresh: true }) }, + }, + admin: { + editorPanels: [{ id: "health", title: "Health", route: "health" }], + editorActions: [{ id: "repair", label: "Repair", route: "repair", placement: "overflow" }], + }, + }); + const runtime = buildRuntime(db, {}, [plugin]); + + expect((await runtime.getManifest()).plugins["content-guard"]).toMatchObject({ + adminMode: "blocks", + editorPanels: [{ id: "health", route: "health" }], + editorActions: [{ id: "repair", route: "repair", placement: "overflow" }], + }); + }); + it("keeps the admin manifest available with a safe registry configuration diagnostic", async () => { const log = vi.spyOn(console, "error").mockImplementation(() => undefined); const runtime = buildRuntime(db, { diff --git a/packages/marketplace/src/routes/author.ts b/packages/marketplace/src/routes/author.ts index 6b684cf2df..bd2dbf0a56 100644 --- a/packages/marketplace/src/routes/author.ts +++ b/packages/marketplace/src/routes/author.ts @@ -791,42 +791,121 @@ const routeEntrySchema = z.union([ z.object({ name: z.string().min(1).regex(routeNamePattern, "Route name must be a safe path segment"), public: z.boolean().optional(), + permission: z.string().min(1).optional(), + cacheControl: z.string().min(1).optional(), }), ]); -export const manifestSchema = z.object({ - // Core PluginManifest fields - id: z.string().min(1), - version: z.string().regex(RE_SEMVER_FULL, "Must be valid semver"), - capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), - allowedHosts: z.array(z.string()).default([]), - storage: z.record(z.string(), storageCollectionSchema).default({}), - hooks: z.array(hookEntrySchema).default([]), - routes: z.array(routeEntrySchema).default([]), - admin: z - .object({ - entry: z.string().optional(), - settingsSchema: z.record(z.string(), z.unknown()).optional(), - pages: z - .array(z.object({ path: z.string(), label: z.string(), icon: z.string().optional() })) - .optional(), - widgets: z - .array( - z.object({ - id: z.string(), - size: z.enum(["full", "half", "third"]).optional(), - title: z.string().optional(), - }), - ) - .optional(), - }) - .default({}), - // Marketplace publishing extras (not part of core PluginManifest) - name: z.string().min(1).max(100).optional(), - description: z.string().max(200).optional(), - minEmDashVersion: z.string().optional(), - changelog: z.string().optional(), +const editorExtensionIdSchema = z + .string() + .min(1) + .max(64) + .regex(/^[a-z][a-z0-9_-]*$/, "Editor extension id must be a lowercase slug"); +const editorCollectionsSchema = z + .array( + z + .string() + .max(63) + .regex(/^[a-z][a-z0-9_]*$/, "Invalid collection slug"), + ) + .max(64); +const editorPanelSchema = z.object({ + id: editorExtensionIdSchema, + title: z.string().min(1).max(128), + route: z.string().min(1).max(128).regex(routeNamePattern), + collections: editorCollectionsSchema.optional(), + order: z.number().int().min(-1_000).max(1_000).optional(), }); +const editorActionSchema = z + .object({ + id: editorExtensionIdSchema, + label: z.string().min(1).max(128), + route: z.string().min(1).max(128).regex(routeNamePattern), + placement: z.enum(["toolbar", "overflow"]), + collections: editorCollectionsSchema.optional(), + style: z.enum(["default", "danger"]).optional(), + confirm: z + .object({ + title: z.string().min(1).max(128), + text: z.string().min(1).max(1_024), + confirm: z.string().min(1).max(64), + deny: z.string().min(1).max(64), + style: z.literal("danger").optional(), + }) + .optional(), + }) + .refine((action) => action.style !== "danger" || action.confirm !== undefined, { + message: "Danger editor actions require confirmation", + path: ["confirm"], + }); + +export const manifestSchema = z + .object({ + // Core PluginManifest fields + id: z.string().min(1), + version: z.string().regex(RE_SEMVER_FULL, "Must be valid semver"), + capabilities: z.array(z.enum(PLUGIN_CAPABILITIES)), + allowedHosts: z.array(z.string()).default([]), + storage: z.record(z.string(), storageCollectionSchema).default({}), + hooks: z.array(hookEntrySchema).default([]), + routes: z.array(routeEntrySchema).default([]), + admin: z + .object({ + entry: z.string().optional(), + settingsSchema: z.record(z.string(), z.unknown()).optional(), + pages: z + .array(z.object({ path: z.string(), label: z.string(), icon: z.string().optional() })) + .optional(), + widgets: z + .array( + z.object({ + id: z.string(), + size: z.enum(["full", "half", "third"]).optional(), + title: z.string().optional(), + }), + ) + .optional(), + editorPanels: z.array(editorPanelSchema).max(32).optional(), + editorActions: z.array(editorActionSchema).max(32).optional(), + }) + .default({}), + // Marketplace publishing extras (not part of core PluginManifest) + name: z.string().min(1).max(100).optional(), + description: z.string().max(200).optional(), + minEmDashVersion: z.string().optional(), + changelog: z.string().optional(), + }) + .superRefine((manifest, ctx) => { + for (const [kind, extensions] of [ + ["editorPanels", manifest.admin.editorPanels], + ["editorActions", manifest.admin.editorActions], + ] as const) { + const seen = new Set(); + for (const [index, extension] of (extensions ?? []).entries()) { + if (seen.has(extension.id)) { + ctx.addIssue({ + code: "custom", + message: `Duplicate ${kind} id`, + path: ["admin", kind, index, "id"], + }); + } + seen.add(extension.id); + const matches = manifest.routes.filter( + (route) => (typeof route === "string" ? route : route.name) === extension.route, + ); + if ( + matches.length !== 1 || + matches.some((route) => typeof route !== "string" && route.public) + ) { + ctx.addIssue({ + code: "custom", + message: "Editor extension must reference exactly one private route", + path: ["admin", kind, index, "route"], + }); + } + } + } + }); // ── Semver comparison (simplified) ────────────────────────────── diff --git a/packages/plugin-cli/schemas/emdash-plugin.schema.json b/packages/plugin-cli/schemas/emdash-plugin.schema.json index 4cbbdb9df9..0f6f691fd1 100644 --- a/packages/plugin-cli/schemas/emdash-plugin.schema.json +++ b/packages/plugin-cli/schemas/emdash-plugin.schema.json @@ -33,34 +33,34 @@ "$ref": "#/$defs/__schema16" }, "author": { - "$ref": "#/$defs/__schema32" + "$ref": "#/$defs/__schema45" }, "authors": { - "$ref": "#/$defs/__schema37" + "$ref": "#/$defs/__schema50" }, "security": { - "$ref": "#/$defs/__schema38" + "$ref": "#/$defs/__schema51" }, "securityContacts": { - "$ref": "#/$defs/__schema42" + "$ref": "#/$defs/__schema55" }, "name": { - "$ref": "#/$defs/__schema43" + "$ref": "#/$defs/__schema56" }, "description": { - "$ref": "#/$defs/__schema44" + "$ref": "#/$defs/__schema57" }, "keywords": { - "$ref": "#/$defs/__schema45" + "$ref": "#/$defs/__schema58" }, "sections": { - "$ref": "#/$defs/__schema47" + "$ref": "#/$defs/__schema60" }, "repo": { - "$ref": "#/$defs/__schema57" + "$ref": "#/$defs/__schema70" }, "release": { - "$ref": "#/$defs/__schema58" + "$ref": "#/$defs/__schema71" } }, "required": [ @@ -251,11 +251,17 @@ }, "fieldWidgets": { "$ref": "#/$defs/__schema30" + }, + "editorPanels": { + "$ref": "#/$defs/__schema32" + }, + "editorActions": { + "$ref": "#/$defs/__schema38" } }, "additionalProperties": false, "title": "Admin surface", - "description": "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime." + "description": "Pages, widgets, and saved-entry extensions the plugin exposes in the admin UI." }, "__schema17": { "maxItems": 32, @@ -629,19 +635,178 @@ "additionalProperties": false }, "__schema32": { - "$ref": "#/$defs/__schema33" + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema33" + } }, "__schema33": { "type": "object", "properties": { - "name": { + "id": { "$ref": "#/$defs/__schema34" }, - "url": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "route": { "$ref": "#/$defs/__schema35" }, - "email": { + "collections": { "$ref": "#/$defs/__schema36" + }, + "order": { + "type": "integer", + "minimum": -1000, + "maximum": 1000 + } + }, + "required": [ + "id", + "title", + "route" + ], + "additionalProperties": false + }, + "__schema34": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z][a-z0-9_-]*$" + }, + "__schema35": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9_\\-/]*$" + }, + "__schema36": { + "maxItems": 64, + "type": "array", + "items": { + "$ref": "#/$defs/__schema37" + } + }, + "__schema37": { + "type": "string", + "maxLength": 63, + "pattern": "^[a-z][a-z0-9_]*$" + }, + "__schema38": { + "maxItems": 32, + "type": "array", + "items": { + "$ref": "#/$defs/__schema39" + } + }, + "__schema39": { + "type": "object", + "properties": { + "id": { + "$ref": "#/$defs/__schema34" + }, + "label": { + "$ref": "#/$defs/__schema40" + }, + "route": { + "$ref": "#/$defs/__schema35" + }, + "placement": { + "$ref": "#/$defs/__schema41" + }, + "collections": { + "$ref": "#/$defs/__schema42" + }, + "style": { + "$ref": "#/$defs/__schema43" + }, + "confirm": { + "$ref": "#/$defs/__schema44" + } + }, + "required": [ + "id", + "label", + "route", + "placement" + ], + "additionalProperties": false + }, + "__schema40": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "__schema41": { + "type": "string", + "enum": [ + "toolbar", + "overflow" + ] + }, + "__schema42": { + "$ref": "#/$defs/__schema36" + }, + "__schema43": { + "type": "string", + "enum": [ + "default", + "danger" + ] + }, + "__schema44": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "text": { + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "confirm": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "deny": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "style": { + "type": "string", + "const": "danger" + } + }, + "required": [ + "title", + "text", + "confirm", + "deny" + ], + "additionalProperties": false + }, + "__schema45": { + "$ref": "#/$defs/__schema46" + }, + "__schema46": { + "type": "object", + "properties": { + "name": { + "$ref": "#/$defs/__schema47" + }, + "url": { + "$ref": "#/$defs/__schema48" + }, + "email": { + "$ref": "#/$defs/__schema49" } }, "required": [ @@ -651,150 +816,150 @@ "title": "Author", "description": "A single author entry. Mirrors the lexicon's author shape." }, - "__schema34": { + "__schema47": { "type": "string", "minLength": 1, "maxLength": 256, "description": "Display name." }, - "__schema35": { + "__schema48": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Author's homepage or profile URL. Either this or `email` is recommended." }, - "__schema36": { + "__schema49": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Author's contact email. Either this or `url` is recommended." }, - "__schema37": { + "__schema50": { "minItems": 1, "maxItems": 32, "type": "array", "items": { - "$ref": "#/$defs/__schema33" + "$ref": "#/$defs/__schema46" }, "title": "Authors (multiple)", "description": "Multi-author form. Mutually exclusive with `author`. Use the singular `author` if there is only one." }, - "__schema38": { - "$ref": "#/$defs/__schema39" + "__schema51": { + "$ref": "#/$defs/__schema52" }, - "__schema39": { + "__schema52": { "type": "object", "properties": { "url": { - "$ref": "#/$defs/__schema40" + "$ref": "#/$defs/__schema53" }, "email": { - "$ref": "#/$defs/__schema41" + "$ref": "#/$defs/__schema54" } }, "additionalProperties": false, "title": "Security contact", "description": "A single security contact. At least one of `url` or `email` must be present." }, - "__schema40": { + "__schema53": { "type": "string", "maxLength": 1024, "format": "uri", "description": "Security disclosure URL (e.g. a security.txt or vulnerability-reporting page). Either this or `email` is required." }, - "__schema41": { + "__schema54": { "type": "string", "maxLength": 256, "format": "email", "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", "description": "Security contact email. Either this or `url` is required." }, - "__schema42": { + "__schema55": { "minItems": 1, "maxItems": 8, "type": "array", "items": { - "$ref": "#/$defs/__schema39" + "$ref": "#/$defs/__schema52" }, "title": "Security contacts (multiple)", "description": "Multi-contact form. Mutually exclusive with `security`. Use the singular `security` if there is only one." }, - "__schema43": { + "__schema56": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Display name", "description": "Human-readable name shown in directory listings. Defaults to the plugin's `id` when omitted." }, - "__schema44": { + "__schema57": { "type": "string", "minLength": 1, "maxLength": 1024, "title": "Description", "description": "Short description (<= 140 graphemes by FAIR convention). Aggregators may truncate longer values when displaying in compact lists." }, - "__schema45": { + "__schema58": { "maxItems": 5, "type": "array", "items": { - "$ref": "#/$defs/__schema46" + "$ref": "#/$defs/__schema59" }, "title": "Keywords", "description": "Search keywords (<= 5 entries, FAIR convention)." }, - "__schema46": { + "__schema59": { "type": "string", "minLength": 1, "maxLength": 128 }, - "__schema47": { + "__schema60": { "type": "object", "properties": { "description": { - "$ref": "#/$defs/__schema48" + "$ref": "#/$defs/__schema61" }, "installation": { - "$ref": "#/$defs/__schema53" + "$ref": "#/$defs/__schema66" }, "faq": { - "$ref": "#/$defs/__schema54" + "$ref": "#/$defs/__schema67" }, "changelog": { - "$ref": "#/$defs/__schema55" + "$ref": "#/$defs/__schema68" }, "security": { - "$ref": "#/$defs/__schema56" + "$ref": "#/$defs/__schema69" } }, "additionalProperties": false, "title": "Profile sections", "description": "Long-form package documentation (description / installation / faq / changelog / security). Each value is CommonMark Markdown (inline or a `{ file }` ref) capped at 20000 bytes / 2000 graphemes. Rendered on the package's registry page." }, - "__schema48": { - "$ref": "#/$defs/__schema49" + "__schema61": { + "$ref": "#/$defs/__schema62" }, - "__schema49": { + "__schema62": { "anyOf": [ { - "$ref": "#/$defs/__schema50" + "$ref": "#/$defs/__schema63" }, { - "$ref": "#/$defs/__schema51" + "$ref": "#/$defs/__schema64" } ], "title": "Section value", "description": "Either inline CommonMark Markdown or a `{ file }` reference to a sibling Markdown file." }, - "__schema50": { + "__schema63": { "type": "string", "description": "Inline CommonMark Markdown for this section. Capped at 20000 bytes / 2000 graphemes." }, - "__schema51": { + "__schema64": { "type": "object", "properties": { "file": { - "$ref": "#/$defs/__schema52" + "$ref": "#/$defs/__schema65" } }, "required": [ @@ -804,25 +969,25 @@ "title": "Section file reference", "description": "Loads the section's Markdown from a sibling file instead of inlining it in the manifest." }, - "__schema52": { + "__schema65": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Path to a CommonMark Markdown file, relative to the manifest. Its content is read inline into the section at publish time." }, - "__schema53": { - "$ref": "#/$defs/__schema49" + "__schema66": { + "$ref": "#/$defs/__schema62" }, - "__schema54": { - "$ref": "#/$defs/__schema49" + "__schema67": { + "$ref": "#/$defs/__schema62" }, - "__schema55": { - "$ref": "#/$defs/__schema49" + "__schema68": { + "$ref": "#/$defs/__schema62" }, - "__schema56": { - "$ref": "#/$defs/__schema49" + "__schema69": { + "$ref": "#/$defs/__schema62" }, - "__schema57": { + "__schema70": { "type": "string", "maxLength": 1024, "format": "uri", @@ -833,27 +998,27 @@ "https://github.com/emdash-cms/plugin-gallery" ] }, - "__schema58": { + "__schema71": { "type": "object", "properties": { "requires": { - "$ref": "#/$defs/__schema59" + "$ref": "#/$defs/__schema72" }, "artifacts": { - "$ref": "#/$defs/__schema62" + "$ref": "#/$defs/__schema75" } }, "additionalProperties": false, "title": "Release", "description": "Per-release fields: environment constraints (`requires`) and media artifacts (icon / screenshot / banner)." }, - "__schema59": { + "__schema72": { "type": "object", "propertyNames": { - "$ref": "#/$defs/__schema60" + "$ref": "#/$defs/__schema73" }, "additionalProperties": { - "$ref": "#/$defs/__schema61" + "$ref": "#/$defs/__schema74" }, "title": "Environment requirements", "description": "Host environment constraints for this release, keyed by `env:*` (e.g. \"env:astro\", \"env:emdash\") with semver-range values. EmDash refuses to install a release whose constraints the host does not satisfy.", @@ -864,41 +1029,41 @@ } ] }, - "__schema60": { + "__schema73": { "type": "string" }, - "__schema61": { + "__schema74": { "type": "string", "minLength": 1 }, - "__schema62": { + "__schema75": { "type": "object", "properties": { "icon": { - "$ref": "#/$defs/__schema63" + "$ref": "#/$defs/__schema76" }, "banner": { - "$ref": "#/$defs/__schema67" + "$ref": "#/$defs/__schema80" }, "screenshots": { - "$ref": "#/$defs/__schema68" + "$ref": "#/$defs/__schema81" } }, "additionalProperties": false, "title": "Artifacts", "description": "Release media artifacts. `icon` and `banner` are single images; `screenshots` is a gallery array." }, - "__schema63": { - "$ref": "#/$defs/__schema64" + "__schema76": { + "$ref": "#/$defs/__schema77" }, - "__schema64": { + "__schema77": { "type": "object", "properties": { "file": { - "$ref": "#/$defs/__schema65" + "$ref": "#/$defs/__schema78" }, "lang": { - "$ref": "#/$defs/__schema66" + "$ref": "#/$defs/__schema79" } }, "required": [ @@ -908,13 +1073,13 @@ "title": "Artifact file reference", "description": "A PNG, JPEG, or WebP file published as an icon, screenshot, or banner." }, - "__schema65": { + "__schema78": { "type": "string", "minLength": 1, "maxLength": 1024, "description": "Path to the image file, relative to the manifest. Resolved, hashed, measured, and uploaded at publish time." }, - "__schema66": { + "__schema79": { "type": "string", "minLength": 2, "maxLength": 64, @@ -924,15 +1089,15 @@ "pt-BR" ] }, - "__schema67": { - "$ref": "#/$defs/__schema64" + "__schema80": { + "$ref": "#/$defs/__schema77" }, - "__schema68": { + "__schema81": { "minItems": 1, "maxItems": 8, "type": "array", "items": { - "$ref": "#/$defs/__schema64" + "$ref": "#/$defs/__schema77" }, "title": "Screenshots", "description": "Screenshot gallery for the plugin's detail page (<= 8 entries)." diff --git a/packages/plugin-cli/src/api.ts b/packages/plugin-cli/src/api.ts index bd232f8457..06fd1e76d1 100644 --- a/packages/plugin-cli/src/api.ts +++ b/packages/plugin-cli/src/api.ts @@ -72,6 +72,8 @@ export { type ManifestHookEntry, type ManifestRouteEntry, type PluginAdminConfig, + type PluginEditorPanel, + type PluginEditorAction, type PluginCapability, type PluginManifest, type PluginStorageConfig, diff --git a/packages/plugin-cli/src/build/api.ts b/packages/plugin-cli/src/build/api.ts index fd29abb546..403027b19f 100644 --- a/packages/plugin-cli/src/build/api.ts +++ b/packages/plugin-cli/src/build/api.ts @@ -290,6 +290,10 @@ async function writeDescriptor(ctx: WriteDescriptorContext): Promise new Set(collections).size === collections.length, { + message: "editor extension collections must be unique", + }); + +export const EditorPanelSchema = z + .object({ + id: editorExtensionIdSchema, + title: z.string().min(1).max(128), + route: editorRouteSchema, + collections: editorCollectionsSchema.optional(), + order: z.number().int().min(-1_000).max(1_000).optional(), + }) + .strict(); + +const EditorActionConfirmSchema = z + .object({ + title: z.string().min(1).max(128), + text: z.string().min(1).max(1_024), + confirm: z.string().min(1).max(64), + deny: z.string().min(1).max(64), + style: z.literal("danger").optional(), + }) + .strict(); + +export const EditorActionSchema = z + .object({ + id: editorExtensionIdSchema, + label: z.string().min(1).max(128), + route: editorRouteSchema, + placement: z.enum(["toolbar", "overflow"]), + collections: editorCollectionsSchema.optional(), + style: z.enum(["default", "danger"]).optional(), + confirm: EditorActionConfirmSchema.optional(), + }) + .strict() + .refine((action) => action.style !== "danger" || action.confirm !== undefined, { + message: "danger editor actions require confirmation", + path: ["confirm"], + }); + +function addDuplicateEditorExtensionIssues( + items: readonly { id: string }[] | undefined, + ctx: z.RefinementCtx, + path: "editorPanels" | "editorActions", +): void { + const seen = new Set(); + for (const [index, item] of (items ?? []).entries()) { + if (seen.has(item.id)) { + ctx.addIssue({ code: "custom", message: `duplicate ${path} id`, path: [path, index, "id"] }); + } + seen.add(item.id); + } +} + const settingBase = { label: z.string().min(1), description: z.string().optional(), @@ -647,12 +719,17 @@ export const AdminSchema = z .optional(), settingsSchema: z.record(z.string(), SettingFieldSchema).optional(), fieldWidgets: z.array(FieldWidgetSchema).max(32).optional(), + editorPanels: z.array(EditorPanelSchema).max(32).optional(), + editorActions: z.array(EditorActionSchema).max(32).optional(), }) .strict() + .superRefine((admin, ctx) => { + addDuplicateEditorExtensionIssues(admin.editorPanels, ctx, "editorPanels"); + addDuplicateEditorExtensionIssues(admin.editorActions, ctx, "editorActions"); + }) .meta({ title: "Admin surface", - description: - "Pages and widgets the plugin exposes in the admin UI. The plugin's `admin` route handler renders Block Kit content for each path / widget id at runtime.", + description: "Pages, widgets, and saved-entry extensions the plugin exposes in the admin UI.", }); // ────────────────────────────────────────────────────────────────────────── diff --git a/packages/plugin-cli/src/manifest/translate.ts b/packages/plugin-cli/src/manifest/translate.ts index c06da93fca..d5634fed29 100644 --- a/packages/plugin-cli/src/manifest/translate.ts +++ b/packages/plugin-cli/src/manifest/translate.ts @@ -43,6 +43,8 @@ export interface NormalisedAdmin { widgets: Array<{ id: string; title?: string; size?: "full" | "half" | "third" }>; settingsSchema?: PluginAdminConfig["settingsSchema"]; fieldWidgets?: PluginAdminConfig["fieldWidgets"]; + editorPanels?: PluginAdminConfig["editorPanels"]; + editorActions?: PluginAdminConfig["editorActions"]; } export interface NormalisedManifest { @@ -315,6 +317,8 @@ export function normaliseManifest(manifest: Manifest, packageVersion?: string): widgets: manifest.admin?.widgets ?? [], settingsSchema: manifest.admin?.settingsSchema, fieldWidgets: manifest.admin?.fieldWidgets, + editorPanels: manifest.admin?.editorPanels, + editorActions: manifest.admin?.editorActions, }, }; } diff --git a/packages/plugin-cli/tests/build-metadata-roundtrip.test.ts b/packages/plugin-cli/tests/build-metadata-roundtrip.test.ts index 909fd62424..b14a81ea64 100644 --- a/packages/plugin-cli/tests/build-metadata-roundtrip.test.ts +++ b/packages/plugin-cli/tests/build-metadata-roundtrip.test.ts @@ -32,6 +32,17 @@ describe("plugin build metadata round trip", () => { security: { email: "security@example.com" }, capabilities: ["content:read", "redirects:write"], admin: { + editorPanels: [ + { id: "health", title: "Health", route: "entry-health", collections: ["events"] }, + ], + editorActions: [ + { + id: "repair", + label: "Repair", + route: "entry-repair", + placement: "overflow", + }, + ], settingsSchema: { enabled: { type: "boolean", label: "Enabled", default: true }, apiKey: { type: "secret", label: "API key" }, @@ -51,9 +62,11 @@ describe("plugin build metadata round trip", () => { join(dir, "src/plugin.ts"), `export default { hooks: { "content:afterSave": async () => undefined }, - routes: { - feed: { public: true, cacheControl: "public, max-age=60", handler: async () => [] }, - manage: { permission: "content:edit_any", handler: async () => ({ ok: true }) } + routes: { + feed: { public: true, cacheControl: "public, max-age=60", handler: async () => [] }, + manage: { permission: "content:edit_any", handler: async () => ({ ok: true }) }, + "entry-health": { permission: "content:edit_own", handler: async () => ({ blocks: [] }) }, + "entry-repair": { permission: "content:edit_own", handler: async () => ({ refresh: true }) } }, mcp: { tools: { manageCalendar: { description: "Manage the calendar.", route: "manage", @@ -90,6 +103,14 @@ describe("plugin build metadata round trip", () => { "redirects:read", "redirects:write", ]); + expect(persistedManifest.admin.editorPanels[0]).toMatchObject({ + id: "health", + route: "entry-health", + }); + expect(persistedManifest.admin.editorActions[0]).toMatchObject({ + id: "repair", + route: "entry-repair", + }); expect(descriptor).toMatchObject({ capabilities: persistedManifest.capabilities, hooks: ["content:afterSave"], @@ -97,6 +118,8 @@ describe("plugin build metadata round trip", () => { mcp: persistedManifest.mcp, settingsSchema: persistedManifest.admin.settingsSchema, fieldWidgets: persistedManifest.admin.fieldWidgets, + editorPanels: persistedManifest.admin.editorPanels, + editorActions: persistedManifest.admin.editorActions, }); }); }); diff --git a/packages/plugin-cli/tests/bundle-utils.test.ts b/packages/plugin-cli/tests/bundle-utils.test.ts index 4fb683716e..cfeea25bbc 100644 --- a/packages/plugin-cli/tests/bundle-utils.test.ts +++ b/packages/plugin-cli/tests/bundle-utils.test.ts @@ -161,6 +161,15 @@ describe("extractManifest", () => { const manifest = extractManifest( minimalResolved({ admin: { + editorPanels: [{ id: "health", title: "Health", route: "entry-health" }], + editorActions: [ + { + id: "repair", + label: "Repair", + route: "entry-repair", + placement: "toolbar", + }, + ], settingsSchema: { enabled: { type: "boolean", label: "Enabled", default: true }, }, @@ -178,6 +187,8 @@ describe("extractManifest", () => { expect(manifest.admin.settingsSchema).toHaveProperty("enabled"); expect(manifest.admin.fieldWidgets?.[0]).toMatchObject({ name: "event-picker" }); + expect(manifest.admin.editorPanels?.[0]).toMatchObject({ id: "health" }); + expect(manifest.admin.editorActions?.[0]).toMatchObject({ id: "repair" }); }); }); diff --git a/packages/plugin-cli/tests/init-templates.test.ts b/packages/plugin-cli/tests/init-templates.test.ts index 82a7a92f09..46d4fb7430 100644 --- a/packages/plugin-cli/tests/init-templates.test.ts +++ b/packages/plugin-cli/tests/init-templates.test.ts @@ -344,6 +344,8 @@ describe("agent guidance", () => { expect(skill).toContain("hooks.content-policy:register"); expect(skill).toContain("this capability does not grant content reads"); expect(skill).toContain("structured Block Kit links"); + expect(skill).toContain("admin.editorPanels"); + expect(skill).toContain("saved-entry panels"); expect(skill).toContain("routeCtx.ui"); expect(skill).toContain("`admin` helpers"); expect(skill).toContain("Node/workerd parity opt-in"); diff --git a/packages/plugin-cli/tests/manifest-schema.test.ts b/packages/plugin-cli/tests/manifest-schema.test.ts index b185189e4c..654674ceef 100644 --- a/packages/plugin-cli/tests/manifest-schema.test.ts +++ b/packages/plugin-cli/tests/manifest-schema.test.ts @@ -46,6 +46,66 @@ describe("AdminSchema", () => { expect(AdminSchema.parse(admin)).toEqual(admin); }); + + it("accepts bounded saved-entry panels and confirmed actions", () => { + const admin = { + editorPanels: [ + { id: "health", title: "Health", route: "entry/health", collections: ["posts"] }, + ], + editorActions: [ + { + id: "repair", + label: "Repair", + route: "entry/repair", + placement: "overflow", + style: "danger", + confirm: { title: "Repair?", text: "Change entry", confirm: "Repair", deny: "Cancel" }, + }, + ], + }; + expect(AdminSchema.parse(admin)).toEqual(admin); + }); + + it.each([ + [ + "duplicate panel ids", + { + editorPanels: [ + { id: "health", title: "Health", route: "health" }, + { id: "health", title: "Other", route: "other" }, + ], + }, + ], + [ + "danger action without confirmation", + { + editorActions: [ + { + id: "repair", + label: "Repair", + route: "repair", + placement: "toolbar", + style: "danger", + }, + ], + }, + ], + [ + "duplicate collection filters", + { + editorPanels: [ + { + id: "health", + title: "Health", + route: "health", + collections: ["posts", "posts"], + }, + ], + }, + ], + ])("rejects %s", (_label, admin) => { + expect(AdminSchema.safeParse(admin).success).toBe(false); + }); }); describe("LicenseSchema", () => { diff --git a/packages/plugin-cli/tests/probe-schema.test.ts b/packages/plugin-cli/tests/probe-schema.test.ts index 700ecde34b..877b8384a8 100644 --- a/packages/plugin-cli/tests/probe-schema.test.ts +++ b/packages/plugin-cli/tests/probe-schema.test.ts @@ -9,7 +9,12 @@ import { describe, expect, it } from "vitest"; -import { BuildPipelineError, parseProbedDefault } from "../src/build/pipeline.js"; +import { + BuildPipelineError, + parseProbedDefault, + validateEditorExtensionRoutes, +} from "../src/build/pipeline.js"; +import type { ResolvedPlugin } from "../src/bundle/types.js"; const PLUGIN_ENTRY = "src/plugin.ts"; @@ -330,3 +335,33 @@ describe("parseProbedDefault", () => { }); }); }); + +describe("validateEditorExtensionRoutes", () => { + function pluginWithRoute(route: ResolvedPlugin["routes"][string] | undefined): ResolvedPlugin { + return { + id: "content-guard", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: {}, + routes: route ? { health: route } : {}, + admin: { + editorPanels: [{ id: "health", title: "Health", route: "health" }], + }, + }; + } + + it("accepts an extension backed by a private route", () => { + expect(() => + validateEditorExtensionRoutes(pluginWithRoute({ handler: () => undefined })), + ).not.toThrow(); + }); + + it.each([ + ["missing", undefined], + ["public", { public: true, handler: () => undefined }], + ])("rejects a %s extension route", (_label, route) => { + expect(() => validateEditorExtensionRoutes(pluginWithRoute(route))).toThrow(BuildPipelineError); + }); +}); diff --git a/packages/plugin-test/src/runtime-host.ts b/packages/plugin-test/src/runtime-host.ts index 7df29b6e29..692d0d55d1 100644 --- a/packages/plugin-test/src/runtime-host.ts +++ b/packages/plugin-test/src/runtime-host.ts @@ -1,4 +1,9 @@ -import type { BlockInteraction, BlockResponse } from "@emdash-cms/blocks/server"; +import type { + BlockInteraction, + BlockResponse, + ContentEditorActionResponse, + ContentEditorPanelInteraction, +} from "@emdash-cms/blocks/server"; import { createDialect } from "@emdash-cms/cloudflare/db/d1"; import { CloudflareSandboxRunner } from "@emdash-cms/cloudflare/sandbox"; import { pluginManifestSchema, reconcileManifestAccess } from "@emdash-cms/plugin-types"; @@ -30,6 +35,7 @@ import { runMigrations } from "emdash/db"; import { BylineRepository, dispatchPluginApiRequest, + dispatchPluginEditorExtensionApiRequest, EmDashRuntime, getI18nConfig, handlePluginSettingsUpdate, @@ -82,6 +88,7 @@ export interface PluginRuntimeMediaFixture { export interface PluginRuntimeAdminRequestOptions { locale?: string; + contentLocale?: string; user?: UserInfo; } @@ -105,6 +112,33 @@ export interface PluginRuntimeTestHost { values: Record, options?: PluginRuntimeAdminRequestOptions & { blockId?: string }, ): Promise; + loadEditorPanel( + panelId: string, + collection: string, + entryId: string, + options?: PluginRuntimeAdminRequestOptions, + ): Promise; + actEditorPanel( + panelId: string, + collection: string, + entryId: string, + actionId: string, + options?: PluginRuntimeAdminRequestOptions & { blockId?: string; value?: unknown }, + ): Promise; + submitEditorPanel( + panelId: string, + collection: string, + entryId: string, + actionId: string, + values: Record, + options?: PluginRuntimeAdminRequestOptions & { blockId?: string }, + ): Promise; + invokeEditorAction( + actionId: string, + collection: string, + entryId: string, + options?: PluginRuntimeAdminRequestOptions, + ): Promise; }; fixtures: { site(input: { @@ -389,6 +423,8 @@ export async function createPluginRuntimeTestHost( fieldWidgets: manifest.admin.fieldWidgets, adminPages: manifest.admin.pages, adminWidgets: manifest.admin.widgets, + editorPanels: manifest.admin.editorPanels, + editorActions: manifest.admin.editorActions, }; const sandboxedPluginEntries = [entry]; const emailTransport = definePlugin({ @@ -503,6 +539,48 @@ export async function createPluginRuntimeTestHost( // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- the production route validates BlockResponse before returning a successful envelope return body.data as BlockResponse; }; + const invokeEditorExtension = async ( + kind: "panel" | "action", + extensionId: string, + collection: string, + entryId: string, + input: ContentEditorPanelInteraction | Record, + adminOptions: PluginRuntimeAdminRequestOptions = {}, + ): Promise => { + assertActive(); + const headers = new Headers({ + "Content-Type": "application/json", + "X-EmDash-Request": "1", + }); + if (adminOptions.locale) headers.set("Cookie", `emdash-locale=${adminOptions.locale}`); + const localeSearch = adminOptions.contentLocale + ? `?locale=${encodeURIComponent(adminOptions.contentLocale)}` + : ""; + const response = await dispatchPluginEditorExtensionApiRequest({ + runtime, + pluginId: manifest.id, + kind, + extensionId, + collection, + entryId, + request: new Request( + `https://plugin.test/_emdash/api/content/${collection}/${entryId}/plugin-extensions/${manifest.id}/${kind}/${extensionId}${localeSearch}`, + { method: "POST", headers, body: JSON.stringify(input) }, + ), + user: adminOptions.user ?? (await getAdminUser()), + }); + if (!response.ok) { + throw new Error( + `Plugin editor ${kind} request failed (${response.status}): ${await response.text()}`, + ); + } + const body: unknown = await response.json(); + if (typeof body !== "object" || body === null || !("data" in body)) { + throw new Error("Plugin editor extension response did not contain data"); + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- production dispatch validates the response before returning success + return body.data as T; + }; const host: PluginRuntimeTestHost = { get manifest() { return manifest; @@ -555,6 +633,52 @@ export async function createPluginRuntimeTestHost( }, adminOptions, ), + loadEditorPanel: (panelId, collection, entryId, adminOptions) => + invokeEditorExtension( + "panel", + panelId, + collection, + entryId, + { type: "panel_load" }, + adminOptions, + ), + actEditorPanel: (panelId, collection, entryId, actionId, adminOptions = {}) => + invokeEditorExtension( + "panel", + panelId, + collection, + entryId, + { + type: "block_action", + action_id: actionId, + ...(adminOptions.blockId !== undefined && { block_id: adminOptions.blockId }), + ...(adminOptions.value !== undefined && { value: adminOptions.value }), + }, + adminOptions, + ), + submitEditorPanel: (panelId, collection, entryId, actionId, values, adminOptions = {}) => + invokeEditorExtension( + "panel", + panelId, + collection, + entryId, + { + type: "form_submit", + action_id: actionId, + values, + ...(adminOptions.blockId !== undefined && { block_id: adminOptions.blockId }), + }, + adminOptions, + ), + invokeEditorAction: (actionId, collection, entryId, adminOptions) => + invokeEditorExtension( + "action", + actionId, + collection, + entryId, + {}, + adminOptions, + ), }, fixtures: { async site(input) { diff --git a/packages/plugin-test/test/fixture/emdash-plugin.jsonc b/packages/plugin-test/test/fixture/emdash-plugin.jsonc index 86822f7ea1..1c954aade3 100644 --- a/packages/plugin-test/test/fixture/emdash-plugin.jsonc +++ b/packages/plugin-test/test/fixture/emdash-plugin.jsonc @@ -31,6 +31,39 @@ "admin": { "pages": [{ "path": "/overview", "label": "Overview" }], "widgets": [{ "id": "status", "title": "Status", "size": "half" }], + "editorPanels": [ + { + "id": "entry-context", + "title": "Entry context", + "route": "entry-context", + "collections": ["posts"], + "order": 10, + }, + ], + "editorActions": [ + { + "id": "refresh-entry", + "label": "Refresh entry", + "route": "refresh-entry", + "placement": "overflow", + "collections": ["posts"], + "style": "danger", + "confirm": { + "title": "Refresh entry?", + "text": "The saved entry will be reloaded.", + "confirm": "Refresh", + "deny": "Cancel", + "style": "danger", + }, + }, + { + "id": "invalid-action", + "label": "Invalid action", + "route": "invalid-action", + "placement": "overflow", + "collections": ["posts"], + }, + ], "settingsSchema": { "enabled": { "type": "boolean", "label": "Enabled", "default": true }, "apiKey": { "type": "secret", "label": "API key" }, diff --git a/packages/plugin-test/test/fixture/src/plugin.ts b/packages/plugin-test/test/fixture/src/plugin.ts index 3fda0aae45..167d26e414 100644 --- a/packages/plugin-test/test/fixture/src/plugin.ts +++ b/packages/plugin-test/test/fixture/src/plugin.ts @@ -302,6 +302,56 @@ const plugin: SandboxedPlugin = { }; }, }, + "entry-context": { + permission: "content:edit_own", + handler: async (route) => { + if (route.ui?.surface !== "content-editor-panel") { + throw new Error("Expected editor panel context"); + } + if ( + typeof route.input === "object" && + route.input !== null && + "action_id" in route.input && + route.input.action_id === "invalid" + ) { + return { blocks: [{ type: "unknown" }] }; + } + return { + blocks: [ + { + type: "fields", + fields: [ + { label: "Surface", value: route.ui.surface }, + { label: "Extension", value: route.ui.extensionId }, + { label: "Collection", value: route.ui.entry.collection }, + { label: "Entry", value: route.ui.entry.id }, + { label: "Content locale", value: route.ui.entry.locale ?? "missing" }, + { label: "Version", value: String(route.ui.entry.version) }, + ], + }, + ], + }; + }, + }, + "refresh-entry": { + permission: "content:edit_own", + handler: async (route) => { + if (route.ui?.surface !== "content-editor-action") { + throw new Error("Expected editor action context"); + } + return { + refresh: true, + toast: { + type: "success", + message: `${route.ui.entry.collection}/${route.ui.entry.id} refreshed`, + }, + }; + }, + }, + "invalid-action": { + permission: "content:edit_own", + handler: async () => ({ refresh: true, navigate: { kind: "plugin-settings" } }), + }, "isolate-id": { public: true, cacheControl: "public, max-age=60", diff --git a/packages/plugin-test/test/host.test.ts b/packages/plugin-test/test/host.test.ts index 6d619262d2..46c18d35dd 100644 --- a/packages/plugin-test/test/host.test.ts +++ b/packages/plugin-test/test/host.test.ts @@ -1301,6 +1301,99 @@ describe("runtime plugin test host", () => { "INVALID_BLOCK_RESPONSE", ); }); + + it("invokes saved-entry panels and actions with host-attested identity", async () => { + runtimeHost = await createPluginRuntimeTestHost({ + i18n: { defaultLocale: "en", locales: ["en", "ar"] }, + }); + await runtimeHost.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + const entry = await runtimeHost.fixtures.content("posts", { + data: { title: "Saved entry" }, + locale: "en", + }); + + const panel = await runtimeHost.admin.loadEditorPanel("entry-context", "posts", entry.id, { + locale: "ar", + contentLocale: "en", + }); + expect(panel.blocks[0]).toMatchObject({ + type: "fields", + fields: [ + { label: "Surface", value: "content-editor-panel" }, + { label: "Extension", value: "entry-context" }, + { label: "Collection", value: "posts" }, + { label: "Entry", value: entry.id }, + { label: "Content locale", value: "en" }, + { label: "Version", value: expect.any(String) }, + ], + }); + await runtimeHost.actions.content.update("posts", entry.id, { + data: { title: "Updated entry" }, + locale: "en", + }); + const refreshedPanel = await runtimeHost.admin.loadEditorPanel( + "entry-context", + "posts", + entry.id, + { locale: "ar", contentLocale: "en" }, + ); + expect(refreshedPanel.blocks[0]).toMatchObject({ + type: "fields", + fields: expect.arrayContaining([{ label: "Version", value: "2" }]), + }); + + await expect( + runtimeHost.admin.invokeEditorAction("refresh-entry", "posts", entry.id, { + locale: "ar", + contentLocale: "en", + }), + ).resolves.toEqual({ + refresh: true, + toast: { type: "success", message: `posts/${entry.id} refreshed` }, + }); + await expect( + runtimeHost.admin.actEditorPanel("entry-context", "posts", entry.id, "invalid"), + ).rejects.toThrow("INVALID_BLOCK_RESPONSE"); + await expect( + runtimeHost.admin.invokeEditorAction("invalid-action", "posts", entry.id), + ).rejects.toThrow("INVALID_EDITOR_ACTION_RESPONSE"); + }); + + it("authorizes editor extensions against the saved entry owner", async () => { + runtimeHost = await createPluginRuntimeTestHost(); + await runtimeHost.fixtures.collection({ + slug: "posts", + label: "Posts", + fields: [{ slug: "title", label: "Title", type: "string" }], + }); + const [owner, otherAuthor] = await Promise.all([ + runtimeHost.fixtures.user({ email: "owner@example.test", role: "author" }), + runtimeHost.fixtures.user({ email: "other@example.test", role: "author" }), + ]); + const entry = await runtimeHost.fixtures.content("posts", { + data: { title: "Owned entry" }, + authorId: owner.id, + }); + + await expect( + runtimeHost.admin.loadEditorPanel("entry-context", "posts", entry.id, { user: owner }), + ).resolves.toHaveProperty("blocks"); + await expect( + runtimeHost.admin.loadEditorPanel("entry-context", "posts", entry.id, { + user: otherAuthor, + }), + ).rejects.toThrow("(403)"); + await expect( + runtimeHost.admin.loadEditorPanel("missing", "posts", entry.id, { user: owner }), + ).rejects.toThrow("(404)"); + await expect( + runtimeHost.admin.loadEditorPanel("entry-context", "pages", entry.id, { user: owner }), + ).rejects.toThrow("(404)"); + }); }); describe("plugin test host", () => { @@ -1317,6 +1410,14 @@ describe("plugin test host", () => { }); expect(host.manifest.admin.settingsSchema).toHaveProperty("enabled"); expect(host.manifest.admin.fieldWidgets?.[0]).toMatchObject({ name: "event-picker" }); + expect(host.manifest.admin.editorPanels?.[0]).toMatchObject({ + id: "entry-context", + route: "entry-context", + }); + expect(host.manifest.admin.editorActions?.[0]).toMatchObject({ + id: "refresh-entry", + route: "refresh-entry", + }); await expect(host.invokeRoute("hello")).resolves.toEqual({ pluginId: "plugin-test-fixture", diff --git a/packages/plugin-types/src/index.ts b/packages/plugin-types/src/index.ts index 72b7778ce3..57b11f7c32 100644 --- a/packages/plugin-types/src/index.ts +++ b/packages/plugin-types/src/index.ts @@ -453,6 +453,32 @@ export interface StorageCollectionConfig { */ export type PluginStorageConfig = Record; +export interface PluginEditorPanel { + id: string; + title: string; + route: string; + collections?: string[]; + order?: number; +} + +export interface PluginEditorActionConfirm { + title: string; + text: string; + confirm: string; + deny: string; + style?: "danger"; +} + +export interface PluginEditorAction { + id: string; + label: string; + route: string; + placement: "toolbar" | "overflow"; + collections?: string[]; + style?: "default" | "danger"; + confirm?: PluginEditorActionConfirm; +} + /** * Plugin admin surface in the manifest. Sandboxed plugins MUST NOT set the * `entry` field (that requires native/trusted mode); the bundler validates @@ -465,6 +491,10 @@ export interface PluginAdminConfig { pages?: Array; /** Dashboard widgets declared by the plugin. */ widgets?: Array; + /** Saved-entry Block Kit panels declared by the plugin. */ + editorPanels?: PluginEditorPanel[]; + /** Saved-entry host-rendered actions declared by the plugin. */ + editorActions?: PluginEditorAction[]; /** * Native-only: a module specifier for a React entry. Sandboxed plugins * MUST NOT set this; the bundler validates the absence and the publish diff --git a/packages/plugin-types/src/manifest-schema.ts b/packages/plugin-types/src/manifest-schema.ts index 7b939cce44..67bc753950 100644 --- a/packages/plugin-types/src/manifest-schema.ts +++ b/packages/plugin-types/src/manifest-schema.ts @@ -234,32 +234,98 @@ const dashboardWidgetSchema = z.object({ title: z.string().optional(), }); -const pluginAdminConfigSchema = z.object({ - entry: z.string().optional(), - settingsSchema: z.record(z.string(), settingFieldSchema).optional(), - pages: z.array(adminPageSchema).optional(), - widgets: z.array(dashboardWidgetSchema).optional(), - fieldWidgets: z - .array( - z.object({ - name: z.string().min(1), - label: z.string().min(1), - fieldTypes: z.array(z.enum(FIELD_TYPES)), - elements: z - .array( - z - .object({ - type: z.string(), - action_id: z.string(), - label: z.string().optional(), - }) - .loose(), - ) - .optional(), - }), - ) - .optional(), +const editorExtensionIdPattern = /^[a-z][a-z0-9_-]*$/; +const collectionSlugPattern = /^[a-z][a-z0-9_]*$/; +const editorCollectionsSchema = z + .array(z.string().max(63).regex(collectionSlugPattern, "Invalid collection slug")) + .max(64) + .refine((collections) => new Set(collections).size === collections.length, { + message: "Editor extension collections must be unique", + }); +const editorPanelSchema = z.object({ + id: z.string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor panel id"), + title: z.string().min(1).max(128), + route: z + .string() + .min(1) + .max(128) + .regex(routeNamePattern, "Route name must be a safe path segment"), + collections: editorCollectionsSchema.optional(), + order: z.number().int().min(-1_000).max(1_000).optional(), +}); +const editorActionConfirmSchema = z.object({ + title: z.string().min(1).max(128), + text: z.string().min(1).max(1_024), + confirm: z.string().min(1).max(64), + deny: z.string().min(1).max(64), + style: z.literal("danger").optional(), }); +const editorActionSchema = z + .object({ + id: z.string().min(1).max(64).regex(editorExtensionIdPattern, "Invalid editor action id"), + label: z.string().min(1).max(128), + route: z + .string() + .min(1) + .max(128) + .regex(routeNamePattern, "Route name must be a safe path segment"), + placement: z.enum(["toolbar", "overflow"]), + collections: editorCollectionsSchema.optional(), + style: z.enum(["default", "danger"]).optional(), + confirm: editorActionConfirmSchema.optional(), + }) + .refine((action) => action.style !== "danger" || action.confirm !== undefined, { + message: "Danger editor actions require confirmation", + path: ["confirm"], + }); + +function uniqueExtensionIds( + items: readonly { id: string }[] | undefined, + ctx: z.RefinementCtx, + path: "editorPanels" | "editorActions", +): void { + const seen = new Set(); + for (const [index, item] of (items ?? []).entries()) { + if (seen.has(item.id)) { + ctx.addIssue({ code: "custom", message: `Duplicate ${path} id`, path: [path, index, "id"] }); + } + seen.add(item.id); + } +} + +const pluginAdminConfigSchema = z + .object({ + entry: z.string().optional(), + settingsSchema: z.record(z.string(), settingFieldSchema).optional(), + pages: z.array(adminPageSchema).optional(), + widgets: z.array(dashboardWidgetSchema).optional(), + editorPanels: z.array(editorPanelSchema).max(32).optional(), + editorActions: z.array(editorActionSchema).max(32).optional(), + fieldWidgets: z + .array( + z.object({ + name: z.string().min(1), + label: z.string().min(1), + fieldTypes: z.array(z.enum(FIELD_TYPES)), + elements: z + .array( + z + .object({ + type: z.string(), + action_id: z.string(), + label: z.string().optional(), + }) + .loose(), + ) + .optional(), + }), + ) + .optional(), + }) + .superRefine((admin, ctx) => { + uniqueExtensionIds(admin.editorPanels, ctx, "editorPanels"); + uniqueExtensionIds(admin.editorActions, ctx, "editorActions"); + }); // ── declaredAccess ────────────────────────────────────────────── @@ -336,7 +402,7 @@ const declaredAccessSchema = z.object({ * to make them consistent (declaredAccess authoritative when present). Kept a * plain object (no `.transform`) because callers `.pick()`/`.extend()` it. */ -export const pluginManifestSchema = z.object({ +export const pluginManifestBaseSchema = z.object({ id: z.string().min(1), version: z.string().min(1), declaredAccess: declaredAccessSchema.optional(), @@ -364,6 +430,46 @@ export const pluginManifestSchema = z.object({ admin: pluginAdminConfigSchema, }); +function validateEditorExtensionRoutes( + manifest: z.infer, + ctx: z.RefinementCtx, +): void { + for (const [kind, extensions] of [ + ["editorPanels", manifest.admin.editorPanels], + ["editorActions", manifest.admin.editorActions], + ] as const) { + for (const [index, extension] of (extensions ?? []).entries()) { + const matches = manifest.routes.filter( + (route) => (typeof route === "string" ? route : route.name) === extension.route, + ); + if (matches.length !== 1) { + ctx.addIssue({ + code: "custom", + message: + matches.length === 0 + ? "Editor extension route is not declared" + : "Editor extension route must be declared exactly once", + path: ["admin", kind, index, "route"], + }); + continue; + } + const route = matches[0]; + if (!route) continue; + if (typeof route !== "string" && route.public === true) { + ctx.addIssue({ + code: "custom", + message: "Editor extension routes must be private", + path: ["admin", kind, index, "route"], + }); + } + } + } +} + +export const pluginManifestSchema = pluginManifestBaseSchema.superRefine( + validateEditorExtensionRoutes, +); + export type ValidatedPluginManifest = z.infer; /** diff --git a/packages/plugin-types/tests/manifest-schema.test.ts b/packages/plugin-types/tests/manifest-schema.test.ts index 42ea66afc4..874e4f39da 100644 --- a/packages/plugin-types/tests/manifest-schema.test.ts +++ b/packages/plugin-types/tests/manifest-schema.test.ts @@ -76,6 +76,8 @@ describe("pluginManifestSchema", () => { permission: "content:read", cacheControl: "public, max-age=60", }, + { name: "entry-panel", permission: "content:edit_own" }, + { name: "entry-action", permission: "content:edit_own" }, ], mcp: { tools: [ @@ -102,22 +104,53 @@ describe("pluginManifestSchema", () => { elements: [{ type: "input", action_id: "event" }], }, ], + editorPanels: [ + { id: "health", title: "Health", route: "entry-panel", collections: ["posts"] }, + ], + editorActions: [ + { + id: "repair", + label: "Repair", + route: "entry-action", + placement: "overflow", + style: "danger", + confirm: { title: "Repair?", text: "Change entry", confirm: "Repair", deny: "Cancel" }, + }, + ], }, }); - expect(result.routes).toEqual([ - { - name: "events/list", - public: true, - permission: "content:read", - cacheControl: "public, max-age=60", - }, - ]); + expect(result.routes[0]).toEqual({ + name: "events/list", + public: true, + permission: "content:read", + cacheControl: "public, max-age=60", + }); expect(result.mcp?.tools[0]).toMatchObject({ name: "listEvents", permission: "content:read", outputSchema: { type: "array" }, }); expect(result.admin.fieldWidgets?.[0]?.name).toBe("event-picker"); + expect(result.admin.editorPanels?.[0]?.route).toBe("entry-panel"); + expect(result.admin.editorActions?.[0]?.confirm?.confirm).toBe("Repair"); + }); + + it.each([ + ["missing route", ["entry-panel"], "missing"], + ["public route", [{ name: "entry-panel", public: true }], "entry-panel"], + ["duplicate route", ["entry-panel", { name: "entry-panel" }], "entry-panel"], + ])("rejects an editor extension with a %s", (_label, routes, route) => { + const result = pluginManifestSchema.safeParse({ + id: "calendar", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + hooks: [], + routes, + admin: { editorPanels: [{ id: "health", title: "Health", route }] }, + }); + expect(result.success).toBe(false); }); }); diff --git a/skills/creating-plugins/SKILL.md b/skills/creating-plugins/SKILL.md index c38ada181f..0fbf5b335f 100644 --- a/skills/creating-plugins/SKILL.md +++ b/skills/creating-plugins/SKILL.md @@ -199,6 +199,8 @@ Read [Hooks](./references/hooks.md) for event and return types. Sandboxed pages and dashboard widgets use validated Block Kit responses from a private `admin` route. Use structured `link` targets for content, another declared plugin page, plugin settings, or an external URL. `routeCtx.ui` provides the host-attested admin locale, direction, and surface. External images must use HTTPS and either declare their hostname in `allowedHosts` or use `network:request:unrestricted`. The current validated block vocabulary includes `empty` and `accordion`; the element vocabulary includes `repeater` and `media_picker` in addition to the scalar form elements. A `tab` type and builder exist, but `validateBlocks()` currently rejects that block, so do not return it. Read [Block Kit](./references/block-kit.md) for exact shapes and where each element can render. +Saved-entry extensions are declared under `admin.editorPanels` and `admin.editorActions`. Each declaration names a private plugin route and can restrict itself to exact collection slugs. Panels receive lazy `panel_load` interactions and return Block Kit. Actions receive `editor_action` and return only a bounded toast, entry refresh, or structured navigation target. The host reloads and ownership-authorizes the entry, then attaches its collection, ID, locale, version, and extension ID to `routeCtx.ui`. It does not send field data or unsaved editor state. Read saved values through capability-gated `ctx.content`. + Core and the admin also have a declarative field-widget path: declare the widget under `admin.fieldWidgets` in `emdash-plugin.jsonc`, then point a schema field at `pluginId:widgetName`. The plugin CLI preserves field widgets in registry manifests and generated descriptors. The field editor currently renders `text_input`, `number_input`, `toggle`, `select`, and `media_picker` elements and combines their values into one object keyed by `action_id`. Use a `json` field for that object; other field types are accepted by the manifest schema but have no end-to-end proof that the composed value can be saved. Other element types show an unsupported-element message. The registry transport is covered through the generated artifact boundary. The repository's browser E2E coverage still exercises a native React field widget rather than a registry-installed declarative widget, so verify the rendered editor and value persistence for the chosen elements. @@ -237,7 +239,7 @@ await host.dispose(); The direct host builds the plugin and invokes it through Cloudflare Worker Loader, the production wrapper, and `PluginBridge`. It preserves hook, route, MCP, settings, and field-widget manifest metadata, supports content fixtures, and exposes KV and declared storage for assertions. Its `invokeHook()` and `invokeRoute()` methods test the transport. They do not prove that a host action emits the hook or applies route authentication, permissions, CSRF, and response caching. -Use `createPluginRuntimeTestHost()` when the test must exercise content, plugin activation, generated settings, media, comments, scheduled tasks, restart, authorization, CSRF, or cache behavior. Its API separates `transport`, `fixtures`, `actions`, `inspect`, `scheduled`, `restart()`, and `dispose()`. Fixtures write initial state without firing hooks, including bylines and taxonomy terms. Actions call production runtime and handler boundaries. Use `actions.plugin.updateSettings()` with `inspect.settings.raw()` to prove that a generated secret-setting save persists an encrypted envelope. Content inspectors can read byline credits and taxonomy assignments without invoking plugin code. Restart preserves D1, plugin storage, media storage, and plugin state while discarding runtime and isolate memory. +Use `createPluginRuntimeTestHost()` when the test must exercise content, plugin activation, generated settings, media, comments, scheduled tasks, restart, authorization, CSRF, cache behavior, or saved-entry extensions. Its API separates `transport`, `fixtures`, `actions`, `inspect`, `admin`, `scheduled`, `restart()`, and `dispose()`. Fixtures write initial state without firing hooks, including bylines and taxonomy terms. Actions call production runtime and handler boundaries. The `admin` helpers load and interact with panels and invoke editor actions through the production ownership and route-permission boundary. Use `actions.plugin.updateSettings()` with `inspect.settings.raw()` to prove that a generated secret-setting save persists an encrypted envelope. Content inspectors can read byline credits and taxonomy assignments without invoking plugin code. Restart preserves D1, plugin storage, media storage, and plugin state while discarding runtime and isolate memory. Redirect capability tests can establish host state with `host.fixtures.redirect()` and inspect persisted rules with `host.inspect.redirects()`. Trigger the plugin route through `host.actions.routes.request()` when the test must prove authorization and the real host-to-isolate redirect bridge. diff --git a/skills/creating-plugins/references/admin-ui.md b/skills/creating-plugins/references/admin-ui.md index a254e47982..5ae7f4e08c 100644 --- a/skills/creating-plugins/references/admin-ui.md +++ b/skills/creating-plugins/references/admin-ui.md @@ -91,6 +91,46 @@ The plugin CLI preserves `admin.settingsSchema` in the registry manifest and gen The `secret` settings field is write-only in the admin response and encrypted before persistence. The site must provide `EMDASH_ENCRYPTION_KEY`; missing, wrong, or tampered key material fails closed. Keep the encryption-key list with database backups. Existing `ctx.kv.get("settings:")` reads remain compatible through EmDash 0.x. +## Sandboxed saved-entry extensions + +Declare saved-entry panels and actions in `emdash-plugin.jsonc`: + +```jsonc title="emdash-plugin.jsonc" +{ + "admin": { + "editorPanels": [ + { + "id": "health", + "title": "Content health", + "route": "editor/health", + "collections": ["posts"], + }, + ], + "editorActions": [ + { + "id": "repair", + "label": "Repair metadata", + "route": "editor/repair", + "placement": "overflow", + "style": "danger", + "confirm": { + "title": "Repair?", + "text": "This changes the saved entry.", + "confirm": "Repair", + "deny": "Cancel", + }, + }, + ], + }, +} +``` + +Every referenced route must be private. EmDash reloads the saved entry and checks ownership plus the route permission before invoking it. `routeCtx.ui.entry` contains only the canonical collection, ID, locale, and version. The host does not send field values or unsaved editor state. + +Panels start collapsed. They receive `panel_load`, then ordinary `block_action` and `form_submit` interactions, and return `BlockResponse`. Actions receive `editor_action` and return an optional toast plus either `refresh: true` or a structured `navigate` target. Action responses cannot request refresh and navigation together. + +Use `createPluginRuntimeTestHost().admin` to exercise this boundary with `loadEditorPanel()`, `actEditorPanel()`, `submitEditorPanel()`, and `invokeEditorAction()`. + ## Sandboxed declarative field widgets Core and the admin contain a declarative field-widget path. Declare the widget in the registry manifest: diff --git a/skills/creating-plugins/references/block-kit.md b/skills/creating-plugins/references/block-kit.md index 4ead39f87d..4ebad2d6fb 100644 --- a/skills/creating-plugins/references/block-kit.md +++ b/skills/creating-plugins/references/block-kit.md @@ -514,6 +514,22 @@ return { } ``` +## Saved-entry panels and actions + +`admin.editorPanels` and `admin.editorActions` point to private plugin routes. Panel routes return Block Kit and receive `panel_load`, `block_action`, or `form_submit`. Action routes receive `editor_action` and return only these bounded fields: + +```typescript +{ + toast?: { message: string; type: "success" | "error" | "info" }; + refresh?: true; + navigate?: LinkTarget; +} +``` + +Use either `refresh` or `navigate`, not both. Navigation uses the same structured target validator as `link` elements. A danger action declaration must include a confirmation dialog. + +For both surfaces, `routeCtx.ui.entry` contains the host-reloaded collection, saved entry ID, content locale, and version. `routeCtx.ui.extensionId` identifies the manifest declaration. Saved field values and unsaved editor state are not included. + ## Links and admin locale Use a structured link target instead of returning an admin URL: diff --git a/skills/creating-plugins/references/sandbox-boundaries.md b/skills/creating-plugins/references/sandbox-boundaries.md index 3ceebed925..8fb4bbe508 100644 --- a/skills/creating-plugins/references/sandbox-boundaries.md +++ b/skills/creating-plugins/references/sandbox-boundaries.md @@ -45,7 +45,7 @@ The following surfaces do not exist in the current sandbox contract. Do not inve - Plugin routes return JSON-serializable data inside EmDash's API envelope. There is no raw or unwrapped route response that controls the status, stream, or arbitrary headers. - `public: true` removes host authentication from the route. There is no separate safe-public-view abstraction that automatically limits fields or capabilities; validate requests and return the minimum public data. -- Block Kit has host-resolved navigation links, but no content-editor panel extension point. +- Content-editor panels and actions cannot receive field values or unsaved editor state. They receive saved entry identity and version; use `ctx.content` for capability-gated saved-content reads. - Block Kit route calls expose the current administrator's host-attested locale and direction in `routeCtx.ui`. Manifest navigation labels remain static strings. ## Runner-only methods are not portable From b7d784a46a674156ab02956cd06bb5e8b41c2c3c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Thu, 17 Sep 2026 22:16:35 +0100 Subject: [PATCH 02/10] fix(plugins): keep editor extensions on saved state --- .../plugins/creating-plugins/block-kit.mdx | 2 +- .../admin/src/components/ContentEditor.tsx | 1 + .../src/components/ContentSettingsPanel.tsx | 3 +- .../SandboxedContentEditorActions.tsx | 25 +++++++- .../SandboxedContentEditorPanel.tsx | 4 +- .../SandboxedContentEditorExtensions.test.tsx | 34 ++++++++++- packages/marketplace/src/routes/author.ts | 57 ++++++++++++++++++- .../marketplace/tests/manifest-schema.test.ts | 46 +++++++++++++++ skills/creating-plugins/SKILL.md | 2 +- .../creating-plugins/references/admin-ui.md | 2 +- 10 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 packages/marketplace/tests/manifest-schema.test.ts diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx index e85ee83c96..b8bc98a5ec 100644 --- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx @@ -220,7 +220,7 @@ Editor extension routes receive an attested `routeCtx.ui` value. For `content-ed A panel receives `{ type: "panel_load" }` when it opens. Its later button and form interactions use the usual `block_action` and `form_submit` shapes. Return a validated `BlockResponse` from every panel interaction. -An editor action receives `{ type: "editor_action" }`. Return an object containing an optional toast and at most one terminal effect: +Editor actions are disabled while the form has unsaved changes or a save is running. After the saved state is current, an action receives `{ type: "editor_action" }`. Return an object containing an optional toast and at most one terminal effect: ```typescript title="src/plugin.ts" return { diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index c34bd3dc6f..99f9bbecb1 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -1041,6 +1041,7 @@ export function ContentEditor({ entryId={item.id} locale={item.locale ?? entryLocale} isMobile={isBelowLg} + disabled={isDirty || isSaving || Boolean(isAutosaving)} onEntryRefresh={onEntryRefresh} /> diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index c3bf02ad05..c57e243e1d 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -1178,7 +1178,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ disclosure > @@ -1189,6 +1189,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ collection={collection} entryId={item.id} locale={item.locale ?? entryLocale} + versionToken={item._rev ?? item.updatedAt} /> diff --git a/packages/admin/src/components/SandboxedContentEditorActions.tsx b/packages/admin/src/components/SandboxedContentEditorActions.tsx index 5f691b4729..b83b1bc113 100644 --- a/packages/admin/src/components/SandboxedContentEditorActions.tsx +++ b/packages/admin/src/components/SandboxedContentEditorActions.tsx @@ -20,6 +20,7 @@ interface SandboxedContentEditorActionsProps { entryId: string; locale?: string | null; isMobile?: boolean; + disabled?: boolean; onEntryRefresh?: () => void | Promise; } @@ -29,6 +30,7 @@ export function SandboxedContentEditorActions({ entryId, locale, isMobile = false, + disabled = false, onEntryRefresh, }: SandboxedContentEditorActionsProps) { const { t, i18n } = useLingui(); @@ -69,6 +71,7 @@ export function SandboxedContentEditorActions({ const invoke = React.useCallback( async (action: ResolvedSandboxedEditorAction) => { + if (disabled) return; const key = `${action.pluginId}:${action.extension.id}`; if (pendingRef.current.has(key)) return; pendingRef.current.add(key); @@ -135,11 +138,22 @@ export function SandboxedContentEditorActions({ } } }, - [applyNavigation, collection, entryId, identity, locale, onEntryRefresh, t, toastManager], + [ + applyNavigation, + collection, + disabled, + entryId, + identity, + locale, + onEntryRefresh, + t, + toastManager, + ], ); const choose = React.useCallback( (action: ResolvedSandboxedEditorAction) => { + if (disabled) return; if (action.extension.confirm) { setConfirmError(null); setConfirming(action); @@ -147,7 +161,7 @@ export function SandboxedContentEditorActions({ } void invoke(action); }, - [invoke], + [disabled, invoke], ); const toolbarCandidates = isMobile @@ -176,6 +190,8 @@ export function SandboxedContentEditorActions({ size="sm" variant={action.extension.style === "danger" ? "destructive" : "secondary"} loading={pending.has(key)} + disabled={disabled} + title={disabled ? t`Save changes before running plugin actions` : undefined} onClick={() => choose(action)} > {actionLabel(action)} @@ -191,6 +207,8 @@ export function SandboxedContentEditorActions({ variant="ghost" shape="square" aria-label={t`Plugin actions`} + disabled={disabled} + title={disabled ? t`Save changes before running plugin actions` : undefined} icon={