Skip to content
Merged
74 changes: 74 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Changelog

## 1.0.0 (unreleased, in progress)

Declared so far (slice 1, the parts that do not depend on the plugin object
itself):

- `PluginApp`, `PluginRouteContext`: the structural app and route-context types,
free of any web-framework type.
- `StorageAdapter` and its entry/stat/watch types.
- `ContentApi` and its result types; `PageIndex` (wider than the theme
contract's: search engines hand entries back to core).
- The search-engine contract: `SearchEngine`, `SearchEngineCreateContext`
(narrow: `register`, `setActiveEngine`, `pages`, `injectedRecords`,
`loadText`; core's storage, config and format registry are not part of it),
`SearchResult`, `SearchOptions`, `SearchFilter`, `FacetCounts`,
`InjectedSearchRecord`, `SearchRecordsCollectContext`.
- `Submission` and related types; `BlueprintField` and its parts;
`InlineEditManager`; `ScheduledAction`.
- `isDuneError` and `DuneErrorCode`.

Slice 2a (services and what they return):

- `PluginServices` and its parts: `SecurityService`, `RateLimitService`,
`PasswordService`, `AuthzService`, `BlueprintService`, `SearchServices`,
`PathServices`, `SessionService`, `UserService`, `EngineServices`.
- Identity, in the contract because any `editor.session` provider builds local
login from it: `User`, `UserCreate`, `UserStore`, `Session`, `SessionManager`,
`PasswordService`.
- `EngineServices` is `workflow`, `scheduler` and `staging` (`WorkflowEngine`,
`Scheduler`, `StagingEngine`): core-owned subsystems an admin drives. Machine
translation and the audit log are not here: they move into
`@dune/plugin-admin`.
- `HistoryEngine`, `RecordInput`, `ContentRevision`, `ContentDiff`;
`RateLimiter`, `RateLimitStore`, `Logger` and the security option types.

Slice 2b (the plugin object, hooks, capabilities and the runtime):

- `DunePlugin`, `PluginApi`, `MountApi`, `HookRegistry`, `HookContext`,
`HookHandler`, `HookEvent` and the event payloads, `ResponseTransformContext`,
`ContentEditorPlugin`, `PublicRouteRegistration` and the admin registrations
(`AdminPageRegistration`, `AdminRouteRegistration`, `AdminBarAction`,
`AdminBarActionContext`). `HookHandler`, `setup`, `mount` and
`transformResponse` are bivariant: core hands handlers a wider context than
this package declares.
- Capabilities: `Capabilities` (slots), `Contributions`, `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`,
`disallowAllMatching`).
- `PluginRuntime` with narrow structural interfaces for what plugins call:
`PluginEngine`, `RouteResolver`, `ThemeLoader`, `Page`, `PluginSearch`,
`FlexEngine`, `SubmissionManager`, `SectionRegistry`, `MetricsCollector`,
`JobScheduler`. `PluginEngine.switchTheme` does not persist the choice; the
caller writes the config.

Out of the contract on purpose: `imageCache`, `engine.getAvailableThemes`,
`rebuildCount`, `indexErrors`, `resolve`, `serveMedia`.

Trimmed to what plugins read (measured against the first-party plugins and the
testing harness): `User` no longer declares `avatarUrl`, `lastSeenAt` or
`stripeCustomerId`. `providerId`, `linkedProviders` and the `UserStore` methods
`getByProvider`, `linkProvider` and `unlinkProvider` stay although no
first-party plugin uses them yet: an OAuth login provider is built from them.
`PageIndex` no longer declares `gated`, `roles`, `publishAt`, `unpublishAt`,
`fileUrl`, `termPageFor` or `coverImage`; it keeps `mtime` and `hash` (required
by core's search-result type; treat `hash` as opaque) and `extra` (read by the
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.
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
# @dune/plugin-api

Type contract for Dune CMS plugins (@dune/plugin-api).
The contract between [Dune CMS](https://getdune.org) and its plugins, as types.

Part of [Dune CMS](https://getdune.org). Not published yet: the declarations are
being extracted from `@dune/core` for 0.36. The package is born at 1.0.0 with no
release candidate.
A leaf package: no dependency on `@dune/core` or `@dune/theme-api`. Core imports
it and type-tests that what it hands a plugin is assignable to these
declarations, and that what a plugin hands in is accepted by core.

Not published yet: born at 1.0.0 (no release candidate) together with Dune 0.36.
The package is being filled in slice by slice; see `CHANGELOG.md` for what is
declared so far.
57 changes: 57 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* The route-registration surface of the contract: what a plugin's `mount()`
* sees of the Fresh `App`, and of the request context its handlers receive.
*
* Core's real `App` and `FreshContext` satisfy these structurally, so the
* contract names no Fresh type and pins no Fresh version. Only what plugins
* demonstrably use is here: registering middleware and routes, and reading the
* request, URL, route parameters and per-request state.
*
* A plugin that needs more (`ctx.render()`, islands, `app.fsRoutes()`,
* `app.layout()`) depends on Fresh itself and narrows what it is given:
* handler properties in the contract are declared as methods, so a handler
* that takes a full `FreshContext` is accepted where a `PluginRouteContext` is
* expected, and `app` can be cast to Fresh's `App`. Core always passes the
* real thing; the cost of narrowing is that such a plugin follows Fresh's
* versions, which this contract no longer promises to.
*
* @module
*/

/** What a route handler or middleware receives. Core passes a Fresh `FreshContext`, which satisfies this. */
export interface PluginRouteContext<
// deno-lint-ignore no-explicit-any
S = any,
> {
/** The incoming request. */
readonly req: Request;
/** The request URL, parsed. */
readonly url: URL;
/** Route parameters, e.g. `{ id: "7" }` for `/items/:id`. */
readonly params: Record<string, string>;
/** Per-request state shared by middleware and handlers. */
state: S;
/** Run the rest of the chain and return its response (middleware only). */
next(): Promise<Response>;
}

/** A route handler or middleware. */
// deno-lint-ignore no-explicit-any
export type PluginRouteHandler<S = any> = (
ctx: PluginRouteContext<S>,
) => Response | Promise<Response>;

/** What `mount()` receives as `app`. Core passes the Fresh `App`, which satisfies this. */
export interface PluginApp {
/** Register middleware that runs for every request (or every request below a path). */
use(...handlers: PluginRouteHandler[]): this;
use(path: string, ...handlers: PluginRouteHandler[]): this;
/** Register route handlers for a method and path (`/items/:id` style). */
get(path: string, ...handlers: PluginRouteHandler[]): this;
post(path: string, ...handlers: PluginRouteHandler[]): this;
put(path: string, ...handlers: PluginRouteHandler[]): this;
patch(path: string, ...handlers: PluginRouteHandler[]): this;
delete(path: string, ...handlers: PluginRouteHandler[]): this;
/** Match any method. */
all(path: string, ...handlers: PluginRouteHandler[]): this;
}
35 changes: 35 additions & 0 deletions src/authz.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Authorisation: the relations a plugin may grant through `authzActions`, and
* the narrow surface of the site's authz system.
*
* Declared structurally so the package depends on no authorisation library. All
* methods are declared with method syntax so core's stricter generic
* implementation is assignable.
*
* @module
*/

/** The relation vocabulary every action is expressed in. */
export type AuthzRelation = "member" | "admin" | "editor" | "author" | "owner";

/** A subject or object in an authorisation tuple. */
export interface AuthzObject {
type: string;
id: string;
}

/** The site's authorisation system. Absent when authz is not enabled. */
export interface PluginAuthz {
/** Whether `who` may perform the action `canThey` on `onWhat`. */
check(
args: { who: AuthzObject; canThey: string; onWhat: AuthzObject },
): Promise<boolean>;
/** Grant a relation. */
allow(
args: { who: AuthzObject; toBe: AuthzRelation; onWhat: AuthzObject },
): Promise<unknown>;
/** Revoke every tuple matching the given parts. */
disallowAllMatching(
args: { who?: AuthzObject; was?: AuthzRelation; onWhat?: AuthzObject },
): Promise<unknown>;
}
177 changes: 177 additions & 0 deletions src/blueprints.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* Blueprint field definitions: the part of a blueprint a plugin's `configSchema`
* uses.
*
* @module
*/

/**
* Supported field types — maps to PageFrontmatter value types.
*
* | type | JS type | Validation extras |
* |-----------|----------------------------------|----------------------------------|
* | text | string | min/max length, pattern |
* | textarea | string | min/max length |
* | markdown | string | min/max length |
* | number | number | min/max value |
* | toggle | boolean | — |
* | date | string (YYYY-MM-DD) | min/max as date strings |
* | select | string (one of options keys) | options must be provided |
* | list | string[] | min/max item count |
* | file | string (path or URL) | pattern for allowed extensions |
* | color | string (#rrggbb or CSS value) | pattern |
*/
export type BlueprintFieldType =
| "text"
| "textarea"
| "markdown"
| "number"
| "toggle"
| "date"
| "select"
| "list"
| "file"
| "color";

/** A single field definition in a blueprint. */
export interface BlueprintField {
/** Field type — controls type checking and admin UI widget. */
type: BlueprintFieldType;
/** Human-readable label (used in admin UI). */
label: string;
/** Default value to use when the field is absent. */
default?: unknown;
/** Whether the field must be present and non-empty. */
required?: boolean;
/**
* Allowed option values for `select` fields.
* Keys are values stored in frontmatter; values are human-readable labels.
* @example { "draft": "Draft", "published": "Published" }
*/
options?: Record<string, string>;
/** Extra type-specific validation constraints. */
validate?: BlueprintFieldValidation;
/**
* File upload configuration — only meaningful when `type` is `"file"`.
* Allows per-field control over size limits, MIME type allowlist,
* and storage sub-path for blueprint-driven upload fields.
*/
upload?: BlueprintFileUploadConfig;
}

/**
* Per-field file upload configuration for blueprint fields of type `"file"`.
* These settings are enforced server-side; the client's declared MIME type
* and filename are never trusted.
*
* @example
* ```yaml
* fields:
* avatar:
* type: file
* label: Profile Photo
* required: true
* upload:
* max_size_mb: 2
* allowed_types: ["image/jpeg", "image/png", "image/webp"]
* storage: "user-uploads/avatars"
* public_url: true
* ```
*/
export interface BlueprintFileUploadConfig {
/**
* Maximum allowed file size in megabytes.
* Files exceeding this limit are rejected with HTTP 413.
* Default: 5
*/
max_size_mb?: number;
/**
* Permitted MIME types.
* The server derives the actual MIME type from the file extension (not the
* client-supplied Content-Type) using the same allowlist as the global
* upload security module.
*
* Specify as an array of MIME type strings.
* @example ["image/jpeg", "image/png", "image/webp"]
*/
allowed_types?: string[];
/**
* Sub-path within the uploads directory where files are stored.
* Must not contain path traversal sequences.
* @example "user-uploads/avatars"
*/
storage?: string;
/**
* Whether to include the public URL in the upload response.
* Default: true
*/
public_url?: boolean;
}

/** Validation constraints for a blueprint field. */
export interface BlueprintFieldValidation {
/**
* Minimum bound.
* - `number`: minimum value (inclusive)
* - `text` / `textarea` / `markdown` / `file` / `color`: minimum string length
* - `list`: minimum item count
*/
min?: number;
/**
* Maximum bound.
* - `number`: maximum value (inclusive)
* - `text` / `textarea` / `markdown` / `file` / `color`: maximum string length
* - `list`: maximum item count
*/
max?: number;
/**
* Regex pattern the value must match.
* - `text` / `textarea` / `markdown` / `file` / `color`: tested against the string
* - `list`: tested against each item
* - Other types: ignored
*/
pattern?: string;
}

/**
* A blueprint definition — the schema for a template's frontmatter.
* Loaded from `blueprints/{template}.yaml`.
*/
export interface BlueprintDefinition {
/** Human-readable name for this content type (e.g. "Blog Post"). */
title: string;
/**
* Parent blueprint to inherit fields from.
* Must match another blueprint filename (without .yaml extension).
* @example "default"
*/
extends?: string;
/**
* Field definitions. Keys are frontmatter field names.
* @example { date: { type: "date", required: true } }
*/
fields: Record<string, BlueprintField>;
}

/**
* Resolved blueprint — inheritance fully flattened.
* `fields` is the merged union of all inherited + own fields.
*/
export interface ResolvedBlueprint {
title: string;
/** Template name this blueprint applies to. */
template: string;
/** Fully merged fields (inherited overrides → own fields). */
fields: Record<string, BlueprintField>;
}

/** Map of template name → blueprint definition (as loaded, before resolution). */
export type BlueprintMap = Record<string, BlueprintDefinition>;

/** A single validation error from validating frontmatter against a blueprint. */
export interface BlueprintValidationError {
/** Frontmatter field name. */
field: string;
/** Human-readable error message. */
message: string;
}
Loading
Loading