Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
a558052
feat: declarations for the app, storage, content API, search-engine a…
zumbrunn Nov 6, 2026
4d6f70f
feat: services, identity, engines, history and blueprint declarations…
zumbrunn Nov 6, 2026
b03cf73
feat: plugin object, hooks, capabilities, config and runtime declarat…
zumbrunn Nov 6, 2026
82f2fbf
refactor: declare only the User and PageIndex fields plugins read
zumbrunn Nov 6, 2026
5a66402
feat: keep the OAuth identity fields and UserStore provider methods
zumbrunn Nov 6, 2026
fb57ee5
feat: admin.prefix capability slot
zumbrunn Nov 6, 2026
1141d11
docs: AuthzService seeds the site resource and says when authz exists
zumbrunn Nov 7, 2026
aa90036
chore: use the editor.session and editor.inline slot names (duneorg/d…
zumbrunn Nov 7, 2026
0f67309
feat!: the contract keeps what core consumes; plugins declare their o…
zumbrunn Nov 7, 2026
1d8cd42
feat!: mirror the inline-edit HTTP gate (EditorIdentity, handleReques…
zumbrunn Nov 7, 2026
006824f
docs: changelog for the inline-edit HTTP gate and page-event actor (#…
zumbrunn Nov 7, 2026
239a71a
feat!: typed capability keys (CapabilityKey, capabilityKey) instead o…
zumbrunn Nov 8, 2026
bd1ef4a
test: isDuneError (CI's test task needs a tests/ directory)
zumbrunn Nov 10, 2026
d224ba9
Merge branch 'feat/plugin-contract' into feat/own-contributions
zumbrunn Nov 10, 2026
8b30bbc
style: deno fmt the changelog
zumbrunn Nov 10, 2026
92bb233
Merge branch 'feat/own-contributions' into feat/inline-edit-routes
zumbrunn Nov 10, 2026
6d77258
test: capabilityKey
zumbrunn Nov 10, 2026
94e788a
Merge remote-tracking branch 'origin/main' into HEAD
zumbrunn Nov 10, 2026
e26133d
ci: run the checks now that this PR targets main
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
25 changes: 17 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,17 @@ Slice 2b (the plugin object, hooks, capabilities and the runtime):
`AdminPageRegistration`, `AdminRouteRegistration` and `ContentEditorPlugin`
(and the keys `admin.pages`, `admin.routes`, `admin.editor`) are declared by
`@dune/plugin-admin`; the bar's `BarAction`, `BarActionContext` and
`bar.actions` by `@dune/plugin-inline-edit`. They extend `Capabilities` and
`Contributions` by declaration merging, which this package supports and keys
by the consuming plugin's name (`admin.*`, `bar.*`); unprefixed names are the
contract's. `ResponseTransformContext` carries `capabilities` (the reader
`MountApi` offers) instead of `adminPrefix` and `barActions`.
- Capabilities: `Capabilities` (slots), `Contributions`, `CapabilityContext`,
`CapabilityFactory`, `CapabilityRegistrar`, `CapabilityReader`,
`CapabilityProvider`, `RequestAuthenticator`, `ScheduledActionExecutor`.
`bar.actions` by `@dune/plugin-inline-edit`, as typed keys made with
`capabilityKey<T>(name)` and named after the consuming plugin (`admin.*`,
`bar.*`); unprefixed names are the contract's. `provide`, `contribute`, `get`
and `list` accept a key. There is no module augmentation, so `Contributions`
is gone and `Capabilities` is closed. `ResponseTransformContext` carries
`capabilities` (the reader `MountApi` offers) instead of `adminPrefix` and
`barActions`.
- Capabilities: `Capabilities` (slots), `CapabilityKey`, `capabilityKey`,
`CapabilityContext`, `CapabilityFactory`, `CapabilityRegistrar`,
`CapabilityReader`, `CapabilityProvider`, `RequestAuthenticator`,
`ScheduledActionExecutor`.
- A narrow `DuneConfig` (`site`, `system.content.dir`, `system.languages`,
`system.debug`, `theme.name`, `plugins`); `admin` config is not part of it.
- `AuthzRelation` and a structural `PluginAuthz` (`check`, `allow`,
Expand All @@ -79,3 +82,9 @@ Meilisearch plugin). Core's own types carry the omitted fields.
Slot added: `admin.prefix` (`AdminPrefix { readonly prefix: string }`), where
the admin is mounted. The admin provides it; core reads it instead of assuming
`/admin`. With no provider there is no admin.

- `InlineEditManager` gains
`handleRequest(req, { editor, path, pageFrontmatter })` and loses `commit` /
`patchFields`; `EditorIdentity` and `InlineEditRequestContext` are declared.
Page-event payloads (`onPageMove`'s `PageMovePayload`, and the others by
documentation) carry an optional `actor`.
65 changes: 36 additions & 29 deletions src/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
*
* Two shapes: a **slot** has at most one active provider
* ({@link Capabilities}); a **contribution point** collects one item from each
* contributing plugin ({@link Contributions}).
* contributing plugin. A plugin that other plugins can extend declares its own
* keys as typed tokens ({@link CapabilityKey}, made with {@link capabilityKey}),
* not by module augmentation, which JSR does not accept in a published package.
*
* @module
*/
Expand Down Expand Up @@ -70,23 +72,9 @@ export interface AdminPrefix {
}

/**
* Slots: at most one active provider each. 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 this 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, and core
* never reads a key it did not declare.
* Slots the contract itself consumes: at most one active provider each. The
* list is closed. An extension point that belongs to a plugin is a
* {@link CapabilityKey} that plugin exports.
*/
export interface Capabilities {
/** Where the admin is mounted. */
Expand All @@ -95,17 +83,37 @@ export interface Capabilities {
"editor.session": RequestAuthenticator;
/** Executes due scheduled actions. */
"scheduler.executor": ScheduledActionExecutor;
/** Real-time inline editing. */
/** Real-time inline editing and its HTTP routes. */
"editor.inline": InlineEditManager;
}

/**
* Contribution points: one item from each contributing plugin. Open to
* declaration merging under the same rule as {@link Capabilities}. The contract
* declares none: every point belongs to the plugin that reads it.
* A typed name for a slot or contribution point that a plugin declares.
* Create it once with {@link capabilityKey} and export it:
*
* ```ts
* export const BAR_ACTIONS: CapabilityKey<(ctx: BarActionContext) => BarAction[]> =
* capabilityKey("bar.actions");
* // contributor: api.capabilities.contribute(BAR_ACTIONS, ({ page }) => [...])
* // consumer: capabilities.list(BAR_ACTIONS)
* ```
*
* **Prefix the name with the plugin that consumes it** (`admin.*` is the
* admin's, `bar.*` plugin-inline-edit's); unprefixed namespaces belong to the
* contract. Two keys with the same name are the same capability at runtime, so
* a plugin may recreate another plugin's key from its documented name instead of
* importing it.
*/
// deno-lint-ignore no-empty-interface
export interface Contributions {}
export interface CapabilityKey<T> {
readonly name: string;
/** Phantom: carries `T` for the type checker only. Never set. */
readonly __type?: T;
}

/** Make a {@link CapabilityKey}. */
export function capabilityKey<T>(name: string): CapabilityKey<T> {
return { name };
}

/** What a slot factory receives: core infrastructure that exists once every `setup()` has settled. */
export interface CapabilityContext {
Expand All @@ -132,10 +140,8 @@ export interface CapabilityRegistrar {
name: K,
factory: CapabilityFactory<Capabilities[K]>,
): void;
contribute<K extends keyof Contributions>(
point: K,
item: Contributions[K],
): void;
provide<T>(key: CapabilityKey<T>, factory: CapabilityFactory<T>): void;
contribute<T>(key: CapabilityKey<T>, item: T): void;
}

/** One entry of {@link CapabilityReader.providers}. */
Expand All @@ -152,8 +158,9 @@ export interface CapabilityProvider {
export interface CapabilityReader {
/** The active provider of a slot, or undefined. */
get<K extends keyof Capabilities>(name: K): Capabilities[K] | undefined;
get<T>(key: CapabilityKey<T>): T | undefined;
/** Everything contributed to a point, in plugin registration order. */
list<K extends keyof Contributions>(point: K): readonly Contributions[K][];
list<T>(key: CapabilityKey<T>): readonly T[];
/** Who provides what, for `dune doctor`. */
providers(): CapabilityProvider[];
}
13 changes: 11 additions & 2 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,18 @@ export type HookEvent =
| "onFormSubmit"
| "onSystemAction";

/** Payload of `onPageMove`: pages were renamed, renumbered or reordered. */
/**
* Payload of `onPageMove`: pages were renamed, renumbered or reordered.
*
* The other page events carry `actor?: string` the same way: `onPageCreate`
* `{ sourcePath, title, actor? }`, `onPageUpdate` and `onPageDelete`
* `{ sourcePath, actor? }`. Whoever writes the page fires the event and fills
* `actor` when a person is behind the write.
*/
export interface PageMovePayload {
moves: Array<{ from: string; to: string }>;
/** Who did it (the user's name or id), when a person did; absent for CLI and automated writes. */
actor?: string;
}

/** Payload of `onFormSubmit`: a public form submission was accepted and stored. */
Expand Down Expand Up @@ -229,7 +238,7 @@ export interface ResponseTransformContext {
/**
* What plugins provide, as `MountApi.capabilities` offers it: slots such as
* `get("admin.prefix")` and any extension point a plugin declares, such as
* `list("bar.actions")`. Read per request, so a plugin module shared between
* `list(BAR_ACTIONS)`. Read per request, so a plugin module shared between
* sites keeps no state of its own.
*/
capabilities: import("./capabilities.ts").CapabilityReader;
Expand Down
73 changes: 49 additions & 24 deletions src/inline-edit.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
/**
* Inline editing service port.
*
* Core defines this interface and consumes it from the admin panel routes
* (`/api/inline-edit/ws`, the presence endpoint, and the content commit /
* field-patch endpoints). It does not ship an implementation: a plugin
* provides one as the `editor.inline` capability (e.g.
* `jsr:@dune/plugin-inline-edit`), and the endpoints respond 501 when
* no plugin has filled the slot.
* Core owns who may edit: after the site's authentication has run it resolves
* the editor (a site user, or whoever the `editor.session` provider signs in),
* checks `pages.update` and, for requests that change something, the request's
* origin. It then hands the request to the plugin that provides the
* `editor.inline` capability (e.g. `jsr:@dune/plugin-inline-edit`) at
* `/api/inline-edit/ws` (a WebSocket upgrade) and `/api/inline-edit/*` (plain
* requests). Both respond 501 when no plugin has filled the slot.
*
* The wire protocol spoken over the WebSocket — and any client-side editor
* UI — is entirely the providing plugin's concern; core treats both as
* opaque.
* The wire protocol spoken over the WebSocket, the HTTP routes below
* `/api/inline-edit/` and any client-side editor UI are entirely the providing
* plugin's concern; core treats them as opaque.
*/

// ── Presence ──────────────────────────────────────────────────────────────────
Expand All @@ -29,6 +30,38 @@ export interface DocumentPresence {
editors: ActiveEditor[];
}

// ── Editor ────────────────────────────────────────────────────────────────────

/**
* Who is editing: an authenticated user that core has already checked for
* `pages.update`. `source` says where the identity came from.
*/
export interface EditorIdentity {
id: string;
/** Display name: the username, else the name, else the email. */
username: string;
/** Every role on the user record (admin-tier roles and content-gating tags mixed). */
roles: string[];
/** `site`: signed in through the site's own login; `session`: through the `editor.session` provider. */
source: "site" | "session";
}

/** What core hands {@link InlineEditManager.handleRequest} besides the request. */
export interface InlineEditRequestContext {
/** The editor, already authenticated and checked for `pages.update`. */
editor: EditorIdentity;
/**
* The path below `/api/inline-edit/`, without a leading slash and still
* percent-encoded (`content/pages%2Fabout%2Fdefault.md/commit`).
*/
path: string;
/**
* Frontmatter of an indexed page, or null when `sourcePath` is not a page of
* this site. Lets a handler apply its own ownership rules.
*/
pageFrontmatter(sourcePath: string): Promise<Record<string, unknown> | null>;
}

// ── Manager ───────────────────────────────────────────────────────────────────

/** Public interface for an inline editing server manager. */
Expand All @@ -41,22 +74,14 @@ export interface InlineEditManager {
handleUpgrade(req: Request, user: { id: string; name: string }): Response;

/**
* Flush the live editing state for `sourcePath` to a history revision
* and write the committed Markdown back to the `.md` file.
* This is the "Save" action triggered by the client.
*/
commit(sourcePath: string, author: string): Promise<void>;

/**
* Patch one or more frontmatter fields on a content file.
* Writes through the history engine (creates a revision) and patches the
* `.md` file on disk. Does not touch the Markdown body.
* Handle a plain HTTP request below `/api/inline-edit/` (everything except
* the WebSocket upgrade). Core has authenticated the editor, checked
* `pages.update` and, for POST, PUT, PATCH and DELETE, that the request is
* same-origin; the handler enforces anything page-specific (which page types
* and owners this editor may write) and fires the `onPage*` events for what
* it writes. Return 404 for a path it does not serve.
*/
patchFields(
sourcePath: string,
fields: Record<string, unknown>,
author: string,
): Promise<void>;
handleRequest(req: Request, ctx: InlineEditRequestContext): Promise<Response>;

/**
* Return live presence data — which documents have active editing
Expand Down
5 changes: 4 additions & 1 deletion src/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ export type {
export type {
ActiveEditor,
DocumentPresence,
EditorIdentity,
InlineEditManager,
InlineEditRequestContext,
} from "./inline-edit.ts";
export type {
ContentFormat,
Expand Down Expand Up @@ -60,6 +62,7 @@ export type {
WorkflowStage,
WorkflowTransition,
} from "./workflow.ts";
export { capabilityKey } from "./capabilities.ts";
export { type DuneErrorCode, isDuneError } from "./errors.ts";
export type {
ContentDiff,
Expand Down Expand Up @@ -124,10 +127,10 @@ export type {
Capabilities,
CapabilityContext,
CapabilityFactory,
CapabilityKey,
CapabilityProvider,
CapabilityReader,
CapabilityRegistrar,
Contributions,
RequestAuthenticator,
ScheduledActionExecutor,
} from "./capabilities.ts";
Expand Down
10 changes: 10 additions & 0 deletions tests/errors_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,13 @@ Deno.test("isDuneError tells an error by its code, not its class", () => {
assertEquals(isDuneError("ssrf_blocked", "ssrf_blocked"), false);
assertEquals(isDuneError(null, "ssrf_blocked"), false);
});

import { capabilityKey } from "../src/mod.ts";

Deno.test("a capability key is its name; two keys with one name are one capability", () => {
assertEquals(capabilityKey("bar.actions"), { name: "bar.actions" });
assertEquals(
capabilityKey<number>("x").name,
capabilityKey<string>("x").name,
);
});
Loading