Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 12 additions & 1 deletion mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions src/admin/block-editor-plugin.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down
6 changes: 3 additions & 3 deletions src/admin/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
141 changes: 141 additions & 0 deletions src/admin/contributions.ts
Original file line number Diff line number Diff line change
@@ -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<S = any> {
/**
* 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<S>): Promise<Response> | 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<S = any> {
/** 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<S>): Promise<Response> | 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<Response>;

/**
* 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" {

Check failure on line 130 in src/admin/contributions.ts

View workflow job for this annotation

GitHub Actions / check

found an ambient module, which is a global augmentation, which are not unsupported
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;
}
}
2 changes: 1 addition & 1 deletion src/admin/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions tests/admin/capabilities_e2e_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,23 @@ 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 = {
name: "test-editor",
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",
);
});
Expand Down
46 changes: 46 additions & 0 deletions tests/admin/contributions_types_test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
Loading