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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,24 @@ 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)

- **The inline-edit content routes are gone; the admin's buttons on the bar are the admin's own (#127).**
`/admin/api/content/*` (source, commit, fields) moved to `@dune/plugin-inline-edit`
(`/api/inline-edit/content/*`), served for any signed-in editor with or without this plugin. The
admin contributes "Edit source" (an overlay with its page editor) and "Open in admin" to the bar's
`bar.actions` point, so the bar needs no knowledge of the admin's URLs.
- **Content webhooks are a listener on the page events**, not a call in each admin route: a write
from the inline-edit bar, MCP or the CLI now reaches them too. `announcePageEvent()` names the
signed-in user as `actor`, which the webhook payload carries.

- **`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 are exported from here as typed tokens, `ADMIN_PAGES`, `ADMIN_ROUTES` and `ADMIN_EDITOR`
(`@dune/plugin-admin/contributions`): a plugin writes `contribute(ADMIN_PAGES, ...)`. 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
35 changes: 31 additions & 4 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import { getDuneAdminIslands, mountAdminPanel } from "./src/admin/mount.ts";
import * as corePlugins from "@dune/core/plugins";
import denoJson from "./deno.json" with { type: "json" };
import { adminSettings } from "./src/admin/settings.ts";
import { adminBarActions, BAR_ACTIONS } from "./src/admin/bar-actions.ts";
import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts";
import { listenForContentWebhooks } from "./src/admin/webhooks.ts";

// ── Core-instance handshake ──────────────────────────────────────────────────
// Re-export the CORE_INSTANCE sentinel and CORE_VERSION that THIS package's
Expand All @@ -52,6 +55,14 @@ 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: the typed keys (`ADMIN_PAGES`, `ADMIN_ROUTES`, `ADMIN_EDITOR`) and their item types. */
export { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES } from "./src/admin/contributions.ts";
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 @@ -138,6 +149,12 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin {
await liveAdminContext?.executeScheduledAction?.(action);
},
}));

// The admin's own buttons on the inline-edit bar. `bar.actions` is that
// plugin's extension point; contributing through a key of the same name
// (see bar-actions.ts) keeps it an optional neighbour, not a dependency
// (a site without it simply never reads this).
api.capabilities.contribute(BAR_ACTIONS, (ctx) => adminBarActions(adminCfg.path, ctx));
},

async mount({ app, runtime, services, capabilities }: MountApi) {
Expand Down Expand Up @@ -224,7 +241,7 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin {
return;
}
try {
await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow, services }, action.sourcePath, newStatus);
await applyWorkflowTransition({ engine: runtime.engine, storage, config, hooks: runtime.hooks, workflow }, action.sourcePath, newStatus);
} catch (err) {
logger.error("scheduler.action-failed", {
actionId: action.id,
Expand Down Expand Up @@ -271,15 +288,25 @@ export function createAdminPlugin(opts: AdminPluginOptions): DunePlugin {
}).catch(() => {});
});

// Outbound content webhooks follow the page events, whoever wrote the
// page (an admin route, the inline-edit bar, MCP, the CLI): the rule is
// that the writer fires the event, and this is a listener like any other.
listenForContentWebhooks({
hooks: runtime.hooks,
endpoints: () => adminSettings(config).webhooks,
runtimeDir,
security: services.security,
});

// ── 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;

const pluginPages = capabilities.list("admin.pages");
const pluginRoutes = capabilities.list("admin.routes");
const pluginPages = capabilities.list(ADMIN_PAGES);
const pluginRoutes = capabilities.list(ADMIN_ROUTES);

const adminContext: AdminContext = {
services,
Expand Down
71 changes: 71 additions & 0 deletions src/admin/bar-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* What the admin adds to the inline-edit bar.
*
* The bar belongs to `@dune/plugin-inline-edit`, which knows nothing about an
* admin; this plugin contributes its own buttons to the bar's `bar.actions`
* point: a link to the page's editor here, and a button that opens that editor
* in an overlay on the page.
*
* The contribution is made without importing the bar plugin's package (that would
* make an optional neighbour a dependency), so the shapes and the key below are
* this plugin's own restatement of the `BarAction` contract.
*
* @module
*/

import { type CapabilityKey, capabilityKey } from "@dune/core/plugin-api";

/** A bar button, as `@dune/plugin-inline-edit` renders it. */
export interface AdminBarAction {
id: string;
label: string;
/** Renders as a link. */
href?: string;
/** Renders as a button running this script. */
onClick?: string;
}

/** The part of the bar's per-response context this plugin reads. */
export interface AdminBarContext {
page: { sourcePath: string } | null;
}

/**
* The bar's `bar.actions` contribution point, recreated from its documented name
* so this plugin needs no dependency on the bar's package: two keys with one
* name are one capability.
*/
export const BAR_ACTIONS: CapabilityKey<(ctx: AdminBarContext) => AdminBarAction[]> = capabilityKey("bar.actions");

/**
* Script for the "Edit source" button: show the admin's page editor
* (`embedded=1` drops its sidebar and topbar, redundant over a content page) in
* a full-screen overlay. Built on first click so nothing loads until asked;
* Escape or Close hides it.
*/
function overlayScript(embeddedUrl: string): string {
return `(function(){var id='dune-source-overlay',o=document.getElementById(id);` +
`if(o){o.style.display='flex';return;}` +
`var s=document.createElement('style');s.textContent='@media print{#'+id+'{display:none!important}}';document.head.appendChild(s);` +
`o=document.createElement('div');o.id=id;` +
`o.style.cssText='position:fixed;inset:0;z-index:100000;background:rgba(0,0,0,.5);display:flex;flex-direction:column';` +
`var b=document.createElement('div');b.style.cssText='flex:none;display:flex;justify-content:flex-end;padding:8px 16px;background:#1a1a2e';` +
`var c=document.createElement('button');c.type='button';c.textContent='\\u2715 Close';` +
`c.style.cssText='background:rgba(255,255,255,.15);color:#fff;border:none;border-radius:4px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit';` +
`function hide(){o.style.display='none';}c.onclick=hide;b.appendChild(c);` +
`var f=document.createElement('iframe');f.title='Edit source';f.style.cssText='flex:1;width:100%;border:none;background:#fff';` +
`f.src=${JSON.stringify(embeddedUrl)};o.appendChild(b);o.appendChild(f);document.body.appendChild(o);` +
`document.addEventListener('keydown',function(e){if(e.key==='Escape')hide();});})()`;
}

/** The admin's bar contribution for a page, or nothing off content pages. */
export function adminBarActions(prefix: string, ctx: AdminBarContext): AdminBarAction[] {
if (!ctx.page) return [];
// The edit route is `${prefix}/pages/edit?path=<source path>` (there is no
// `/pages/<path>` route).
const editUrl = `${prefix}/pages/edit?path=${encodeURIComponent(ctx.page.sourcePath)}`;
return [
{ id: "admin:edit-source", label: "✎ Edit source", onClick: overlayScript(`${editUrl}&embedded=1`) },
{ id: "admin:open", label: "Open in admin →", href: editUrl },
];
}
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
139 changes: 139 additions & 0 deletions src/admin/contributions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* 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, as typed keys under its own `admin.`
* prefix. A plugin that contributes an admin page, an admin route or a
* replacement page editor imports the key (which carries the type) from
* `@dune/plugin-admin`:
*
* ```ts
* import { ADMIN_PAGES } from "@dune/plugin-admin";
* api.capabilities.contribute(ADMIN_PAGES, { path: "/billing", label: "Billing", permission: "billing.manage", handler });
* ```
*
* @module
*/

import { type CapabilityKey, capabilityKey, 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;
}

/** A replacement page editor (slot). Consumer: the admin. */
export const ADMIN_EDITOR: CapabilityKey<ContentEditorPlugin> = capabilityKey("admin.editor");

/** An extra admin page (contribution point). Consumer: the admin. */
export const ADMIN_PAGES: CapabilityKey<AdminPageRegistration> = capabilityKey("admin.pages");

/**
* A guarded admin route, any method, under the admin prefix (contribution point).
* Consumer: the admin, which applies authentication, CSRF and permission checks.
*/
export const ADMIN_ROUTES: CapabilityKey<AdminRouteRegistration> = capabilityKey("admin.routes");
Loading
Loading