Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5480f7e
feat(config)!: runtime_dir and max_revisions are core settings, not a…
zumbrunn Nov 6, 2026
0ea5229
feat(plugin-api)!: the admin declares where it is mounted (admin.pref…
zumbrunn Nov 6, 2026
439d7e4
chore: drop the deno.lock change that slipped into the step 2 commit
zumbrunn Nov 6, 2026
8ad89d5
feat(plugins)!: core no longer loads the admin, it is a plugin you li…
zumbrunn Nov 6, 2026
f72041b
feat(plugin-api)!: machine translation and the audit trail leave core…
zumbrunn Nov 6, 2026
3e609c4
feat(config)!: the admin's settings are the admin plugin's config, no…
zumbrunn Nov 7, 2026
145b7ab
feat(config)!: machine_translation moves into the admin plugin's conf…
zumbrunn Nov 7, 2026
c5de0fa
chore: drop the deno.lock change that slipped into the step 5 commits
zumbrunn Nov 7, 2026
b0d38c0
fix(authz)!: site users' roles grant editing, checked on app:site (#130)
zumbrunn Nov 7, 2026
2191190
fix(authz): a role granted or revoked in a running app takes effect a…
zumbrunn Nov 7, 2026
ca675bb
Merge origin/main into feat/explicit-admin-loading
zumbrunn Nov 7, 2026
bf41539
Merge feat/explicit-admin-loading into feat/mt-audit-to-admin
zumbrunn Nov 7, 2026
25067b4
Merge feat/mt-audit-to-admin into feat/admin-settings-in-plugin
zumbrunn Nov 7, 2026
87db9e4
Merge feat/admin-settings-in-plugin into fix/site-user-editing
zumbrunn Nov 7, 2026
d3e5c3a
feat(plugin-api)!: the contract keeps what core consumes; plugins dec…
zumbrunn Nov 7, 2026
292a3dd
feat(plugin-api)!: transforms read capabilities from ResponseTransfor…
zumbrunn Nov 7, 2026
3c1b2be
docs: the unprefixed capability namespaces are editor, scheduler and …
zumbrunn Nov 7, 2026
979fc52
fix: headless smoke boots without the admin entry; headless scaffold …
zumbrunn Nov 8, 2026
c5ed518
fix(auth): log a failed role-grant bootstrap, own-property override l…
zumbrunn Nov 8, 2026
5f85916
Merge branch 'feat/explicit-admin-loading' into feat/mt-audit-to-admin
zumbrunn Nov 8, 2026
0588f2b
Merge branch 'feat/mt-audit-to-admin' into feat/admin-settings-in-plugin
zumbrunn Nov 8, 2026
42f5f23
test: the overlay admin.enabled warning, with #131's manual-move exit
zumbrunn Nov 8, 2026
e648e84
Merge branch 'feat/admin-settings-in-plugin' into fix/site-user-editing
zumbrunn Nov 8, 2026
b204168
chore: keep deno.lock as on the base branch
zumbrunn Nov 8, 2026
2d41672
Merge branch 'fix/site-user-editing' into refactor/contract-split
zumbrunn Nov 8, 2026
84547bd
fix(cli): headless scaffold registers plugin islands with registerIsland
zumbrunn Nov 8, 2026
83b8207
Merge remote-tracking branch 'origin/feat/explicit-admin-loading' int…
zumbrunn Nov 10, 2026
db082f9
Merge origin/main into feat/mt-audit-to-admin
zumbrunn Nov 10, 2026
7445cfb
fix(migrate:config): keep core settings of a disabled admin block; ne…
zumbrunn Nov 10, 2026
89a25ec
Merge remote-tracking branch 'origin/feat/mt-audit-to-admin' into fea…
zumbrunn Nov 10, 2026
5672444
Merge remote-tracking branch 'origin/main' into feat/admin-settings-i…
zumbrunn Nov 10, 2026
2b50403
Merge remote-tracking branch 'origin/feat/admin-settings-in-plugin' i…
zumbrunn Nov 10, 2026
70f485b
Merge remote-tracking branch 'origin/main' into fix/site-user-editing
zumbrunn Nov 10, 2026
2cd6e29
Merge remote-tracking branch 'origin/fix/site-user-editing' into refa…
zumbrunn Nov 10, 2026
42a1cff
Merge origin/main into refactor/contract-split
zumbrunn Nov 10, 2026
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,18 @@ where the setting lives now (see `dune migrate:config`, which reports these too)

### Changed — BREAKING

- **The contract keeps only what core consumes; each plugin declares its own extension points
(#127).** `AdminPageRegistration`, `AdminRouteRegistration`, `ContentEditorPlugin`,
`AdminBarAction` and `AdminBarActionContext` leave `@dune/core/plugins` and
`@dune/plugin-api`; core never read them. The keys `admin.pages`, `admin.routes`,
`editor.content` (now `admin.editor`) and `bar.actions` leave `Capabilities` /
`Contributions` and belong to the plugin that reads them (`@dune/plugin-admin`,
`@dune/plugin-inline-edit`), which declares them by declaration merging. Both interfaces are
now open to merging; the rule is that a key is prefixed with the name of the plugin that
consumes it, and unprefixed names are core's. At runtime nothing changes: the registry was
already keyed by string. `ResponseTransformContext.barActions` and `.adminPrefix` are replaced
by `ResponseTransformContext.capabilities`, the same reader `MountApi.capabilities` offers: a
bar-owning plugin reads `list("bar.actions")` and `get("admin.prefix")` per request.
- **Site-wide permissions are checked on `app:site`, not `app:admin` (#130).** `pages.update`
and the other admin-tier actions name what they protect (this site's content), not the panel
that happens to administer it, so the same check works on a site with no admin, one admin
Expand Down
27 changes: 14 additions & 13 deletions src/cli/response-transforms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ interface AdminAuthMiddleware {
authenticate(req: Request): Promise<unknown>;
}
import type { DuneConfig } from "../config/types.ts";
import type { AdminBarAction, AdminBarActionContext, DunePlugin, ResponseTransformContext } from "../hooks/types.ts";
import type { DunePlugin, ResponseTransformContext } from "../hooks/types.ts";
import type { CapabilityReader } from "../plugin-api/capabilities.ts";

import type { DuneAuthSystem } from "../auth/authz.ts";
import type { PageIndex } from "../content/types.ts";
import { highestAdminRole, roleHasPermission, SITE_RESOURCE } from "../auth/authz-schema.ts";
Expand All @@ -47,6 +49,13 @@ import { hasSessionCookie } from "./admin-bar-inject.ts";
import { isAdminPath } from "./serve-utils.ts";
import { scrubMarkersFromResponse } from "./marker-scrub.ts";

/** What a transform sees when the caller supplies no registry: nothing is provided. */
const EMPTY_CAPABILITIES: CapabilityReader = {
get: () => undefined,
list: () => [],
providers: () => [],
};

/**
* Narrow shape accepted from a route resolver's result — just the fields
* this module actually reads. `RouteResolver.resolve()` (`routing/resolver.ts`)
Expand All @@ -72,11 +81,8 @@ export interface RunResponseTransformsOptions {
auth: AdminAuthMiddleware | null;
/** Cookies that carry the `editor.session` provider's session (`RequestAuthenticator.sessionCookies`); omitted means any cookie. */
sessionCookies?: readonly string[];
/**
* Bar buttons contributed to the `bar.actions` point, in plugin registration
* order. Handed to transform plugins as `ResponseTransformContext.barActions`.
*/
barActions?: readonly ((ctx: AdminBarActionContext) => AdminBarAction[])[];
/** What plugins provide; handed to transform plugins as `ResponseTransformContext.capabilities`. Omitted means nothing is provided. */
capabilities?: CapabilityReader;
/**
* Polizy authz system — the sole authority for the `pages.update` check
* below (dec-identity-unification Phase 5c/7). Undefined only when authz
Expand Down Expand Up @@ -134,8 +140,8 @@ export async function runPluginResponseTransforms(
config,
adminPrefix,
actionToRelations,
barActions = [],
sessionCookies,
capabilities = EMPTY_CAPABILITIES,
} = opts;

const transformPlugins = plugins.filter((p) => p.transformResponse);
Expand Down Expand Up @@ -202,12 +208,7 @@ export async function runPluginResponseTransforms(
template: matchedPage.template,
}
: null,
// "" when no admin is installed: plugins that build admin links only do so for a signed-in admin.
adminPrefix: adminPrefix ?? "",
// A plugin can contribute to `bar.actions` without declaring
// transformResponse itself, so the list comes from the contribution
// point, not from the plugins selected to run a transform.
barActions,
capabilities,
});
}

Expand Down
183 changes: 8 additions & 175 deletions src/hooks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,133 +249,6 @@ export interface PublicRouteRegistration {
island?: string;
}

/**
* Context passed to a `bar.actions` contribution.
*
* Deliberately the same narrowed shape {@link ResponseTransformContext}
* passes as `page`/`adminPrefix` (plus `template`, which actions need to
* decide what to show but `transformResponse` itself has never needed) —
* a plugin contributing bar actions is answering "what belongs on the bar
* for this response," the same question `transformResponse` answers for
* the page body.
*
* @since 1.2.0
*/
export interface AdminBarActionContext {
/** Content page matching the current URL, or null for non-content routes. */
page: { sourcePath: string; route: string; title: string | null; template: string } | null;
/** Admin panel URL prefix (e.g. `"/admin"`). */
adminPrefix: string;
}

/**
* A button contributed to the shared admin bar UI through the `bar.actions`
* contribution point. Rendered by whichever plugin owns the
* bar (e.g. `@dune/plugin-inline-edit`) — the contributing plugin controls
* only the button's appearance and behavior, not its position or styling.
*
* @since 1.2.0
*/
export interface AdminBarAction {
/**
* Unique id — namespace by plugin name to avoid collisions across
* plugins, e.g. `"pdf-export:download"`.
*/
id: string;
/** Button label. */
label: string;
/** Optional emoji/icon prefix. */
icon?: string;
/**
* Renders as a plain link (`<a href>`) when set — the common case for
* downloads and navigation. Mutually exclusive with `onClick`; `href`
* takes precedence if both are set.
*/
href?: string;
/**
* Renders as a `<button>` running this literal JavaScript on click, when
* `href` is not set. Trusted content from a loaded plugin, not user
* input — no sanitization is applied, the same trust level as any other
* server-rendered admin-only script.
*/
onClick?: string;
}

/**
* 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` itself: whichever
* admin package provides the admin UI reads the list (`capabilities.list("admin.pages")`)
* and mounts each page as a programmatic route, enforcing its declared
* `permission`. A site without an admin package gets no admin pages; there is
* no core fallback, because enforcing `permission` needs an admin-specific
* auth system that `@dune/core` has no reason to depend on.
*
* @since 0.7.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 })`.
* Whichever admin package provides the admin UI 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 an admin package 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 0.36.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 definition interface.
*
Expand Down Expand Up @@ -540,7 +413,7 @@ export interface DunePlugin {
* @example
* ```ts
* // Gate a plugin-specific admin capability behind its own permission,
* // reachable from an `admin.routes` contribution:
* // reachable from an `admin.routes` contribution (the admin plugin's extension point):
* // api.capabilities.contribute("admin.routes", { method: "POST", path: "/billing/refund", permission: "billing.manage", handler })
* export default {
* name: "my-billing-plugin",
Expand Down Expand Up @@ -633,43 +506,6 @@ export interface DunePlugin {
) => Promise<Response> | Response;
}

/**
* Plugin-provided replacement for the built-in block editor.
*
* Provide it as the `editor.content` capability: `api.capabilities.provide("editor.content", () => impl)`.
* The edit route delegates to `pageEditorHandler` instead of the default block editor.
*
* Defined in core (not in `@dune/plugin-admin`) so that alternative editor plugins can
* type their implementation without taking a dependency on the admin plugin itself.
*
* @since 0.24.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;
}

/**
* Context passed to {@link DunePlugin.transformResponse}.
*
Expand Down Expand Up @@ -717,21 +553,18 @@ export interface ResponseTransformContext {
/**
* Content page matching the current URL, or null for non-content routes
* (admin paths, API paths, plugin routes, theme static assets, etc.).
* `template` was added in 1.2.0 alongside the bar actions — existing
* consumers destructuring the other four fields are unaffected.
*/
page: { sourcePath: string; route: string; title: string | null; template: string } | null;
/** Admin panel URL prefix (e.g. `"/admin"`). */
adminPrefix: string;
/**
* Bar buttons contributed through the `bar.actions` capability point
* (`PluginApi.capabilities.contribute("bar.actions", fn)`), in plugin
* registration order. Call each with the bar action context. A bar-owning
* plugin (e.g. `@dune/plugin-inline-edit`) collects other plugins' buttons here.
* What plugins provide, as `MountApi.capabilities` offers it: the slots
* (`get("admin.prefix")`, `get("auth.session")`) and any extension point a
* plugin declares (`list("bar.actions")`). A transform reads what it needs
* here per request, so a plugin module shared between sites keeps no state
* of its own.
*
* @since 0.36.0
*/
barActions: readonly ((ctx: AdminBarActionContext) => AdminBarAction[])[];
capabilities: import("../plugin-api/capabilities.ts").CapabilityReader;
}

/**
Expand All @@ -758,7 +591,7 @@ export interface MountApi {
/** The same services object `setup()` received (`PluginApi.services`). */
services: import("../plugin-api/services.ts").PluginServices;
/**
* What plugins provide: `capabilities.get("editor.session")`, `.list("admin.pages")`.
* What plugins provide: `capabilities.get("editor.session")`, and any extension point a plugin declares (the admin's `admin.pages`, say).
* Slots are resolved before `mount()` runs; read them lazily (at request
* time) rather than assuming a provider exists.
*/
Expand Down
45 changes: 24 additions & 21 deletions src/plugin-api/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,7 @@
import type { StorageAdapter } from "../storage/types.ts";
import type { DuneConfig } from "../config/types.ts";
import type { HistoryEngine } from "../history/mod.ts";
import type {
AdminBarAction,
AdminBarActionContext,
AdminPageRegistration,
AdminRouteRegistration,
ContentEditorPlugin,
HookRegistry,
} from "../hooks/types.ts";
import type { HookRegistry } from "../hooks/types.ts";
import type { InlineEditManager } from "../inline-edit/types.ts";
import type { ScheduledAction } from "../workflow/types.ts";
import type { PluginServices } from "./services.ts";
Expand Down Expand Up @@ -91,8 +84,22 @@ export interface AdminPrefix {
}

/**
* Slots. An `interface` so a later release can open it to declaration merging;
* 1.0 ships first-party keys only and third-party keys are unsupported.
* Slots: what core itself consumes, or what every plugin may rely on.
*
* Open to declaration merging. A plugin that offers an extension point of its
* own declares the key next to its types, so core's contract does not grow with
* every plugin's features:
*
* ```ts
* declare module "@dune/plugin-api" {
* interface Capabilities { "admin.editor": ContentEditorPlugin }
* }
* ```
*
* The rule: **a key is prefixed with the name of the plugin that consumes it**
* (`admin.*` is the admin's, `bar.*` plugin-inline-edit's). Unprefixed
* namespaces (`editor`, `scheduler`, `admin.prefix`) belong to core.
* Core never reads a key it did not declare.
*/
export interface Capabilities {
/** Where the admin is mounted. Consumers: core's request handling, static build and plugin-route registration. */
Expand All @@ -103,19 +110,15 @@ export interface Capabilities {
"scheduler.executor": ScheduledActionExecutor;
/** Real-time inline editing. Consumers: core's `/api/inline-edit/ws`, the admin. */
"editor.inline": InlineEditManager;
/** A replacement page editor. Consumer: the admin. */
"editor.content": ContentEditorPlugin;
}

/** Contribution points. */
export 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;
/** Buttons for the admin bar. Consumer: the bar renderer, via core's response transforms. */
"bar.actions": (ctx: AdminBarActionContext) => AdminBarAction[];
}
/**
* Contribution points: one item from each contributing plugin. Open to
* declaration merging under the same rule as {@link Capabilities}. Core
* declares none: every point today belongs to the plugin that reads it.
*/
// deno-lint-ignore no-empty-interface
export interface Contributions {}

/** What a slot factory receives: core infrastructure that exists once `setup()`s have settled. */
export interface CapabilityContext {
Expand Down
Loading
Loading