diff --git a/CHANGELOG.md b/CHANGELOG.md index ed70b83..ee73f0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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..dbd85b6 100644 --- a/mod.ts +++ b/mod.ts @@ -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 @@ -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; @@ -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) { @@ -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, @@ -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, diff --git a/src/admin/bar-actions.ts b/src/admin/bar-actions.ts new file mode 100644 index 0000000..0251883 --- /dev/null +++ b/src/admin/bar-actions.ts @@ -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=` (there is no + // `/pages/` 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 }, + ]; +} 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..955f1ad --- /dev/null +++ b/src/admin/contributions.ts @@ -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 { + /** + * 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; +} + +/** A replacement page editor (slot). Consumer: the admin. */ +export const ADMIN_EDITOR: CapabilityKey = capabilityKey("admin.editor"); + +/** An extra admin page (contribution point). Consumer: the admin. */ +export const ADMIN_PAGES: CapabilityKey = 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 = capabilityKey("admin.routes"); diff --git a/src/admin/manifest.gen.ts b/src/admin/manifest.gen.ts index 5c81817..1eb48b4 100644 --- a/src/admin/manifest.gen.ts +++ b/src/admin/manifest.gen.ts @@ -11,93 +11,92 @@ import * as r4 from "./routes/api/config/index.ts"; import * as r5 from "./routes/api/config/theme.ts"; import * as r6 from "./routes/api/config/theme-config.ts"; import * as r7 from "./routes/api/config/themes.ts"; -import * as r8 from "./routes/api/content/[...rest].ts"; -import * as r9 from "./routes/api/content-editor/ws.ts"; -import * as r10 from "./routes/api/csrf.ts"; -import * as r11 from "./routes/api/dashboard.ts"; -import * as r12 from "./routes/api/dev/apply.ts"; -import * as r13 from "./routes/api/editor/parse.ts"; -import * as r14 from "./routes/api/editor/serialize.ts"; -import * as r15 from "./routes/api/email-preview.ts"; -import * as r16 from "./routes/api/email-preview/[id].ts"; -import * as r17 from "./routes/api/flex/[type]/index.ts"; -import * as r18 from "./routes/api/flex/[type]/[id]/index.ts"; -import * as r19 from "./routes/api/history/[...rest].ts"; -import * as r20 from "./routes/api/i18n/memory/index.ts"; -import * as r21 from "./routes/api/i18n/memory/rebuild.ts"; -import * as r22 from "./routes/api/i18n/mt-status.ts"; -import * as r23 from "./routes/api/i18n/translate-page.ts"; -import * as r24 from "./routes/api/i18n/translate-segment.ts"; -import * as r25 from "./routes/api/inline-edit/presence.ts"; -import * as r26 from "./routes/api/introspect.ts"; -import * as r27 from "./routes/api/jobs.ts"; -import * as r28 from "./routes/api/jobs/[name]/run.ts"; -import * as r29 from "./routes/api/media/index.ts"; -import * as r30 from "./routes/api/media/meta.ts"; -import * as r31 from "./routes/api/media/upload.ts"; -import * as r32 from "./routes/api/metrics.ts"; -import * as r33 from "./routes/api/page-source.ts"; -import * as r34 from "./routes/api/pages/index.ts"; -import * as r35 from "./routes/api/pages/reorder.ts"; -import * as r36 from "./routes/api/pages/translate.ts"; -import * as r37 from "./routes/api/pages/[...path]/index.ts"; -import * as r38 from "./routes/api/pages/[...path]/comments/index.ts"; -import * as r39 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; -import * as r40 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; -import * as r41 from "./routes/api/plugins/index.ts"; -import * as r42 from "./routes/api/plugins/install.ts"; -import * as r43 from "./routes/api/plugins/[name]/config.ts"; -import * as r44 from "./routes/api/preview.ts"; -import * as r45 from "./routes/api/registry/plugins.ts"; -import * as r46 from "./routes/api/registry/themes.ts"; -import * as r47 from "./routes/api/render-markdown.ts"; -import * as r48 from "./routes/api/search/analytics.ts"; -import * as r49 from "./routes/api/search/engines.ts"; -import * as r50 from "./routes/api/sections.ts"; -import * as r51 from "./routes/api/staging/[...path]/index.ts"; -import * as r52 from "./routes/api/staging/[...path]/publish.ts"; -import * as r53 from "./routes/api/theme-preview.ts"; -import * as r54 from "./routes/api/themes/install.ts"; -import * as r55 from "./routes/api/users/index.ts"; -import * as r56 from "./routes/api/users/[id]/index.ts"; -import * as r57 from "./routes/api/users/[id]/password.ts"; -import * as r58 from "./routes/api/webhooks/deliveries.ts"; -import * as r59 from "./routes/api/workflow/schedule/index.ts"; -import * as r60 from "./routes/api/workflow/schedule/[id].ts"; -import * as r61 from "./routes/api/workflow/scheduled/[...path].ts"; -import * as r62 from "./routes/api/workflow/stages.ts"; -import * as r63 from "./routes/api/workflow/status/[...path].ts"; -import * as r64 from "./routes/api/workflow/transition.ts"; -import * as r65 from "./routes/audit/index.tsx"; -import * as r66 from "./routes/collab/edit-ws.ts"; -import * as r67 from "./routes/collab/ws.ts"; -import * as r68 from "./routes/config/index.tsx"; -import * as r69 from "./routes/email-preview/index.tsx"; -import * as r70 from "./routes/flex/index.tsx"; -import * as r71 from "./routes/flex/[type]/index.tsx"; -import * as r72 from "./routes/flex/[type]/[id].tsx"; -import * as r73 from "./routes/i18n/index.tsx"; -import * as r74 from "./routes/i18n/memory.tsx"; -import * as r75 from "./routes/jobs/index.tsx"; -import * as r76 from "./routes/login.tsx"; -import * as r77 from "./routes/login/logout.ts"; -import * as r78 from "./routes/marketplace/index.tsx"; -import * as r79 from "./routes/media/index.tsx"; -import * as r80 from "./routes/metrics/index.tsx"; -import * as r81 from "./routes/pages/index.tsx"; -import * as r82 from "./routes/pages/builder.tsx"; -import * as r83 from "./routes/pages/edit.tsx"; -import * as r84 from "./routes/pages/history.tsx"; -import * as r85 from "./routes/plugins/index.tsx"; -import * as r86 from "./routes/search/index.tsx"; -import * as r87 from "./routes/submissions/index.tsx"; -import * as r88 from "./routes/submissions/[form]/index.tsx"; -import * as r89 from "./routes/submissions/[form]/[id]/index.tsx"; -import * as r90 from "./routes/submissions/[form]/[id]/delete.ts"; -import * as r91 from "./routes/submissions/[form]/[id]/files/[filename].ts"; -import * as r92 from "./routes/submissions/[form]/[id]/status.ts"; -import * as r93 from "./routes/themes/index.tsx"; -import * as r94 from "./routes/users/index.tsx"; +import * as r8 from "./routes/api/content-editor/ws.ts"; +import * as r9 from "./routes/api/csrf.ts"; +import * as r10 from "./routes/api/dashboard.ts"; +import * as r11 from "./routes/api/dev/apply.ts"; +import * as r12 from "./routes/api/editor/parse.ts"; +import * as r13 from "./routes/api/editor/serialize.ts"; +import * as r14 from "./routes/api/email-preview.ts"; +import * as r15 from "./routes/api/email-preview/[id].ts"; +import * as r16 from "./routes/api/flex/[type]/index.ts"; +import * as r17 from "./routes/api/flex/[type]/[id]/index.ts"; +import * as r18 from "./routes/api/history/[...rest].ts"; +import * as r19 from "./routes/api/i18n/memory/index.ts"; +import * as r20 from "./routes/api/i18n/memory/rebuild.ts"; +import * as r21 from "./routes/api/i18n/mt-status.ts"; +import * as r22 from "./routes/api/i18n/translate-page.ts"; +import * as r23 from "./routes/api/i18n/translate-segment.ts"; +import * as r24 from "./routes/api/inline-edit/presence.ts"; +import * as r25 from "./routes/api/introspect.ts"; +import * as r26 from "./routes/api/jobs.ts"; +import * as r27 from "./routes/api/jobs/[name]/run.ts"; +import * as r28 from "./routes/api/media/index.ts"; +import * as r29 from "./routes/api/media/meta.ts"; +import * as r30 from "./routes/api/media/upload.ts"; +import * as r31 from "./routes/api/metrics.ts"; +import * as r32 from "./routes/api/page-source.ts"; +import * as r33 from "./routes/api/pages/index.ts"; +import * as r34 from "./routes/api/pages/reorder.ts"; +import * as r35 from "./routes/api/pages/translate.ts"; +import * as r36 from "./routes/api/pages/[...path]/index.ts"; +import * as r37 from "./routes/api/pages/[...path]/comments/index.ts"; +import * as r38 from "./routes/api/pages/[...path]/comments/[id]/index.ts"; +import * as r39 from "./routes/api/pages/[...path]/comments/[id]/resolve.ts"; +import * as r40 from "./routes/api/plugins/index.ts"; +import * as r41 from "./routes/api/plugins/install.ts"; +import * as r42 from "./routes/api/plugins/[name]/config.ts"; +import * as r43 from "./routes/api/preview.ts"; +import * as r44 from "./routes/api/registry/plugins.ts"; +import * as r45 from "./routes/api/registry/themes.ts"; +import * as r46 from "./routes/api/render-markdown.ts"; +import * as r47 from "./routes/api/search/analytics.ts"; +import * as r48 from "./routes/api/search/engines.ts"; +import * as r49 from "./routes/api/sections.ts"; +import * as r50 from "./routes/api/staging/[...path]/index.ts"; +import * as r51 from "./routes/api/staging/[...path]/publish.ts"; +import * as r52 from "./routes/api/theme-preview.ts"; +import * as r53 from "./routes/api/themes/install.ts"; +import * as r54 from "./routes/api/users/index.ts"; +import * as r55 from "./routes/api/users/[id]/index.ts"; +import * as r56 from "./routes/api/users/[id]/password.ts"; +import * as r57 from "./routes/api/webhooks/deliveries.ts"; +import * as r58 from "./routes/api/workflow/schedule/index.ts"; +import * as r59 from "./routes/api/workflow/schedule/[id].ts"; +import * as r60 from "./routes/api/workflow/scheduled/[...path].ts"; +import * as r61 from "./routes/api/workflow/stages.ts"; +import * as r62 from "./routes/api/workflow/status/[...path].ts"; +import * as r63 from "./routes/api/workflow/transition.ts"; +import * as r64 from "./routes/audit/index.tsx"; +import * as r65 from "./routes/collab/edit-ws.ts"; +import * as r66 from "./routes/collab/ws.ts"; +import * as r67 from "./routes/config/index.tsx"; +import * as r68 from "./routes/email-preview/index.tsx"; +import * as r69 from "./routes/flex/index.tsx"; +import * as r70 from "./routes/flex/[type]/index.tsx"; +import * as r71 from "./routes/flex/[type]/[id].tsx"; +import * as r72 from "./routes/i18n/index.tsx"; +import * as r73 from "./routes/i18n/memory.tsx"; +import * as r74 from "./routes/jobs/index.tsx"; +import * as r75 from "./routes/login.tsx"; +import * as r76 from "./routes/login/logout.ts"; +import * as r77 from "./routes/marketplace/index.tsx"; +import * as r78 from "./routes/media/index.tsx"; +import * as r79 from "./routes/metrics/index.tsx"; +import * as r80 from "./routes/pages/index.tsx"; +import * as r81 from "./routes/pages/builder.tsx"; +import * as r82 from "./routes/pages/edit.tsx"; +import * as r83 from "./routes/pages/history.tsx"; +import * as r84 from "./routes/plugins/index.tsx"; +import * as r85 from "./routes/search/index.tsx"; +import * as r86 from "./routes/submissions/index.tsx"; +import * as r87 from "./routes/submissions/[form]/index.tsx"; +import * as r88 from "./routes/submissions/[form]/[id]/index.tsx"; +import * as r89 from "./routes/submissions/[form]/[id]/delete.ts"; +import * as r90 from "./routes/submissions/[form]/[id]/files/[filename].ts"; +import * as r91 from "./routes/submissions/[form]/[id]/status.ts"; +import * as r92 from "./routes/themes/index.tsx"; +import * as r93 from "./routes/users/index.tsx"; /** Shape of an fs-convention route module (subset Fresh cares about). */ export interface AdminRouteModule { @@ -125,93 +124,92 @@ export const adminRoutes: AdminRouteEntry[] = [ { pattern: "/api/config/theme", mod: r5 }, { pattern: "/api/config/theme-config", mod: r6 }, { pattern: "/api/config/themes", mod: r7 }, - { pattern: "/api/content/:rest*", mod: r8 }, - { pattern: "/api/content-editor/ws", mod: r9 }, - { pattern: "/api/csrf", mod: r10 }, - { pattern: "/api/dashboard", mod: r11 }, - { pattern: "/api/dev/apply", mod: r12 }, - { pattern: "/api/editor/parse", mod: r13 }, - { pattern: "/api/editor/serialize", mod: r14 }, - { pattern: "/api/email-preview", mod: r15 }, - { pattern: "/api/email-preview/:id", mod: r16 }, - { pattern: "/api/flex/:type", mod: r17 }, - { pattern: "/api/flex/:type/:id", mod: r18 }, - { pattern: "/api/history/:rest*", mod: r19 }, - { pattern: "/api/i18n/memory", mod: r20 }, - { pattern: "/api/i18n/memory/rebuild", mod: r21 }, - { pattern: "/api/i18n/mt-status", mod: r22 }, - { pattern: "/api/i18n/translate-page", mod: r23 }, - { pattern: "/api/i18n/translate-segment", mod: r24 }, - { pattern: "/api/inline-edit/presence", mod: r25 }, - { pattern: "/api/introspect", mod: r26 }, - { pattern: "/api/jobs", mod: r27 }, - { pattern: "/api/jobs/:name/run", mod: r28 }, - { pattern: "/api/media", mod: r29 }, - { pattern: "/api/media/meta", mod: r30 }, - { pattern: "/api/media/upload", mod: r31 }, - { pattern: "/api/metrics", mod: r32 }, - { pattern: "/api/page-source", mod: r33 }, - { pattern: "/api/pages", mod: r34 }, - { pattern: "/api/pages/reorder", mod: r35 }, - { pattern: "/api/pages/translate", mod: r36 }, - { pattern: "/api/pages/:path*", mod: r37 }, - { pattern: "/api/pages/:path*/comments", mod: r38 }, - { pattern: "/api/pages/:path*/comments/:id", mod: r39 }, - { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r40 }, - { pattern: "/api/plugins", mod: r41 }, - { pattern: "/api/plugins/install", mod: r42 }, - { pattern: "/api/plugins/:name/config", mod: r43 }, - { pattern: "/api/preview", mod: r44 }, - { pattern: "/api/registry/plugins", mod: r45 }, - { pattern: "/api/registry/themes", mod: r46 }, - { pattern: "/api/render-markdown", mod: r47 }, - { pattern: "/api/search/analytics", mod: r48 }, - { pattern: "/api/search/engines", mod: r49 }, - { pattern: "/api/sections", mod: r50 }, - { pattern: "/api/staging/:path*", mod: r51 }, - { pattern: "/api/staging/:path*/publish", mod: r52 }, - { pattern: "/api/theme-preview", mod: r53 }, - { pattern: "/api/themes/install", mod: r54 }, - { pattern: "/api/users", mod: r55 }, - { pattern: "/api/users/:id", mod: r56 }, - { pattern: "/api/users/:id/password", mod: r57 }, - { pattern: "/api/webhooks/deliveries", mod: r58 }, - { pattern: "/api/workflow/schedule", mod: r59 }, - { pattern: "/api/workflow/schedule/:id", mod: r60 }, - { pattern: "/api/workflow/scheduled/:path*", mod: r61 }, - { pattern: "/api/workflow/stages", mod: r62 }, - { pattern: "/api/workflow/status/:path*", mod: r63 }, - { pattern: "/api/workflow/transition", mod: r64 }, - { pattern: "/audit", mod: r65 }, - { pattern: "/collab/edit-ws", mod: r66 }, - { pattern: "/collab/ws", mod: r67 }, - { pattern: "/config", mod: r68 }, - { pattern: "/email-preview", mod: r69 }, - { pattern: "/flex", mod: r70 }, - { pattern: "/flex/:type", mod: r71 }, - { pattern: "/flex/:type/:id", mod: r72 }, - { pattern: "/i18n", mod: r73 }, - { pattern: "/i18n/memory", mod: r74 }, - { pattern: "/jobs", mod: r75 }, - { pattern: "/login", mod: r76 }, - { pattern: "/login/logout", mod: r77 }, - { pattern: "/marketplace", mod: r78 }, - { pattern: "/media", mod: r79 }, - { pattern: "/metrics", mod: r80 }, - { pattern: "/pages", mod: r81 }, - { pattern: "/pages/builder", mod: r82 }, - { pattern: "/pages/edit", mod: r83 }, - { pattern: "/pages/history", mod: r84 }, - { pattern: "/plugins", mod: r85 }, - { pattern: "/search", mod: r86 }, - { pattern: "/submissions", mod: r87 }, - { pattern: "/submissions/:form", mod: r88 }, - { pattern: "/submissions/:form/:id", mod: r89 }, - { pattern: "/submissions/:form/:id/delete", mod: r90 }, - { pattern: "/submissions/:form/:id/files/:filename", mod: r91 }, - { pattern: "/submissions/:form/:id/status", mod: r92 }, - { pattern: "/themes", mod: r93 }, - { pattern: "/users", mod: r94 }, + { pattern: "/api/content-editor/ws", mod: r8 }, + { pattern: "/api/csrf", mod: r9 }, + { pattern: "/api/dashboard", mod: r10 }, + { pattern: "/api/dev/apply", mod: r11 }, + { pattern: "/api/editor/parse", mod: r12 }, + { pattern: "/api/editor/serialize", mod: r13 }, + { pattern: "/api/email-preview", mod: r14 }, + { pattern: "/api/email-preview/:id", mod: r15 }, + { pattern: "/api/flex/:type", mod: r16 }, + { pattern: "/api/flex/:type/:id", mod: r17 }, + { pattern: "/api/history/:rest*", mod: r18 }, + { pattern: "/api/i18n/memory", mod: r19 }, + { pattern: "/api/i18n/memory/rebuild", mod: r20 }, + { pattern: "/api/i18n/mt-status", mod: r21 }, + { pattern: "/api/i18n/translate-page", mod: r22 }, + { pattern: "/api/i18n/translate-segment", mod: r23 }, + { pattern: "/api/inline-edit/presence", mod: r24 }, + { pattern: "/api/introspect", mod: r25 }, + { pattern: "/api/jobs", mod: r26 }, + { pattern: "/api/jobs/:name/run", mod: r27 }, + { pattern: "/api/media", mod: r28 }, + { pattern: "/api/media/meta", mod: r29 }, + { pattern: "/api/media/upload", mod: r30 }, + { pattern: "/api/metrics", mod: r31 }, + { pattern: "/api/page-source", mod: r32 }, + { pattern: "/api/pages", mod: r33 }, + { pattern: "/api/pages/reorder", mod: r34 }, + { pattern: "/api/pages/translate", mod: r35 }, + { pattern: "/api/pages/:path*", mod: r36 }, + { pattern: "/api/pages/:path*/comments", mod: r37 }, + { pattern: "/api/pages/:path*/comments/:id", mod: r38 }, + { pattern: "/api/pages/:path*/comments/:id/resolve", mod: r39 }, + { pattern: "/api/plugins", mod: r40 }, + { pattern: "/api/plugins/install", mod: r41 }, + { pattern: "/api/plugins/:name/config", mod: r42 }, + { pattern: "/api/preview", mod: r43 }, + { pattern: "/api/registry/plugins", mod: r44 }, + { pattern: "/api/registry/themes", mod: r45 }, + { pattern: "/api/render-markdown", mod: r46 }, + { pattern: "/api/search/analytics", mod: r47 }, + { pattern: "/api/search/engines", mod: r48 }, + { pattern: "/api/sections", mod: r49 }, + { pattern: "/api/staging/:path*", mod: r50 }, + { pattern: "/api/staging/:path*/publish", mod: r51 }, + { pattern: "/api/theme-preview", mod: r52 }, + { pattern: "/api/themes/install", mod: r53 }, + { pattern: "/api/users", mod: r54 }, + { pattern: "/api/users/:id", mod: r55 }, + { pattern: "/api/users/:id/password", mod: r56 }, + { pattern: "/api/webhooks/deliveries", mod: r57 }, + { pattern: "/api/workflow/schedule", mod: r58 }, + { pattern: "/api/workflow/schedule/:id", mod: r59 }, + { pattern: "/api/workflow/scheduled/:path*", mod: r60 }, + { pattern: "/api/workflow/stages", mod: r61 }, + { pattern: "/api/workflow/status/:path*", mod: r62 }, + { pattern: "/api/workflow/transition", mod: r63 }, + { pattern: "/audit", mod: r64 }, + { pattern: "/collab/edit-ws", mod: r65 }, + { pattern: "/collab/ws", mod: r66 }, + { pattern: "/config", mod: r67 }, + { pattern: "/email-preview", mod: r68 }, + { pattern: "/flex", mod: r69 }, + { pattern: "/flex/:type", mod: r70 }, + { pattern: "/flex/:type/:id", mod: r71 }, + { pattern: "/i18n", mod: r72 }, + { pattern: "/i18n/memory", mod: r73 }, + { pattern: "/jobs", mod: r74 }, + { pattern: "/login", mod: r75 }, + { pattern: "/login/logout", mod: r76 }, + { pattern: "/marketplace", mod: r77 }, + { pattern: "/media", mod: r78 }, + { pattern: "/metrics", mod: r79 }, + { pattern: "/pages", mod: r80 }, + { pattern: "/pages/builder", mod: r81 }, + { pattern: "/pages/edit", mod: r82 }, + { pattern: "/pages/history", mod: r83 }, + { pattern: "/plugins", mod: r84 }, + { pattern: "/search", mod: r85 }, + { pattern: "/submissions", mod: r86 }, + { pattern: "/submissions/:form", mod: r87 }, + { pattern: "/submissions/:form/:id", mod: r88 }, + { pattern: "/submissions/:form/:id/delete", mod: r89 }, + { pattern: "/submissions/:form/:id/files/:filename", mod: r90 }, + { pattern: "/submissions/:form/:id/status", mod: r91 }, + { pattern: "/themes", mod: r92 }, + { pattern: "/users", mod: r93 }, ]; /** Island files under src/admin/islands/, for Builder.registerIsland(). */ diff --git a/src/admin/page-events.ts b/src/admin/page-events.ts index da4a41e..a3e79ca 100644 --- a/src/admin/page-events.ts +++ b/src/admin/page-events.ts @@ -1,19 +1,19 @@ /** - * Announce a page write to plugins and content webhooks. + * Announce a page write to plugins. * * Whoever writes a page fires its event: core never does it for the writer, and * search indexes, webhooks and audit plugins only learn of a write through it. * Every admin route that creates, rewrites, deletes or moves a page calls this - * after the file is on disk. Fire-and-forget: a failing handler or an - * unreachable webhook never fails the request. + * after the file is on disk, naming the signed-in user as `actor`. The admin's + * own content webhooks are one of the listeners (registered in `mount()`), so + * they also see writes made elsewhere. Fire-and-forget: a failing handler never + * fails the request. * * @module */ import type { FreshContext } from "fresh"; import type { AdminState } from "./types.ts"; -import { fireContentWebhooks } from "./webhooks.ts"; -import { adminSettings } from "./settings.ts"; /** Paths are relative to the content directory. */ export type PageEvent = @@ -22,19 +22,12 @@ export type PageEvent = | { event: "onPageDelete"; payload: { sourcePath: string } } | { event: "onPageMove"; payload: { moves: Array<{ from: string; to: string }> } }; -/** Fire a page event to hook handlers and to the configured content webhooks. */ +/** Fire a page event to the hook handlers, with the signed-in user as `actor`. */ export function announcePageEvent(ctx: FreshContext, e: PageEvent): void { - const { hooks, config, services } = ctx.state.adminContext; - if (hooks) { - hooks.fire(e.event, e.payload).catch((err: unknown) => - console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) - ); - } - fireContentWebhooks( - adminSettings(config).webhooks, - e.event, - e.payload, - services.paths.runtimeDir, - services.security, + const { hooks } = ctx.state.adminContext; + const user = ctx.state.auth?.user; + const actor = user ? (user.username ?? user.name ?? user.id) : undefined; + hooks?.fire(e.event, actor ? { ...e.payload, actor } : e.payload).catch((err: unknown) => + console.warn(`[dune] ${e.event} hook failed: ${err instanceof Error ? err.message : err}`) ); } diff --git a/src/admin/routes/api/content/[...rest].ts b/src/admin/routes/api/content/[...rest].ts deleted file mode 100644 index 5a1285c..0000000 --- a/src/admin/routes/api/content/[...rest].ts +++ /dev/null @@ -1,157 +0,0 @@ -/** - * Inline editing content API (v0.16+). - * - * GET /admin/api/content/:encodedSourcePath/source - * Return the raw Markdown source of the content file (body only, no - * frontmatter). Used by the auto-overlay textarea editor to pre-populate - * the edit area with the current content. - * Response: `{ body: "# Heading\n..." }` - * - * POST /admin/api/content/:encodedSourcePath/commit - * Flush the live editing state to a history revision and write committed - * Markdown back to the `.md` file. This is the "Save" action. - * - * PATCH /admin/api/content/:encodedSourcePath/fields - * Update individual frontmatter fields without touching the Markdown body. - * Body: `{ fields: { title: "New title", date: "2026-06-08", ... } }` - * Special key `__body` replaces the Markdown body (used by auto-overlay). - * - * In all routes `:encodedSourcePath` is the URL-encoded page source path, - * e.g. `pages%2Fabout%2Fdefault.md`. - */ - -import type { AdminState } from "../../../types.ts"; -import { requirePermission, requireTsxWrite, requireOwnedPage, json, serverError, csrfCheck, validatePagePath } from "../_utils.ts"; -import { splitFile } from "../../../../collab/manager.ts"; -import type { FreshContext } from "fresh"; - -export const handler = { - // ── GET /admin/api/content/:path/source ───────────────────────────────────── - async GET(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.read"); - if (denied) return denied; - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2 || parts[1] !== "source") { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - - const { storage, config } = ctx.state.adminContext; - const contentDir = config.system.content.dir; - const filePath = `${contentDir}/${sourcePath}`; - - try { - const raw = await storage.readText(filePath); - const { body } = splitFile(raw); - return json({ body }); - } catch (err) { - return serverError(err); - } - }, - - // ── POST /admin/api/content/:path/commit ──────────────────────────────────── - async POST(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - // rest = "encodedPath/commit" or "encodedPath/fields" - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "commit") { - return json({ error: "Use PATCH for field updates" }, 405); - } - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.commit(sourcePath, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, - - // ── PATCH /admin/api/content/:path/fields ─────────────────────────────────── - async PATCH(ctx: FreshContext) { - const denied = await requirePermission(ctx, "pages.update"); - if (denied) return denied; - - const csrfErr = await csrfCheck(ctx); - if (csrfErr) return csrfErr; - - const { inlineEdit } = ctx.state.adminContext; - if (!inlineEdit) return json({ error: "Inline editing not enabled" }, 501); - - const rest = ctx.params.rest; - const parts = rest.split("/"); - - if (parts.length !== 2) { - return json({ error: "Invalid route" }, 400); - } - - const sourcePath = decodeURIComponent(parts[0]); - if (!validatePagePath(sourcePath)) return json({ error: "Invalid path" }, 400); - const tsxDenied = requireTsxWrite(ctx, sourcePath); - if (tsxDenied) return tsxDenied; - const ownerDenied = await requireOwnedPage(ctx, sourcePath); - if (ownerDenied) return ownerDenied; - - const action = parts[1]; - if (action !== "fields") { - return json({ error: "Use POST for commit" }, 405); - } - - let body: unknown; - try { - body = await ctx.req.json(); - } catch { - return json({ error: "Invalid JSON body" }, 400); - } - - if ( - !body || - typeof body !== "object" || - !("fields" in body) || - typeof (body as { fields: unknown }).fields !== "object" - ) { - return json({ error: "Body must be { fields: { ... } }" }, 400); - } - - const fields = (body as { fields: Record }).fields; - - const authResult = ctx.state.auth; - const author = authResult?.user?.username ?? "unknown"; - - try { - await inlineEdit.patchFields(sourcePath, fields, author); - return json({ ok: true }); - } catch (err) { - return serverError(err); - } - }, -}; diff --git a/src/admin/routes/api/pages/[...path]/index.ts b/src/admin/routes/api/pages/[...path]/index.ts index 2c60120..e9121c5 100644 --- a/src/admin/routes/api/pages/[...path]/index.ts +++ b/src/admin/routes/api/pages/[...path]/index.ts @@ -12,7 +12,7 @@ import type { AdminState } from "../../../../types.ts"; import { requirePermission, requireTsxWrite, requirePageOwner, requireOwnedPage, pageOwnerId, json, serverError, actorFromAuth, getClientIp, csrfCheck, validatePagePath } from "../../_utils.ts"; import { stringify as stringifyYaml } from "@std/yaml"; -import { fireContentWebhooks } from "../../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../../page-events.ts"; import type { PageFrontmatter } from "@dune/core/content/types"; import type { FreshContext } from "fresh"; import { adminSettings } from "../../../../settings.ts"; @@ -69,7 +69,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.update"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger, services } = ctx.state.adminContext; + const { engine, storage, config, auditLogger, services } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -130,10 +130,7 @@ export const handler = { await engine.rebuild(); await maybeGitCommit(filePath, pagePath, undefined, config); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageUpdate", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageUpdate", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageUpdate", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.update", @@ -157,7 +154,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.delete"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; const pagePath = decodeURIComponent(ctx.params.path); if (!validatePagePath(pagePath)) return json({ error: "Invalid path" }, 400); @@ -172,10 +169,7 @@ export const handler = { await storage.delete(`${contentDir}/${page.sourcePath}`); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageDelete", { sourcePath: page.sourcePath }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageDelete", { sourcePath: page.sourcePath }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageDelete", payload: { sourcePath: page.sourcePath } }); void auditLogger?.log({ event: "page.delete", diff --git a/src/admin/routes/api/pages/index.ts b/src/admin/routes/api/pages/index.ts index b8f9230..9b08eab 100644 --- a/src/admin/routes/api/pages/index.ts +++ b/src/admin/routes/api/pages/index.ts @@ -2,10 +2,9 @@ import type { AdminState } from "../../../types.ts"; import { requirePermission, requireTsxWrite, json, serverError, actorFromAuth, getClientIp, validatePagePath, csrfCheck } from "../_utils.ts"; -import { fireContentWebhooks } from "../../../../admin/webhooks.ts"; +import { announcePageEvent } from "../../../page-events.ts"; import { stringify as stringifyYaml } from "@std/yaml"; import type { FreshContext } from "fresh"; -import { adminSettings } from "../../../settings.ts"; export const handler = { async GET(ctx: FreshContext) { @@ -29,7 +28,7 @@ export const handler = { const denied = await requirePermission(ctx, "pages.create"); if (denied) return denied; - const { engine, storage, config, hooks, auditLogger } = ctx.state.adminContext; + const { engine, storage, config, auditLogger } = ctx.state.adminContext; const authResult = ctx.state.auth; try { const body = await ctx.req.json(); @@ -68,10 +67,7 @@ export const handler = { await storage.write(filePath, new TextEncoder().encode(fm + "\n" + defaultContent)); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = ctx.state.adminContext.services.paths.runtimeDir; - if (hooks) hooks.fire("onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onPageCreate", { sourcePath: `${pagePath}/default${ext}`, title }, runtimeDir, ctx.state.adminContext.services.security); + announcePageEvent(ctx, { event: "onPageCreate", payload: { sourcePath: `${pagePath}/default${ext}`, title } }); void auditLogger?.log({ event: "page.create", diff --git a/src/admin/routes/api/workflow/transition.ts b/src/admin/routes/api/workflow/transition.ts index 5d40f3e..0a42063 100644 --- a/src/admin/routes/api/workflow/transition.ts +++ b/src/admin/routes/api/workflow/transition.ts @@ -35,7 +35,7 @@ export const handler = { } const result = await applyWorkflowTransition( - { engine, storage, config, hooks, workflow, services: ctx.state.adminContext.services }, + { engine, storage, config, hooks, workflow }, sourcePath, newStatus, ); 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/src/admin/webhooks.ts b/src/admin/webhooks.ts index 8904e3a..b81f106 100644 --- a/src/admin/webhooks.ts +++ b/src/admin/webhooks.ts @@ -19,6 +19,7 @@ import { join } from "@std/path"; import { encodeHex } from "@std/encoding/hex"; import { isDuneError } from "@dune/core/plugin-api"; import type { SecurityService } from "@dune/core/plugin-api"; +import type { HookRegistry } from "@dune/core/hooks"; /** The slice of the security service webhook delivery needs. */ export type WebhookSecurity = Pick; @@ -295,6 +296,33 @@ export function fireContentWebhooks( }); } +/** The content events an endpoint can subscribe to. */ +const CONTENT_EVENTS: WebhookContentEvent[] = [ + "onPageCreate", + "onPageUpdate", + "onPageDelete", + "onPageMove", + "onWorkflowChange", +]; + +/** + * Deliver content webhooks for every page event that is fired, whoever fires + * it. `endpoints` is read per event, so a settings change applies without a + * restart. Delivery is fire-and-forget (see {@link fireContentWebhooks}). + */ +export function listenForContentWebhooks(opts: { + hooks: Pick; + endpoints: () => WebhookEndpointConfig[]; + runtimeDir: string; + security: WebhookSecurity; +}): void { + for (const event of CONTENT_EVENTS) { + opts.hooks.on(event, ({ data }) => { + fireContentWebhooks(opts.endpoints(), event, data, opts.runtimeDir, opts.security); + }); + } +} + export interface ListDeliveryLogsOptions { /** Maximum records to return (default: 50) */ limit?: number; diff --git a/src/admin/workflow-actions.ts b/src/admin/workflow-actions.ts index 1260aa7..9f094b2 100644 --- a/src/admin/workflow-actions.ts +++ b/src/admin/workflow-actions.ts @@ -12,14 +12,11 @@ * @module */ -import type { PluginServices } from "@dune/core/plugin-api"; import type { DuneEngine } from "@dune/core/engine"; import type { StorageAdapter } from "@dune/core/storage"; import type { DuneConfig } from "@dune/core/config"; import type { HookRegistry } from "@dune/core/hooks"; import type { WorkflowEngine } from "@dune/core/workflow"; -import { fireContentWebhooks } from "./webhooks.ts"; -import { adminSettings } from "./settings.ts"; export interface WorkflowActionContext { engine: DuneEngine; @@ -27,8 +24,6 @@ export interface WorkflowActionContext { config: DuneConfig; hooks?: HookRegistry; workflow: WorkflowEngine; - /** Core's services (webhook delivery uses the SSRF-guarded fetch). */ - services: PluginServices; } /** @@ -41,7 +36,7 @@ export async function applyWorkflowTransition( sourcePath: string, newStatus: string, ): Promise<{ from: string; to: string }> { - const { workflow, engine, storage, config, hooks, services } = ctx; + const { workflow, engine, storage, config, hooks } = ctx; const pageIndex = engine.pages.find((p) => p.sourcePath === sourcePath); if (!pageIndex) throw new Error(`Page not found: ${sourcePath}`); @@ -76,10 +71,8 @@ export async function applyWorkflowTransition( await storage.write(filePath, new TextEncoder().encode(updated)); await engine.rebuild(); - const webhookEndpoints = adminSettings(config).webhooks; - const runtimeDir = services.paths.runtimeDir; + // The admin's content webhooks listen to this event (see mount()). if (hooks) hooks.fire("onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }).catch(() => {}); - fireContentWebhooks(webhookEndpoints, "onWorkflowChange", { sourcePath, from: currentStatus, to: newStatus }, runtimeDir, services.security); return { from: currentStatus, to: newStatus }; } diff --git a/tests/admin/bar_actions_test.ts b/tests/admin/bar_actions_test.ts new file mode 100644 index 0000000..68840c8 --- /dev/null +++ b/tests/admin/bar_actions_test.ts @@ -0,0 +1,25 @@ +/** What the admin contributes to the inline-edit bar. */ + +import { assertEquals, assertStringIncludes } from "@std/assert"; +import { adminBarActions } from "../../src/admin/bar-actions.ts"; + +Deno.test("no bar buttons off content pages", () => { + assertEquals(adminBarActions("/admin", { page: null }), []); +}); + +Deno.test("an edit-source button and an open-in-admin link, at the admin's own prefix", () => { + const actions = adminBarActions("/panel", { page: { sourcePath: "03.work/01.test.mdx" } }); + assertEquals(actions.map((a) => a.id), ["admin:edit-source", "admin:open"]); + const expected = "/panel/pages/edit?path=03.work%2F01.test.mdx"; + assertEquals(actions[1].href, expected); + // The overlay shows the same page embedded; the link is a full-page navigation. + assertStringIncludes(actions[0].onClick ?? "", JSON.stringify(`${expected}&embedded=1`)); + assertEquals(actions[0].href, undefined); +}); + +Deno.test("the overlay script is valid JavaScript that builds an iframe lazily", () => { + const script = adminBarActions("/admin", { page: { sourcePath: "a.md" } })[0].onClick ?? ""; + new Function(script); // throws on a syntax error + assertStringIncludes(script, "createElement('iframe')"); + assertStringIncludes(script, "dune-source-overlay"); +}); diff --git a/tests/admin/capabilities_e2e_test.ts b/tests/admin/capabilities_e2e_test.ts index 4c22822..eee5d4a 100644 --- a/tests/admin/capabilities_e2e_test.ts +++ b/tests/admin/capabilities_e2e_test.ts @@ -9,7 +9,7 @@ import { join } from "@std/path"; import { bootstrap } from "@dune/core/bootstrap"; import { createDuneApp } from "@dune/core/fresh-app"; import type { DunePlugin } from "@dune/core/hooks"; -import { createAdminPlugin } from "../../mod.ts"; +import { ADMIN_EDITOR, ADMIN_PAGES, ADMIN_ROUTES, createAdminPlugin } from "../../mod.ts"; async function withSite( plugins: DunePlugin[], @@ -87,7 +87,7 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.pages", { + api.capabilities.contribute(ADMIN_PAGES, { path: "/my-plugin", label: "My plugin", permission: "pages.read", @@ -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", ); }); @@ -137,7 +137,7 @@ Deno.test( version: "1.0.0", hooks: {}, setup(api) { - api.capabilities.contribute("admin.routes", { + api.capabilities.contribute(ADMIN_ROUTES, { method: "POST", path: "/api/my-plugin/save", permission: "config.update", diff --git a/tests/admin/contributions_types_test.ts b/tests/admin/contributions_types_test.ts new file mode 100644 index 0000000..0f95112 --- /dev/null +++ b/tests/admin/contributions_types_test.ts @@ -0,0 +1,53 @@ +/** + * The admin declares its own extension points as typed keys. These checks fail to compile if + * the key typing, 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 { + ADMIN_EDITOR, + ADMIN_PAGES, + ADMIN_ROUTES, + type AdminPageRegistration, + type AdminRouteRegistration, + type ContentEditorPlugin, +} from "../../mod.ts"; + +Deno.test("the admin's keys type what is contributed and read", () => { + 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"); +}); diff --git a/tests/admin/page_events_test.ts b/tests/admin/page_events_test.ts index 5b7f25a..512eff3 100644 --- a/tests/admin/page_events_test.ts +++ b/tests/admin/page_events_test.ts @@ -96,11 +96,11 @@ Deno.test("admin page routes announce create, update, move and delete", async () await withSite(async ({ seen, call }) => { let res = await call("POST", "/admin/api/pages", { path: "blog", title: "Hello" }); assertEquals(res.ok, true); - assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello" } }]); + assertEquals(seen.splice(0), [{ event: "onPageCreate", data: { sourcePath: "blog/default.md", title: "Hello", actor: "ed" } }]); res = await call("PUT", "/admin/api/pages/blog/default.md", { content: "changed" }); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "blog/default.md", actor: "ed" } }]); // Move 02.beta before 01.alpha: both folders are renumbered. res = await call("POST", "/admin/api/pages/reorder", { @@ -116,12 +116,13 @@ Deno.test("admin page routes announce create, update, move and delete", async () { from: "02.beta", to: "01.beta" }, { from: "01.alpha", to: "02.alpha" }, ], + actor: "ed", }, }]); res = await call("DELETE", "/admin/api/pages/blog/default.md"); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageDelete", data: { sourcePath: "blog/default.md", actor: "ed" } }]); }); }); @@ -139,6 +140,6 @@ Deno.test("history restore announces onPageUpdate", async () => { const res = await call("POST", `/admin/api/history/01.alpha/default.md/${revisions[0].number}/restore`); assertEquals(res.status, 200); - assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md" } }]); + assertEquals(seen.splice(0), [{ event: "onPageUpdate", data: { sourcePath: "01.alpha/default.md", actor: "ed" } }]); }); }); diff --git a/tests/admin/webhook_listener_test.ts b/tests/admin/webhook_listener_test.ts new file mode 100644 index 0000000..3e768bb --- /dev/null +++ b/tests/admin/webhook_listener_test.ts @@ -0,0 +1,101 @@ +/** + * Content webhooks follow the page events, whoever fires them: a write from the + * inline-edit bar, MCP or the CLI reaches the admin's configured endpoints just + * like one from an admin route. + */ + +import { assertEquals } from "@std/assert"; +import { join } from "@std/path"; +import { listenForContentWebhooks } from "../../src/admin/webhooks.ts"; +import type { WebhookEndpointConfig } from "../../src/admin/settings.ts"; + +type Handler = (ctx: { data: unknown }) => void; + +function fakeHooks() { + const handlers = new Map(); + return { + on: (event: string, handler: Handler) => { + handlers.set(event, [...(handlers.get(event) ?? []), handler]); + }, + fire: (event: string, data: unknown) => (handlers.get(event) ?? []).forEach((h) => h({ data })), + events: () => [...handlers.keys()].sort(), + }; +} + +async function withReceiver(run: (url: string, received: unknown[]) => Promise) { + const received: unknown[] = []; + const server = Deno.serve({ port: 0, onListen: () => {} }, async (req) => { + received.push(await req.json()); + return new Response("ok"); + }); + try { + await run(`http://127.0.0.1:${server.addr.port}/`, received); + } finally { + await server.shutdown(); + } +} + +const security = { safeFetch: (url: string, init?: RequestInit) => fetch(url, init) }; + +async function settle(received: unknown[], count: number) { + for (let i = 0; i < 100 && received.length < count; i++) await new Promise((r) => setTimeout(r, 20)); +} + +Deno.test("a page event fired by anyone is delivered, with its actor", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + const endpoints: WebhookEndpointConfig[] = [{ url, events: ["onPageUpdate"], allow_private: true }]; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + assertEquals(hooks.events(), ["onPageCreate", "onPageDelete", "onPageMove", "onPageUpdate", "onWorkflowChange"]); + + // What plugin-inline-edit fires after a save. + hooks.fire("onPageUpdate", { sourcePath: "about/default.md", actor: "alice" }); + // Not subscribed: never delivered. + hooks.fire("onPageDelete", { sourcePath: "about/default.md" }); + await settle(received, 1); + await new Promise((r) => setTimeout(r, 100)); + + assertEquals(received.length, 1); + const body = received[0] as { event: string; payload: unknown }; + assertEquals(body.event, "onPageUpdate"); + assertEquals(body.payload, { sourcePath: "about/default.md", actor: "alice" }); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); + +Deno.test("the endpoint list is read per event, so a settings change applies at once", async () => { + const runtimeDir = await Deno.makeTempDir({ prefix: "dune_webhook_listener_" }); + try { + await withReceiver(async (url, received) => { + const hooks = fakeHooks(); + let endpoints: WebhookEndpointConfig[] = []; + listenForContentWebhooks({ + // deno-lint-ignore no-explicit-any + hooks: hooks as any, + endpoints: () => endpoints, + runtimeDir, + security, + }); + hooks.fire("onPageCreate", { sourcePath: "a/default.md", title: "A" }); + await new Promise((r) => setTimeout(r, 100)); + assertEquals(received.length, 0); + + endpoints = [{ url, events: ["onPageCreate"], allow_private: true }]; + hooks.fire("onPageCreate", { sourcePath: "b/default.md", title: "B" }); + await settle(received, 1); + assertEquals(received.length, 1); + }); + } finally { + await Deno.remove(runtimeDir, { recursive: true }).catch(() => {}); + } +}); diff --git a/tests/admin/workflow_actions_test.ts b/tests/admin/workflow_actions_test.ts index f30c3df..012bb38 100644 --- a/tests/admin/workflow_actions_test.ts +++ b/tests/admin/workflow_actions_test.ts @@ -11,7 +11,6 @@ * executeScheduledAction-shaped callback actually applies the transition. */ -import { makeServices } from "../helpers/services.ts"; import { assertEquals, assertRejects } from "https://deno.land/std@0.224.0/assert/mod.ts"; import type { PageIndex } from "@dune/core/content/types"; import type { DuneConfig } from "@dune/core/config"; @@ -80,7 +79,7 @@ Deno.test("applyWorkflowTransition: patches status and published, rebuilds the i const result = await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "01.blog/01.hello/default.md", "published", ); @@ -105,7 +104,7 @@ Deno.test("applyWorkflowTransition: throws for an unknown sourcePath", async () () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "does/not/exist.md", "published", ), @@ -125,7 +124,7 @@ Deno.test("applyWorkflowTransition: rejects a status value with invalid characte () => applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, "01.blog/01.hello/default.md", "not\nvalid", ), @@ -172,7 +171,7 @@ Deno.test("Scheduler + executeScheduledAction-shaped callback: a due action actu const newStatus = SCHEDULED_ACTION_STATUS[action.action]; await applyWorkflowTransition( // deno-lint-ignore no-explicit-any - { engine: engine as any, storage, config: CONFIG, workflow, services: makeServices(storage) }, + { engine: engine as any, storage, config: CONFIG, workflow }, action.sourcePath, newStatus, );