diff --git a/CHANGELOG.md b/CHANGELOG.md index ed70b83..be318e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ follows [Semantic Versioning](https://semver.org). `onPageMove` is also a valid webhook event. Needs `@dune/core` with `onPageMove` (0.36). `tests/admin/page_events_test.ts` drives create, update, reorder, delete and restore through the real routes. +### Changed — BREAKING (the admin declares its own extension points, #127) + +- **`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin` live here now**, exported + from `@dune/plugin-admin` (and `@dune/plugin-admin/contributions`). Core's contract never read them. The + keys `admin.pages` and `admin.routes` are declared by this package by declaration merging, so importing + the types is what makes a plugin's `contribute("admin.pages", ...)` type-check. The `editor.content` + slot is renamed **`admin.editor`**: a key carries the name of the plugin that reads it. A plugin that + provides a replacement page editor changes the key and imports `ContentEditorPlugin` from here. + ### Changed — BREAKING (the admin is called `admin`, and its settings are its plugin config) - **The plugin is named `admin`** (it was `dune-admin`), so its settings are `config.plugins.admin`, and diff --git a/deno.json b/deno.json index d6274b4..1b48fe1 100644 --- a/deno.json +++ b/deno.json @@ -13,6 +13,7 @@ "./admin/mount": "./src/admin/mount.ts", "./admin/context": "./src/admin/context.ts", "./admin/types": "./src/admin/types.ts", + "./contributions": "./src/admin/contributions.ts", "./admin/guards": "./src/admin/guards.ts", "./admin/auth/middleware": "./src/admin/auth/middleware.ts", "./admin/auth/provider": "./src/admin/auth/provider.ts", diff --git a/mod.ts b/mod.ts index 602d373..dc7e8f6 100644 --- a/mod.ts +++ b/mod.ts @@ -52,6 +52,17 @@ const _core = corePlugins as unknown as { /** The `@dune/core` instance sentinel this package resolved. See core's `plugins/mod.ts`. */ export const resolvedCoreSentinel: unknown = _core.CORE_INSTANCE; +/** + * The admin's extension points. Importing them also teaches TypeScript the keys + * (`admin.pages`, `admin.routes`, `admin.editor`) this plugin declares on the + * capability registry. + */ +export type { + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, +} from "./src/admin/contributions.ts"; + /** The `@dune/core` version this package resolved. Undefined on cores ≤0.30. */ export const resolvedCoreVersion: string | undefined = _core.CORE_VERSION; @@ -273,7 +284,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin { // ── Default content editor ──────────────────────────────────────────────── const inlineEdit = capabilities.get("editor.inline"); - const contentEditor = capabilities.get("editor.content") ?? createBlockEditorPlugin(); + const contentEditor = capabilities.get("admin.editor") ?? createBlockEditorPlugin(); // ── Build AdminContext ──────────────────────────────────────────────────── const metricsEnabled = config.system.metrics?.enabled !== false; diff --git a/src/admin/block-editor-plugin.tsx b/src/admin/block-editor-plugin.tsx index a166a4b..62b959d 100644 --- a/src/admin/block-editor-plugin.tsx +++ b/src/admin/block-editor-plugin.tsx @@ -1,14 +1,14 @@ /** @jsxImportSource preact */ /** * Built-in block editor — the default ContentEditorPlugin loaded by bootstrap - * when no plugin provides the `editor.content` capability. + * when no plugin provides the `admin.editor` capability. * * Wraps the PageEditor island in the same way the old hardwired route did, but * now as a proper ContentEditorPlugin so it participates in the same slot that * third-party editors use. */ -import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { ContentEditorPlugin } from "./contributions.ts"; import type { FreshContext } from "fresh"; import PageEditor from "./islands/PageEditor.tsx"; diff --git a/src/admin/context.ts b/src/admin/context.ts index e90e973..90c2178 100644 --- a/src/admin/context.ts +++ b/src/admin/context.ts @@ -17,8 +17,8 @@ import type { Scheduler, ScheduledAction } from "@dune/core/workflow"; import type { HistoryEngine } from "@dune/core/history"; import type { SubmissionManager } from "./submissions.ts"; import type { FlexEngine } from "@dune/core/flex"; -import type { HookRegistry, AdminPageRegistration, AdminRouteRegistration } from "@dune/core/hooks"; -import type { ContentEditorPlugin } from "@dune/core/hooks"; +import type { HookRegistry } from "@dune/core/hooks"; +import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; import type { StagingEngine } from "@dune/core/staging"; import type { CommentManager } from "./comments.ts"; import type { CollabManager } from "../collab/mod.ts"; @@ -68,7 +68,7 @@ export interface AdminContext { collab?: CollabManager; /** Inline editing manager, provided by a plugin as the `editor.inline` capability. */ inlineEdit?: InlineEditManager; - /** Custom page editor, provided by a plugin as the `editor.content` capability. Replaces the built-in block editor. */ + /** Custom page editor, provided by a plugin as the `admin.editor` capability. Replaces the built-in block editor. */ contentEditor?: ContentEditorPlugin; auditLogger?: AuditLogger; metrics?: MetricsCollector; diff --git a/src/admin/contributions.ts b/src/admin/contributions.ts new file mode 100644 index 0000000..e60720f --- /dev/null +++ b/src/admin/contributions.ts @@ -0,0 +1,141 @@ +/** + * The admin's own extension points. + * + * Core's contract names none of these: it never reads them. The admin is their + * only consumer, so it declares them here, by declaration merging into the + * capability registry, under its own `admin.` prefix. A plugin that contributes + * an admin page, an admin route or a replacement page editor imports the types + * from `@dune/plugin-admin`; importing them is also what makes the keys known to + * TypeScript: + * + * ```ts + * import type { AdminPageRegistration } from "@dune/plugin-admin"; + * api.capabilities.contribute("admin.pages", { path: "/billing", label: "Billing", permission: "billing.manage", handler }); + * ``` + * + * @module + */ + +import type { PluginRouteContext } from "@dune/core/plugin-api"; + +/** + * A custom admin page contributed by a plugin. + * + * Plugins contribute these to the `admin.pages` point: + * `api.capabilities.contribute("admin.pages", { path, label, permission, handler })`. + * Unlike `publicRoutes`, this is **not** wired by `@dune/core`: the admin reads the list + * (`capabilities.list("admin.pages")`) and mounts each page as a programmatic route, + * enforcing its declared `permission`. A site without the admin gets no admin pages. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminPageRegistration { + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin` registers at `/admin/my-plugin`. + */ + path: string; + /** Human-readable nav label shown in the admin sidebar */ + label: string; + /** Optional icon — emoji or inline SVG */ + icon?: string; + /** + * Admin permission required to view this page. Required, like + * {@link AdminRouteRegistration.permission}: every admin contribution says + * who may use it, so a page cannot be exposed to every authenticated admin + * by leaving a field out. + */ + permission: string; + /** GET handler for the page (a method, so a full Fresh context is accepted: see {@link PluginRouteContext}). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * A guarded admin route contributed by a plugin: the way to add a `POST`, `PUT`, + * `PATCH` or `DELETE` endpoint (or a `GET` API route that is not a sidebar page) + * under the admin prefix. + * + * Plugins contribute these to the `admin.routes` point: + * `api.capabilities.contribute("admin.routes", { method, path, permission, handler })`. + * The admin reads the list + * (`capabilities.list("admin.routes")`) and mounts each route behind its own + * authentication, with the CSRF check applied to every non-safe method and the + * declared `permission` enforced, so the handler only runs for a request that + * passed all three. A site without the admin mounts nothing. + * + * A route registered directly in a plugin's `mount()` is *not* behind the + * admin's middleware (mount order is registration order, and the admin mounts + * last), so it cannot use the admin's guards: contribute it here instead. + * + * @since 4.0.0 + */ +// deno-lint-ignore no-explicit-any +export interface AdminRouteRegistration { + /** HTTP method. Anything other than `GET` is subject to the CSRF check. */ + method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; + /** + * URL path relative to the admin prefix (must start with `/`). + * Example: `/my-plugin/save` registers at `/admin/my-plugin/save`. + */ + path: string; + /** + * Admin permission required (including ones the plugin declares through + * `authzActions`). Required, not optional: a mutation endpoint must say who + * may call it, so it cannot be mounted open to every authenticated admin by + * leaving a field out. + */ + permission: string; + /** Handler; runs only after authentication, CSRF and permission checks pass (a method, so a full Fresh context is accepted). */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * Plugin-provided replacement for the built-in block editor. + * + * Provide it as the `admin.editor` capability: `api.capabilities.provide("admin.editor", () => impl)`. + * The edit route delegates to `pageEditorHandler` instead of the default block editor. + * + * Declared by the admin plugin, which is the only reader of the `admin.editor` slot; a plugin + * that provides one imports this type from `@dune/plugin-admin`. + * + * @since 4.0.0 + */ +export interface ContentEditorPlugin { + /** + * Handle `GET /admin/pages/edit?path=...`. + * + * Core passes the full Fresh context: annotate `ctx` as `FreshContext` and call + * `ctx.render(component)` to render within the admin layout, or return any + * `Response` directly. An unannotated `ctx` is a {@link PluginRouteContext}. + * Access `ctx.state.adminContext` for engine, config, and other admin services. + * The context state type is `AdminState` from `@dune/plugin-admin`; typed as + * `any` here so core does not depend on admin-internal types. + */ + pageEditorHandler(ctx: PluginRouteContext): Response | Promise; + + /** + * Optional WebSocket upgrade handler for real-time collaboration. + * + * When present, `GET /admin/api/content-editor/ws?path=...` delegates here + * after auth and path validation. Return a `101 Switching Protocols` response + * via `Deno.upgradeWebSocket`. When absent the WS endpoint responds 501. + */ + wsHandler?: ( + req: Request, + user: { id: string; name: string }, + ) => Response; +} + +declare module "@dune/core/plugin-api" { + interface Capabilities { + /** A replacement page editor. Consumer: the admin. */ + "admin.editor": ContentEditorPlugin; + } + interface Contributions { + /** An extra admin page. Consumer: the admin. */ + "admin.pages": AdminPageRegistration; + /** A guarded admin route (any method) under the admin prefix. Consumer: the admin, which applies authentication, CSRF and permission checks. */ + "admin.routes": AdminRouteRegistration; + } +} diff --git a/src/admin/types.ts b/src/admin/types.ts index 1cdb749..6890004 100644 --- a/src/admin/types.ts +++ b/src/admin/types.ts @@ -4,7 +4,7 @@ * @module */ -export type { ContentEditorPlugin } from "@dune/core/hooks"; +export type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "./contributions.ts"; // Session and Role live in core to break circular deps; import for // local use AND re-export so callers importing from admin/types get them. // Session was formerly AdminSession — renamed in diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 4c22822..d75a4f0 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -104,7 +104,7 @@ Deno.test( ); Deno.test( - "admin uses the editor.content slot another plugin provides", + "admin uses the admin.editor slot another plugin provides", { sanitizeOps: false, sanitizeResources: false }, async () => { const editor: DunePlugin = { @@ -112,15 +112,15 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.provide("editor.content", () => ({ + api.capabilities.provide("admin.editor", () => ({ pageEditorHandler: () => new Response("custom editor"), })); }, }; await withSite([editor], async (ctx) => { - assertExists(ctx.capabilities.get("editor.content")); + assertExists(ctx.capabilities.get("admin.editor")); assertEquals( - ctx.capabilities.providers().find((p) => p.name === "editor.content")?.provider, + ctx.capabilities.providers().find((p) => p.name === "admin.editor")?.provider, "test-editor", ); }); diff --git a/tests/admin/contributions_types_test.ts b/tests/admin/contributions_types_test.ts new file mode 100644 index 0000000..cb6cfdc --- /dev/null +++ b/tests/admin/contributions_types_test.ts @@ -0,0 +1,46 @@ +/** + * The admin declares its own extension points. These checks fail to compile if + * the declaration merging, the required `permission`, or the handler typing + * regresses; the runtime assertions only keep the tests honest. + */ + +import { assertEquals } from "@std/assert"; +import type { FreshContext } from "fresh"; +import { createCapabilityRegistry } from "@dune/core/plugin-api"; +import type { AdminPageRegistration, AdminRouteRegistration, ContentEditorPlugin } from "../../mod.ts"; + +Deno.test("importing the admin's types teaches TypeScript its keys", () => { + const reg = createCapabilityRegistry(); + const page: AdminPageRegistration = { path: "/p", label: "P", permission: "pages.read", handler: () => new Response("") }; + reg.registrar("x").contribute("admin.pages", page); + assertEquals(reg.reader.list("admin.pages").map((p) => p.path), ["/p"]); +}); + +Deno.test("an admin.pages contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.pages + reg.registrar("a").contribute("admin.pages", { path: "/x", label: "X", handler: () => new Response("") }); + assertEquals(reg.reader.list("admin.pages").length, 1); +}); + +Deno.test("an admin.routes contribution must name a permission (compile-time)", () => { + const reg = createCapabilityRegistry(); + // @ts-expect-error `permission` is required on admin.routes + reg.registrar("a").contribute("admin.routes", { method: "POST", path: "/x", handler: () => new Response("") }); + assertEquals(reg.reader.list("admin.routes").length, 1); +}); + +Deno.test("contribution handlers may be written against a full Fresh context", () => { + // `handler` is declared as a method, so the parameter is checked bivariantly. + const full = (ctx: FreshContext<{ n: number }>) => new Response(String(ctx.state.n)); + const page: AdminPageRegistration<{ n: number }> = { path: "/p", label: "P", permission: "pages.read", handler: full }; + const route: AdminRouteRegistration<{ n: number }> = { method: "POST", path: "/r", permission: "pages.update", handler: full }; + assertEquals([page.path, route.path], ["/p", "/r"]); +}); + +Deno.test("admin.editor takes a ContentEditorPlugin", () => { + const reg = createCapabilityRegistry(); + const editor: ContentEditorPlugin = { pageEditorHandler: () => new Response("") }; + reg.registrar("alt-editor").provide("admin.editor", () => editor); + assertEquals(reg.reader.providers()[0].name, "admin.editor"); +});