diff --git a/CHANGELOG.md b/CHANGELOG.md index fedf150..2286623 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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(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`, @@ -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`. diff --git a/src/capabilities.ts b/src/capabilities.ts index 2a21120..4d3b5b0 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -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 */ @@ -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. */ @@ -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 { + readonly name: string; + /** Phantom: carries `T` for the type checker only. Never set. */ + readonly __type?: T; +} + +/** Make a {@link CapabilityKey}. */ +export function capabilityKey(name: string): CapabilityKey { + return { name }; +} /** What a slot factory receives: core infrastructure that exists once every `setup()` has settled. */ export interface CapabilityContext { @@ -132,10 +140,8 @@ export interface CapabilityRegistrar { name: K, factory: CapabilityFactory, ): void; - contribute( - point: K, - item: Contributions[K], - ): void; + provide(key: CapabilityKey, factory: CapabilityFactory): void; + contribute(key: CapabilityKey, item: T): void; } /** One entry of {@link CapabilityReader.providers}. */ @@ -152,8 +158,9 @@ export interface CapabilityProvider { export interface CapabilityReader { /** The active provider of a slot, or undefined. */ get(name: K): Capabilities[K] | undefined; + get(key: CapabilityKey): T | undefined; /** Everything contributed to a point, in plugin registration order. */ - list(point: K): readonly Contributions[K][]; + list(key: CapabilityKey): readonly T[]; /** Who provides what, for `dune doctor`. */ providers(): CapabilityProvider[]; } diff --git a/src/hooks.ts b/src/hooks.ts index d1d425b..624d666 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -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. */ @@ -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; diff --git a/src/inline-edit.ts b/src/inline-edit.ts index 6fa89f7..38b4e50 100644 --- a/src/inline-edit.ts +++ b/src/inline-edit.ts @@ -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 ────────────────────────────────────────────────────────────────── @@ -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 | null>; +} + // ── Manager ─────────────────────────────────────────────────────────────────── /** Public interface for an inline editing server manager. */ @@ -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; - - /** - * 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, - author: string, - ): Promise; + handleRequest(req: Request, ctx: InlineEditRequestContext): Promise; /** * Return live presence data — which documents have active editing diff --git a/src/mod.ts b/src/mod.ts index 3e51f9e..57cf9a2 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -31,7 +31,9 @@ export type { export type { ActiveEditor, DocumentPresence, + EditorIdentity, InlineEditManager, + InlineEditRequestContext, } from "./inline-edit.ts"; export type { ContentFormat, @@ -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, @@ -124,10 +127,10 @@ export type { Capabilities, CapabilityContext, CapabilityFactory, + CapabilityKey, CapabilityProvider, CapabilityReader, CapabilityRegistrar, - Contributions, RequestAuthenticator, ScheduledActionExecutor, } from "./capabilities.ts"; diff --git a/tests/errors_test.ts b/tests/errors_test.ts index e67fc77..93b760d 100644 --- a/tests/errors_test.ts +++ b/tests/errors_test.ts @@ -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("x").name, + capabilityKey("x").name, + ); +});