diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6eb4092 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index c053b5d..1254c28 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e4fc59d --- /dev/null +++ b/src/app.ts @@ -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; + /** Per-request state shared by middleware and handlers. */ + state: S; + /** Run the rest of the chain and return its response (middleware only). */ + next(): Promise; +} + +/** A route handler or middleware. */ +// deno-lint-ignore no-explicit-any +export type PluginRouteHandler = ( + ctx: PluginRouteContext, +) => Response | Promise; + +/** 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; +} diff --git a/src/authz.ts b/src/authz.ts new file mode 100644 index 0000000..099af88 --- /dev/null +++ b/src/authz.ts @@ -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; + /** Grant a relation. */ + allow( + args: { who: AuthzObject; toBe: AuthzRelation; onWhat: AuthzObject }, + ): Promise; + /** Revoke every tuple matching the given parts. */ + disallowAllMatching( + args: { who?: AuthzObject; was?: AuthzRelation; onWhat?: AuthzObject }, + ): Promise; +} diff --git a/src/blueprints.ts b/src/blueprints.ts new file mode 100644 index 0000000..2e0cf71 --- /dev/null +++ b/src/blueprints.ts @@ -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; + /** 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; +} + +/** + * 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; +} + +/** Map of template name → blueprint definition (as loaded, before resolution). */ +export type BlueprintMap = Record; + +/** A single validation error from validating frontmatter against a blueprint. */ +export interface BlueprintValidationError { + /** Frontmatter field name. */ + field: string; + /** Human-readable error message. */ + message: string; +} diff --git a/src/capabilities.ts b/src/capabilities.ts new file mode 100644 index 0000000..aa8dd3b --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,151 @@ +/** + * Capabilities: what a plugin *provides* to core and to other plugins. + * + * `PluginServices` go the other way (core implements, a plugin uses). A + * capability is implemented by a plugin and used by core or another plugin, and + * is optional by nature: when nobody provides it the consumer carries on + * without the feature. + * + * Two shapes: a **slot** has at most one active provider + * ({@link Capabilities}); a **contribution point** collects one item from each + * contributing plugin ({@link Contributions}). + * + * @module + */ + +import type { AdminBarAction, AdminBarActionContext } from "./hooks.ts"; +import type { + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, +} from "./hooks.ts"; +import type { DuneConfig } from "./config.ts"; +import type { HistoryEngine } from "./history.ts"; +import type { HookRegistry } from "./hooks.ts"; +import type { InlineEditManager } from "./inline-edit.ts"; +import type { PluginServices } from "./services.ts"; +import type { StorageAdapter } from "./storage.ts"; +import type { ScheduledAction } from "./workflow.ts"; + +/** The result of authenticating a request as an admin-tier session. */ +export interface AuthenticateResult { + authenticated: boolean; + /** + * The signed-in admin. Only `id` is guaranteed: `username` is absent for + * OAuth/magic-link accounts, so fall back to `name`, then `email`, then `id`. + */ + user?: { + id: string; + username?: string; + email?: string; + name?: string; + roles?: string[]; + }; +} + +/** Tells core whether a request carries an authenticated admin session. */ +export interface RequestAuthenticator { + /** + * Resolve the admin session of a request. Called on public pages, so answer + * quickly, without touching a session store, when the request carries no + * session of its own. + */ + authenticate(req: Request): Promise; + /** + * Names of the cookies that carry this provider's session. Core uses them to + * decide whether to call {@link authenticate} at all and whether to keep a + * response out of the shared page cache. When omitted, any request with a + * `Cookie` header is treated as possibly signed in. + */ + readonly sessionCookies?: readonly string[]; +} + +/** Runs a due scheduled action (publish, unpublish, archive). */ +export interface ScheduledActionExecutor { + execute(action: ScheduledAction): Promise; +} + +/** + * Where the admin is mounted. Core never assumes an admin exists or where it + * is: it reads this slot to keep admin routes out of the page cache, the static + * build and the plugin-route namespace. No provider means no admin. + */ +export interface AdminPrefix { + /** URL path prefix the admin is mounted at: starts with `/`, no trailing slash (e.g. `/admin`). */ + readonly prefix: string; +} + +/** Slots: at most one active provider each. First-party keys only in 1.0. */ +export interface Capabilities { + /** Where the admin is mounted. */ + "admin.prefix": AdminPrefix; + /** Who is the admin on this request. */ + "editor.session": RequestAuthenticator; + /** Executes due scheduled actions. */ + "scheduler.executor": ScheduledActionExecutor; + /** Real-time inline editing. */ + "editor.inline": InlineEditManager; + /** A replacement page editor. */ + "editor.content": ContentEditorPlugin; +} + +/** Contribution points: one item from each contributing plugin. */ +export interface Contributions { + /** An extra admin page. */ + "admin.pages": AdminPageRegistration; + /** A guarded admin route under the admin prefix. */ + "admin.routes": AdminRouteRegistration; + /** Buttons for the admin bar. */ + "bar.actions": (ctx: AdminBarActionContext) => AdminBarAction[]; +} + +/** What a slot factory receives: core infrastructure that exists once every `setup()` has settled. */ +export interface CapabilityContext { + storage: StorageAdapter; + config: DuneConfig; + history: HistoryEngine; + /** The hook registry; a factory that writes content fires the matching `onPage*` event itself. */ + hooks: HookRegistry; + services: PluginServices; + /** Plugin runtime data directory. */ + dataDir: string; + /** Content directory relative to the site root. */ + contentDir: string; +} + +/** Slot factory: may be async. Returning `undefined` declines the slot. */ +export type CapabilityFactory = ( + ctx: CapabilityContext, +) => T | undefined | Promise; + +/** `PluginApi.capabilities`: how a plugin declares what it provides. */ +export interface CapabilityRegistrar { + provide( + name: K, + factory: CapabilityFactory, + ): void; + contribute( + point: K, + item: Contributions[K], + ): void; +} + +/** One entry of {@link CapabilityReader.providers}. */ +export interface CapabilityProvider { + name: string; + kind: "slot" | "contribution"; + /** Name of the plugin that registered it. */ + provider: string; + /** Slots only: `unresolved`, `active`, `declined` (factory returned undefined) or `failed` (factory threw). */ + status: "unresolved" | "active" | "declined" | "failed"; +} + +/** `MountApi.capabilities`: how core and plugins consume capabilities. */ +export interface CapabilityReader { + /** The active provider of a slot, or undefined. */ + get(name: K): Capabilities[K] | undefined; + /** Everything contributed to a point, in plugin registration order. */ + list(point: K): readonly Contributions[K][]; + /** Who provides what, for `dune doctor`. */ + providers(): CapabilityProvider[]; +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3c8cc03 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,52 @@ +/** + * The slice of the site configuration a plugin may read. + * + * Core's `DuneConfig` is large and most of it is core's own business. These + * interfaces name only what plugins read; core's config is assignable to them, + * and adding a field here later is additive while removing one is breaking. + * Keys a plugin needs beyond this belong in its own `plugins.` section. + * + * @module + */ + +/** Site identity. */ +export interface SiteConfig { + title: string; + description: string; + /** Canonical site URL, including any path prefix the site is served under. */ + url: string; + /** Taxonomy names declared for the site (e.g. `["category", "tag"]`). */ + taxonomies: string[]; +} + +/** The active theme. */ +export interface ThemeConfig { + /** Active theme name. */ + name: string; +} + +/** Language settings. */ +export interface LanguageConfig { + supported: string[]; + default: string; + include_default_in_url: boolean; +} + +/** Engine behaviour a plugin may depend on. */ +export interface SystemConfig { + content: { + /** Content directory relative to the site root. */ + dir: string; + }; + languages: LanguageConfig; + debug: boolean; +} + +/** The loaded site configuration, as a plugin sees it. Read-only. */ +export interface DuneConfig { + site: SiteConfig; + system: SystemConfig; + theme: ThemeConfig; + /** Per-plugin settings, keyed by plugin name. */ + plugins: Record>; +} diff --git a/src/content-api.ts b/src/content-api.ts new file mode 100644 index 0000000..d52a583 --- /dev/null +++ b/src/content-api.ts @@ -0,0 +1,162 @@ +/** + * The content query API (`runtime.contentApi`, `ctx.content` in hooks). + * + * @module + */ + +import type { PageIndex } from "./page-index.ts"; + +/** + * A fully resolved page — HTML rendered, frontmatter loaded, ready for use in + * a Fresh route handler or API endpoint. + * + * @typeParam FM - Frontmatter shape. Defaults to `Record`. + * Pass your own interface for type-safe field access: + * `contentApi.page("/blog/hello")` + */ +export interface ResolvedPage> { + /** URL route — e.g. "/blog/hello-world" */ + route: string; + /** Page title from frontmatter */ + title: string; + /** ISO date string, or null if not set */ + date: string | null; + /** Rendered HTML (from Markdown/MDX/TSX component) */ + html: string; + /** Parsed frontmatter — typed as FM (defaults to Record) */ + frontmatter: FM; + /** BCP-47 language code (e.g. "en", "de") */ + language: string; + /** Whether the page is published */ + published: boolean; + /** Nav display title (falls back to `title`) */ + navTitle: string; + /** Absolute URL built from site URL + route, or null if site URL is not set */ + url: string | null; +} + +/** A single search result. */ +export interface ContentSearchResult { + route: string; + title: string; + score: number; + excerpt?: string; +} + +/** + * A resolved taxonomy term with its associated page count. + * + * If any published page declares `termPageFor` pointing at this term, + * `pageRoute` is set to that page's route. Otherwise null. + * + * Use `contentApi.termPage(vocab, value)` to resolve the full page with + * rendered HTML and typed frontmatter. + */ +export interface TaxonomyTerm { + name: string; + slug: string; + count: number; + /** Route of the term page (from `termPageFor` frontmatter), or null. */ + pageRoute: string | null; +} + +/** Filtering and sorting options for `ContentApi.pages()`. */ +export interface PagesOptions { + /** Filter by collection name. */ + collection?: string; + /** Filter pages that have a specific taxonomy tag value. */ + tag?: string; + /** Filter by taxonomy name and value. e.g. { name: "category", value: "news" } */ + taxonomy?: { name: string; value: string }; + /** Maximum number of pages to return. */ + limit?: number; + /** Number of pages to skip before returning results. */ + offset?: number; + /** Only return published pages. Defaults to true. */ + published?: boolean; + /** Filter by language code. */ + language?: string; + /** Sort order. Defaults to "order". */ + orderBy?: "date" | "title" | "order"; + /** Sort direction. Defaults to "asc". */ + orderDir?: "asc" | "desc"; +} + +/** + * The Dune content query API. + * Obtain an instance via `BootstrapResult.contentApi` (see the module doc above). + */ +export interface ContentApi { + /** + * Resolve a single page by URL route. + * + * Returns `null` if no page exists at the given route. + * The HTML is rendered lazily (first access triggers Markdown/MDX/TSX processing). + * + * @param route - Absolute URL path, e.g. "/blog/hello-world" + */ + page>( + route: string, + ): Promise | null>; + + /** + * Query the content index for pages matching the given criteria. + * + * Returns `PageIndex[]` (lightweight references — HTML is NOT pre-rendered). + * Use `page()` to resolve individual pages when you need rendered HTML. + */ + pages(opts?: PagesOptions): PageIndex[]; + + /** + * Full-text search across all indexed content. + * + * Returns synchronously (the search index is in-memory). Requires + * `buildSearch: true` in the `bootstrap()` call, or `search.build()` + * to have been called separately — otherwise the index is empty and + * all queries return no results. + * + * @param query - Search query string + * @param opts.limit - Maximum results to return (default: 10) + */ + search( + query: string, + opts?: { limit?: number }, + ): Promise; + + /** + * List all terms for a taxonomy. + * + * Each term includes a `pageRoute` pointing to its associated term page + * if one exists (i.e. a page that declares `termPageFor` for this term). + * + * @param name - Taxonomy name as configured in `dune.yaml` (e.g. "tag", "category") + */ + taxonomy(name: string): TaxonomyTerm[]; + + /** + * Resolve the term page for a taxonomy term. + * + * A term page is any published content page that declares `termPageFor` + * in its frontmatter pointing at the given vocabulary and value: + * + * ```yaml + * # Simple form — implies the "tag" vocabulary: + * termPageFor: ewr + * + * # Explicit vocabulary: + * termPageFor: + * category: politics + * ``` + * + * Returns `null` if no such page exists. + * + * @param vocab - Taxonomy vocabulary name, e.g. "tag", "category" + * @param value - Term value, e.g. "ewr" + */ + termPage>( + vocab: string, + value: string, + ): Promise | null>; +} + +// ── Factory ─────────────────────────────────────────────────────────────────── diff --git a/src/engines.ts b/src/engines.ts new file mode 100644 index 0000000..e80f150 --- /dev/null +++ b/src/engines.ts @@ -0,0 +1,99 @@ +/** + * The core subsystems an admin drives: editorial workflow, the scheduler for timed actions, and staged drafts. + * + * @module + */ + +import type { PageIndex } from "./page-index.ts"; +import type { + ContentStatus, + ScheduledAction, + WorkflowStage, + WorkflowTransition, +} from "./workflow.ts"; + +/** Manages content status transitions and workflow queries. Obtain via {@link createWorkflowEngine}. */ +export interface WorkflowEngine { + /** All configured stages */ + stages: WorkflowStage[]; + /** Get the effective status for a page (from frontmatter or default) */ + getStatus(page: PageIndex): ContentStatus; + /** Check if a transition is valid, optionally scoped to a user role */ + canTransition(from: ContentStatus, to: ContentStatus, role?: string): boolean; + /** Get allowed target statuses from a status, optionally filtered by role */ + allowedTransitions(from: ContentStatus, role?: string): ContentStatus[]; + /** Full transition objects from a status, optionally filtered by role */ + allowedTransitionObjects( + from: ContentStatus, + role?: string, + ): WorkflowTransition[]; + /** Whether transitioning to `to` should set published=true on the page */ + setsPublished(to: ContentStatus): boolean; + /** Find pages by status */ + findByStatus(pages: PageIndex[], status: ContentStatus): PageIndex[]; + /** Get status counts */ + statusCounts(pages: PageIndex[]): Record; +} + +/** Schedules and executes time-based publish/unpublish/archive actions. Obtain via {@link createScheduler}. */ +export interface Scheduler { + /** Schedule a new action */ + schedule( + action: Omit, + ): Promise; + /** Cancel a scheduled action */ + cancel(id: string): Promise; + /** List all pending actions */ + list(): Promise; + /** List actions for a specific page */ + listForPage(sourcePath: string): Promise; + /** Check and execute due actions (called by timer) */ + tick(onAction: (action: ScheduledAction) => Promise): Promise; + /** Start the scheduler polling loop */ + start(onAction: (action: ScheduledAction) => Promise): void; + /** Stop the scheduler */ + stop(): void; +} + +/** Manages staged drafts and their shareable preview tokens. Obtain via {@link createStagingEngine}. */ +export interface StagingEngine { + /** + * Create or update a staged draft for a page. + * Generates a new random token if no draft exists, preserves the token + * on updates so existing preview URLs remain valid. + */ + upsert(input: UpsertInput): Promise; + /** Get the current draft for a page (null if none). */ + get(sourcePath: string): Promise; + /** Delete a staged draft (discard). */ + discard(sourcePath: string): Promise; + /** + * Verify that a token matches the stored draft for a page. + * Returns the draft on success, null if token is wrong or draft missing. + */ + verify(sourcePath: string, token: string): Promise; +} + +/** An unpublished draft of a page, stored alongside revision history. */ +export interface StagedDraft { + /** Source path of the page (e.g. "01.home/default.md") */ + sourcePath: string; + /** Markdown / MDX / TSX content body */ + content: string; + /** Frontmatter fields */ + frontmatter: Record; + /** Opaque hex token used to authenticate the preview URL */ + token: string; + /** Unix ms timestamp when the draft was created/last updated */ + updatedAt: number; + /** Admin username who created the draft */ + createdBy?: string; +} + +/** Input for {@link StagingEngine.upsert}. */ +export interface UpsertInput { + sourcePath: string; + content: string; + frontmatter: Record; + createdBy?: string; +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..341a5bb --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,23 @@ +/** + * Stable error identity for plugins. + * + * `instanceof` fails across two copies of a module, and a plugin may be + * resolved against a different copy of core than the one running. Errors that + * cross the plugin boundary therefore carry a stable `code`; test with + * {@link isDuneError} instead of `instanceof`. + * + * @module + */ + +/** Codes of the errors a plugin can receive from core services. */ +export type DuneErrorCode = + | "body_too_large" + | "ssrf_blocked" + | "duplicate_email" + | "weak_password"; + +/** True if `err` is a Dune error with the given `code`. */ +export function isDuneError(err: unknown, code: DuneErrorCode): boolean { + return typeof err === "object" && err !== null && + (err as { code?: unknown }).code === code; +} diff --git a/src/forms.ts b/src/forms.ts new file mode 100644 index 0000000..1145ca9 --- /dev/null +++ b/src/forms.ts @@ -0,0 +1,38 @@ +/** + * Form submission records, as carried by the `onFormSubmit` hook. + * + * @module + */ + +export interface Submission { + id: string; + form: string; + receivedAt: number; // Unix ms + status: SubmissionStatus; + fields: Record; + meta: SubmissionMeta; + /** Uploaded file attachments (set if form includes file inputs) */ + files?: SubmissionFile[]; +} + +export interface SubmissionFile { + /** Original filename from the upload */ + name: string; + /** Detected MIME type */ + contentType: string; + /** File size in bytes */ + size: number; + /** Storage path (relative to storage root), used internally for serving */ + storagePath: string; +} + +export type SubmissionStatus = "new" | "read" | "archived"; + +export interface SubmissionMeta { + /** Submitter IP (from X-Forwarded-For or request) */ + ip?: string; + /** Accept-Language header value */ + language?: string; + /** User-Agent header value */ + userAgent?: string; +} diff --git a/src/history.ts b/src/history.ts new file mode 100644 index 0000000..74455e7 --- /dev/null +++ b/src/history.ts @@ -0,0 +1,73 @@ +/** + * Revision history. + * + * @module + */ + +/** Records page revisions and provides diff and restore capabilities. Obtain via {@link createHistoryEngine}. */ +export interface HistoryEngine { + /** Record a new revision for a page */ + record(input: RecordInput): Promise; + /** Get revision history for a page (newest first) */ + getHistory(sourcePath: string, limit?: number): Promise; + /** Get a specific revision */ + getRevision( + sourcePath: string, + revisionNumber: number, + ): Promise; + /** Get the latest revision for a page */ + getLatest(sourcePath: string): Promise; + /** Compute diff between two revisions */ + diff( + sourcePath: string, + fromRev: number, + toRev: number, + ): Promise; + /** Compute diff between a revision and current content */ + diffWithCurrent( + sourcePath: string, + revisionNumber: number, + currentContent: string, + ): Promise; + /** Get total revision count for a page */ + getRevisionCount(sourcePath: string): Promise; +} + +/** Input for {@link HistoryEngine.record} — captures a page revision. */ +export interface RecordInput { + sourcePath: string; + content: string; + frontmatter: Record; + author?: string; + message?: string; +} + +/** Content revision record */ +export interface ContentRevision { + /** Unique revision ID */ + id: string; + /** Source path of the page */ + sourcePath: string; + /** Revision number (sequential per page) */ + number: number; + /** Complete page content at this revision */ + content: string; + /** Frontmatter snapshot */ + frontmatter: Record; + /** Who made this change */ + author?: string; + /** When this revision was created */ + createdAt: number; + /** Description of what changed */ + message?: string; +} + +/** Diff between two revisions */ +export interface ContentDiff { + /** Lines added */ + additions: number; + /** Lines removed */ + deletions: number; + /** Unified diff format */ + patch: string; +} diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..3ac3e67 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,324 @@ +/** + * Hooks and the plugin object: lifecycle events, `DunePlugin`, the objects + * `setup()` and `mount()` receive, and the registrations a plugin contributes. + * + * Callback-shaped members that core hands wider objects to (route handlers, + * hook handlers) are declared with method syntax so a handler written against + * these narrower types is accepted by core, and the other way round. + * + * @module + */ + +import type { PluginApp, PluginRouteContext } from "./app.ts"; +import type { AuthzRelation } from "./authz.ts"; +import type { BlueprintField } from "./blueprints.ts"; +import type { CapabilityReader, CapabilityRegistrar } from "./capabilities.ts"; +import type { DuneConfig } from "./config.ts"; +import type { ContentApi } from "./content-api.ts"; +import type { Submission } from "./forms.ts"; +import type { PluginRuntime } from "./runtime.ts"; +import type { PluginServices } from "./services.ts"; +import type { StorageAdapter } from "./storage.ts"; + +/** + * All lifecycle events a plugin can subscribe to. New events may be added in + * minor versions; existing events are not removed or renamed within a major. + */ +export type HookEvent = + // Startup + | "onConfigLoaded" + | "onStorageReady" + | "onContentIndexReady" + // Request lifecycle + | "onRequest" + | "onCollectionResolved" + | "onBeforeRender" + // Content processing + | "onMarkdownProcess" + | "onMarkdownProcessed" + | "onMediaDiscovered" + // Cache + | "onCacheHit" + | "onCacheMiss" + | "onCacheInvalidate" + // API + | "onApiRequest" + | "onApiResponse" + // Engine lifecycle + | "onRebuild" + | "onThemeSwitch" + // Search + | "onSearchRecordsCollect" + | "onSearchEngineCreate" + // Content mutation: fired by whoever writes the page, never by core on their + // behalf. Payloads carry paths relative to the content directory. + | "onPageCreate" + | "onPageUpdate" + | "onPageDelete" + | "onPageMove" + | "onWorkflowChange" + // Public features (fired by core, so they work without an admin plugin) + | "onFormSubmit" + | "onSystemAction"; + +/** Payload of `onPageMove`: pages were renamed, renumbered or reordered. */ +export interface PageMovePayload { + moves: Array<{ from: string; to: string }>; +} + +/** Payload of `onFormSubmit`: a public form submission was accepted and stored. */ +export interface FormSubmitPayload { + /** Form name (the `forms/.yaml` file). */ + form: string; + /** The stored submission. */ + submission: Submission; +} + +/** Payload of `onSystemAction`: an incoming webhook ran an action. */ +export interface SystemActionPayload { + action: "rebuild" | "purge-cache"; + /** Source IP (honours `system.trusted_proxies`), or null when unknown. */ + ip: string | null; + userAgent: string | null; +} + +/** + * Hook handler signature. Declared with method syntax (a bivariant parameter) + * so a handler written against these narrower types and the handlers core + * already holds are accepted in both directions: core hands handlers a wider + * context than this package declares. + */ +export type HookHandler = { + handler(context: HookContext): Promise | void; +}["handler"]; + +/** Context passed to each hook handler. */ +export interface HookContext { + event: HookEvent; + data: T; + config: DuneConfig; + storage: StorageAdapter; + /** Stop further hook processing for this event. */ + stopPropagation: () => void; + /** Replace the data being passed through the hook chain. */ + setData: (data: T) => void; + /** Background job API, present only when the job scheduler is running. */ + jobs?: { + /** Trigger a registered job by name immediately, regardless of its schedule. */ + run(name: string): Promise; + }; + /** + * The content query API. Absent for the hooks that fire before a content + * index exists (`onConfigLoaded`, `onStorageReady`, `onContentIndexReady`, + * `onSearchRecordsCollect`, `onSearchEngineCreate`) and in the lightweight + * registries some CLI commands build. Guard with `ctx.content?.`. + */ + content?: ContentApi; +} + +/** What a plugin's `setup()` receives. */ +export interface PluginApi { + /** Hook registry: call `hooks.on()` to subscribe to lifecycle events. */ + hooks: HookRegistry; + /** Merged site configuration (read-only). */ + config: DuneConfig; + /** Storage adapter for reading and writing plugin-specific data. */ + storage: StorageAdapter; + /** Services built by core: security policy, sessions, users, passwords, rate limiting, logger. */ + services: PluginServices; + /** + * Declare what this plugin provides to core and to other plugins. Declaring + * only records it; core resolves the slot factories after every `setup()` + * has settled. + */ + capabilities: CapabilityRegistrar; +} + +/** A public-facing route contributed by a plugin. */ +export interface PublicRouteRegistration { + /** HTTP method (default `GET`); `ALL` matches any. */ + method?: "GET" | "POST" | "PUT" | "DELETE" | "ALL"; + /** Full URL path (must start with `/`). */ + path: string; + /** Route handler. */ + handler(ctx: PluginRouteContext): Response | Promise; + /** Absolute path to an island module used by this route. */ + island?: string; +} + +/** Context passed to a `bar.actions` contribution. */ +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 through the `bar.actions` point. */ +export interface AdminBarAction { + /** Unique id; namespace by plugin name, e.g. `"pdf-export:download"`. */ + id: string; + label: string; + icon?: string; + /** Renders as a link when set; takes precedence over `onClick`. */ + href?: string; + /** Literal JavaScript run on click when `href` is not set. Trusted plugin content. */ + onClick?: string; +} + +/** A custom admin page contributed through the `admin.pages` point. */ +// deno-lint-ignore no-explicit-any +export interface AdminPageRegistration { + /** URL path relative to the admin prefix (must start with `/`). */ + path: string; + /** Human-readable nav label shown in the admin sidebar. */ + label: string; + icon?: string; + /** Admin permission required to view this page. */ + permission: string; + /** GET handler for the page. */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** A guarded admin route contributed through the `admin.routes` point. */ +// 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 `/`). */ + path: string; + /** Admin permission required, including ones declared through `authzActions`. */ + permission: string; + /** Runs only after authentication, CSRF and permission checks pass. */ + handler(ctx: PluginRouteContext): Promise | Response; +} + +/** + * Plugin definition. Export it as the default export of your plugin module. + * + * @example + * ```ts + * export default { + * name: "my-plugin", + * version: "1.0.0", + * hooks: { + * onRebuild: (ctx) => { console.log("rebuilt"); }, + * }, + * } satisfies DunePlugin; + * ``` + */ +export interface DunePlugin { + /** Unique plugin identifier, used as the key in `config.plugins`. */ + name: string; + /** SemVer plugin version. */ + version: string; + description?: string; + author?: string; + /** Lifecycle hook subscriptions, called in registration order. */ + hooks: Partial>; + /** Blueprint-style config schema for the plugin's own settings. */ + configSchema?: Record; + /** Called once when the plugin is registered. */ + setup?(api: PluginApi): Promise | void; + /** Absolute path to the plugin's static assets directory (set by the loader). */ + assetDir?: string; + /** + * Browser code entry points to bundle and serve at + * `/plugins/{plugin-name}/{entry}.js`. Bump the plugin version whenever + * client code changes: bundles are cached per name and version. + */ + clientEntries?: Record; + /** Absolute path to the plugin's templates directory (set by the loader). */ + templateDir?: string; + /** Names of other plugins this plugin depends on. */ + dependencies?: string[]; + /** Public-facing Fresh routes, registered before the content catch-all. */ + publicRoutes?: PublicRouteRegistration[]; + /** Preact island file specifiers contributed by this plugin. */ + islandSpecifiers?: string[]; + /** New admin-permission actions, each mapped to the relations that satisfy it. */ + authzActions?: Record; + /** Mount routes onto the app, after bootstrap and before requests are served. */ + mount?(api: MountApi): Promise | void; + /** + * Transform an HTTP response before it is sent. Output must depend only on + * `ctx.auth` and `ctx.page`, because transformed responses are cached by + * pathname for anonymous requests. + */ + transformResponse?( + ctx: ResponseTransformContext, + ): Promise | Response; +} + +/** Plugin-provided replacement for the built-in block editor (the `editor.content` slot). */ +export interface ContentEditorPlugin { + /** Handle `GET /admin/pages/edit?path=...`. */ + pageEditorHandler(ctx: PluginRouteContext): Response | Promise; + /** Optional WebSocket upgrade handler for real-time collaboration. */ + wsHandler?: ( + req: Request, + user: { id: string; name: string }, + ) => Response; +} + +/** Context passed to {@link DunePlugin.transformResponse}. */ +export interface ResponseTransformContext { + req: Request; + /** The response produced by the app, possibly already transformed by earlier plugins. */ + response: Response; + /** The signed-in admin, or null when the request carries no valid admin session. */ + auth: { + username: string; + role: string; + /** + * A synchronous, role-only approximation of the permission check. Fine for + * UI affordances; never an access-control decision. + */ + hasPermission(permission: string): boolean; + } | null; + config: DuneConfig; + /** 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; + /** Bar buttons contributed through `bar.actions`, in plugin registration order. */ + barActions: readonly ((ctx: AdminBarActionContext) => AdminBarAction[])[]; +} + +/** What a plugin's `mount()` receives. */ +export interface MountApi { + /** Where routes and middleware are registered. */ + app: PluginApp; + /** The booted site. */ + runtime: PluginRuntime; + /** The same services object `setup()` received. */ + services: PluginServices; + /** What plugins provide. Read lazily (at request time): a provider may be absent. */ + capabilities: CapabilityReader; +} + +/** Hook registry. */ +export interface HookRegistry { + /** Register a plugin. */ + registerPlugin(plugin: DunePlugin): void; + /** Register a single hook handler. */ + on(event: HookEvent, handler: HookHandler): void; + /** Remove a hook handler. */ + off(event: HookEvent, handler: HookHandler): void; + /** Fire a hook event, passing data through all handlers. */ + fire(event: HookEvent, data: T): Promise; + /** List registered plugins. */ + plugins(): DunePlugin[]; + /** Resolve once every plugin's `setup()`, including async ones, has settled. Never rejects. */ + whenSetupComplete(): Promise; +} diff --git a/src/identity.ts b/src/identity.ts new file mode 100644 index 0000000..2e97fa5 --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,141 @@ +/** + * Identity: users, sessions and password helpers, the pieces an `editor.session` provider builds local login from. + * + * @module + */ + +/** A Dune account — admin panel user, public site visitor, or both. */ +export interface User { + id: string; + email: string; + name?: string; + /** + * Login name for password-based (local admin) auth. Absent for + * OAuth/magic-link-only accounts, which have no username concept. + */ + username?: string; + /** PBKDF2 hash, present only for accounts with a local password. */ + passwordHash?: string; + /** + * External identity provider this account was originally created with: + * "local" (password) | "github" | "google" | "discord" | "magic" | ... + * Unlike `linkedProviders` below, this one is not removable through the + * account-linking flow — it's the account's original signup method. + */ + provider: string; + /** Provider's user ID (for OAuth). */ + providerId?: string; + /** + * Additional OAuth identities linked to this account after signup, via + * `GET /auth/{provider}/link` (requires an existing session). Each entry + * is independently usable to log in — `getByProvider()` checks both this + * list and the primary `provider`/`providerId` pair. Unlike the primary + * pair, entries here can be removed via `POST /auth/{provider}/unlink`. + * + * @since 0.32.1 + */ + linkedProviders?: { provider: string; providerId: string }[]; + /** Roles/tags, e.g. ["admin"], ["member", "subscriber"]. */ + roles: string[]; + createdAt: number; // ms timestamp + updatedAt: number; + enabled: boolean; +} + +/** + * Input for creating a new {@link User} — server-generated fields are omitted. + */ +export type UserCreate = + & Omit + & { + enabled?: boolean; + }; + +/** Storage-agnostic interface for reading and writing site user accounts. */ +export interface UserStore { + getById(id: string): Promise; + getByEmail(email: string): Promise; + getByUsername(username: string): Promise; + /** Checks both the primary provider/providerId pair and linkedProviders. */ + getByProvider(provider: string, providerId: string): Promise; + /** @throws {DuplicateEmailError} if a user with this email already exists. */ + create(user: UserCreate): Promise; + /** + * Add an OAuth identity to `linkedProviders`. Caller is responsible for + * checking `getByProvider()` first — this is a pure data operation, not a + * policy decision (that lives in the route handler, which decides what + * "already claimed elsewhere" should mean). No-op (returns the user + * unchanged) if this exact (provider, providerId) pair is already present, + * whether as the primary pair or an existing linked entry. + */ + linkProvider( + id: string, + provider: string, + providerId: string, + ): Promise; + /** + * Remove a linked OAuth identity by provider name. Only removes entries in + * `linkedProviders` — the primary `provider`/`providerId` pair (the + * account's original signup method) is never touched here. No-op if no + * matching entry exists. + */ + unlinkProvider(id: string, provider: string): Promise; + /** @throws {DuplicateEmailError} if `updates.email` is set and already used by a different user. */ + update( + id: string, + updates: Partial< + Pick< + User, + | "email" + | "name" + | "username" + | "passwordHash" + | "roles" + | "enabled" + > + >, + ): Promise; + list(opts?: { limit?: number; offset?: number }): Promise; + delete(id: string): Promise; +} + +/** A single stored session record. */ +export interface Session { + id: string; + userId: string; + createdAt: number; + expiresAt: number; + /** IP address of the client that created the session */ + ip?: string; + /** + * Full user object embedded in the session. Set when the caller has no + * persistent user record to look up from — public auth's + * `user_store: "session"` mode synthesises a User from OAuth/magic-link + * claims at login and carries it in the session for the cookie's + * lifetime. Admin sessions never set this. + */ + embeddedUser?: User; +} + +/** + * Creates and validates sessions backed by a SessionStore. + * + * Shared by @dune/plugin-admin's admin sessions and public-site auth + * sessions (dec-identity-unification Phase 5c) — one mechanism, + * parameterized by which SessionStore backend it's given, rather than two + * independently-implemented stacks. `create()`'s `embeddedUser` param is + * only ever used by public auth's `user_store: "session"` mode; admin + * sessions never pass it. + */ +export interface SessionManager { + /** Create a new session for a user. */ + create(userId: string, ip?: string, embeddedUser?: User): Promise; + /** Get and validate a session by its ID. Returns null if expired or not found. */ + get(sessionId: string): Promise; + /** Revoke (delete) a session. */ + revoke(sessionId: string): Promise; + /** Revoke all sessions for a user. */ + revokeAll(userId: string): Promise; + /** Clean up expired sessions. */ + cleanup(): Promise; +} diff --git a/src/inline-edit.ts b/src/inline-edit.ts new file mode 100644 index 0000000..6fa89f7 --- /dev/null +++ b/src/inline-edit.ts @@ -0,0 +1,67 @@ +/** + * 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. + * + * 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. + */ + +// ── Presence ────────────────────────────────────────────────────────────────── + +/** Active editor info returned by {@link InlineEditManager.getPresence}. */ +export interface ActiveEditor { + userId: string; + name: string; + /** Display color (hex) assigned by the editing service. */ + color: string; +} + +/** Presence record for one document that has at least one active editor. */ +export interface DocumentPresence { + sourcePath: string; + editors: ActiveEditor[]; +} + +// ── Manager ─────────────────────────────────────────────────────────────────── + +/** Public interface for an inline editing server manager. */ +export interface InlineEditManager { + /** + * Handle an HTTP → WebSocket upgrade for a live editing session. + * Caller must have already authenticated the user. + * Returns a 101 response on success, or 4xx on bad request. + */ + 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. + */ + patchFields( + sourcePath: string, + fields: Record, + author: string, + ): Promise; + + /** + * Return live presence data — which documents have active editing + * sessions, and who is editing them. + * Only documents with at least one connected client are included. + */ + getPresence(): DocumentPresence[]; +} diff --git a/src/mod.ts b/src/mod.ts index 19a9f99..901288e 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,173 @@ /** - * @dune/plugin-api: the type contract for Dune plugin authors. + * @dune/plugin-api: the contract between Dune and its plugins. * - * Leaf package: it depends on neither `@dune/core` nor the other contract - * package. Core imports it and type-tests that its own types stay compatible. + * A leaf package: it depends on neither `@dune/core` nor `@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. * * @module */ -export {}; + +export type { PluginApp, PluginRouteContext } from "./app.ts"; +export type { + Submission, + SubmissionFile, + SubmissionMeta, + SubmissionStatus, +} from "./forms.ts"; +export type { + BlueprintField, + BlueprintFieldType, + BlueprintFieldValidation, + BlueprintFileUploadConfig, +} from "./blueprints.ts"; +export type { + ContentApi, + ContentSearchResult, + PagesOptions, + ResolvedPage, + TaxonomyTerm, +} from "./content-api.ts"; +export type { + ActiveEditor, + DocumentPresence, + InlineEditManager, +} from "./inline-edit.ts"; +export type { + ContentFormat, + PageFrontmatter, + PageIndex, +} from "./page-index.ts"; +export type { + FacetCounts, + InjectedSearchRecord, + SearchEngine, + SearchEngineCreateContext, + SearchFilter, + SearchOptions, + SearchRecordsCollectContext, + SearchResult, +} from "./search.ts"; +export type { + StorageAdapter, + StorageEntry, + StorageStat, + WatchEvent, +} from "./storage.ts"; +export type { + ContentStatus, + ScheduledAction, + WorkflowStage, + WorkflowTransition, +} from "./workflow.ts"; +export { type DuneErrorCode, isDuneError } from "./errors.ts"; +export type { + ContentDiff, + ContentRevision, + HistoryEngine, + RecordInput, +} from "./history.ts"; +export type { + Scheduler, + StagedDraft, + StagingEngine, + UpsertInput, + WorkflowEngine, +} from "./engines.ts"; +export type { + Session, + SessionManager, + User, + UserCreate, + UserStore, +} from "./identity.ts"; +export type { + ClientIpOptions, + Logger, + PasswordCheckResult, + RateLimiter, + RateLimitStore, + SanitizeOptions, + SsrfCheckOptions, +} from "./security.ts"; +export type { + BlueprintDefinition, + BlueprintMap, + BlueprintValidationError, + ResolvedBlueprint, +} from "./blueprints.ts"; +export type { + AuthzService, + BlueprintService, + EngineServices, + PasswordService, + PathServices, + PluginServices, + RateLimitService, + SearchAnalyticsSummary, + SearchServices, + SecurityService, + SessionService, + UserService, +} from "./services.ts"; +export type { AuthzObject, AuthzRelation, PluginAuthz } from "./authz.ts"; +export type { + DuneConfig, + LanguageConfig, + SiteConfig, + SystemConfig, + ThemeConfig, +} from "./config.ts"; +export type { + AdminPrefix, + AuthenticateResult, + Capabilities, + CapabilityContext, + CapabilityFactory, + CapabilityProvider, + CapabilityReader, + CapabilityRegistrar, + Contributions, + RequestAuthenticator, + ScheduledActionExecutor, +} from "./capabilities.ts"; +export type { + AdminBarAction, + AdminBarActionContext, + AdminPageRegistration, + AdminRouteRegistration, + ContentEditorPlugin, + DunePlugin, + FormSubmitPayload, + HookContext, + HookEvent, + HookHandler, + HookRegistry, + MountApi, + PageMovePayload, + PluginApi, + PublicRouteRegistration, + ResponseTransformContext, + SystemActionPayload, +} from "./hooks.ts"; +export type { + FlexEngine, + FlexRecord, + FlexSchema, + JobScheduler, + JobState, + LatencyStats, + LoadedTemplate, + MetricsCollector, + MetricsSnapshot, + Page, + PluginEngine, + PluginRuntime, + PluginSearch, + RouteResolver, + SectionDef, + SectionField, + SectionRegistry, + SubmissionManager, + ThemeLoader, +} from "./runtime.ts"; diff --git a/src/page-index.ts b/src/page-index.ts new file mode 100644 index 0000000..8cf4904 --- /dev/null +++ b/src/page-index.ts @@ -0,0 +1,60 @@ +/** + * Lightweight page reference used by the content API and search engines. + * + * Wider than the theme contract's `PageIndex` on purpose: a search engine + * returns the entries core handed it inside its results, and core reads + * `mtime` and `hash` off them. Treat `hash` as opaque (pass entries through + * unchanged; do not build them by hand). Fields no plugin reads (role gating, + * publish windows, redirect and term-page markers, cover image) are not + * declared; core's entries carry them anyway. + * + * @module + */ + +export type ContentFormat = "md" | "tsx" | "mdx"; + +export interface PageIndex { + sourcePath: string; + route: string; + /** Language code. */ + language: string; + format: ContentFormat; + template: string; + title: string; + /** Navigation label; falls back to the title when `nav_title` is not set. */ + navTitle: string; + date: string | null; + /** `YYYY-MM-DD` from the `dateModified` frontmatter field, when present and valid. */ + dateModified?: string; + /** Whether the page is live right now (`published` and inside its publish window). */ + published: boolean; + /** Content workflow status. */ + status: "draft" | "in_review" | "published" | "archived"; + visible: boolean; + routable: boolean; + isModule: boolean; + order: number; + depth: number; + parentPath: string | null; + taxonomy: Record; + /** File modification time, for cache invalidation. Opaque: pass through. */ + mtime: number; + /** Hash of the frontmatter, for change detection. Opaque: pass through. */ + hash: string; + /** Site-declared custom frontmatter fields extracted for facet filtering. */ + extra?: Record; +} + +/** + * Frontmatter of a page. Only the fields the plugin contract itself relies on + * are named; any other field is allowed (`[key: string]: unknown`). The full + * catalogue of standard fields is in `@dune/theme-api`'s `PageFrontmatter`. + */ +export interface PageFrontmatter { + title: string; + template?: string; + published?: boolean; + date?: string; + taxonomy?: Record; + [key: string]: unknown; +} diff --git a/src/runtime.ts b/src/runtime.ts new file mode 100644 index 0000000..3e27a18 --- /dev/null +++ b/src/runtime.ts @@ -0,0 +1,274 @@ +/** + * What a plugin gets to work with once the site is up: `MountApi.runtime`. + * + * A curated slice of the booted site, as narrow structural interfaces: only the + * members plugins call are declared, and core's classes are proven assignable + * to them. Everything is read-only. `jobs` is a getter because the job + * scheduler is created per serving process, after `mount()`: read it lazily. + * + * Interfaces whose methods take objects core types more widely are declared + * with method syntax, which makes the parameter check bivariant. + * + * @module + */ + +import type { PluginAuthz } from "./authz.ts"; +import type { BlueprintField, BlueprintMap } from "./blueprints.ts"; +import type { DuneConfig, SiteConfig } from "./config.ts"; +import type { ContentApi } from "./content-api.ts"; +import type { Submission, SubmissionStatus } from "./forms.ts"; +import type { HistoryEngine } from "./history.ts"; +import type { HookRegistry } from "./hooks.ts"; +import type { + ContentFormat, + PageFrontmatter, + PageIndex, +} from "./page-index.ts"; +import type { SearchEngine } from "./search.ts"; +import type { StorageAdapter } from "./storage.ts"; + +/** A loaded page: the index entry plus lazily rendered content. */ +export interface Page { + sourcePath: string; + route: string; + language: string; + format: ContentFormat; + template: string; + navTitle: string; + frontmatter: PageFrontmatter; + /** Raw body, or null for formats that have none. */ + rawContent: string | null; + /** Render the page body to HTML. */ + html(): Promise; +} + +/** Maps URL paths to pages. */ +export interface RouteResolver { + findBySourcePath(sourcePath: string): PageIndex | undefined; + getNavigation(lang?: string): PageIndex[]; + getTopNavigation(lang?: string): PageIndex[]; +} + +/** A template resolved by a theme. */ +export interface LoadedTemplate { + /** The template component (a Preact component; typed `unknown` here). */ + component: unknown; +} + +/** Loads templates, layouts and locale strings from a theme. */ +export interface ThemeLoader { + resolveTemplateName(page: Page): string | null; + loadTemplate(name: string): Promise; + loadLayout(name: string): Promise; + loadLocale(lang: string): Promise>; +} + +/** The content engine: pages, routes, themes, rebuilds. */ +export interface PluginEngine { + readonly config: DuneConfig; + readonly site: SiteConfig; + /** The content index. */ + readonly pages: PageIndex[]; + /** Taxonomy name → term → source paths. */ + readonly taxonomyMap: Record>; + /** Loaded blueprint definitions, keyed by template name. */ + readonly blueprints: BlueprintMap; + /** User-controlled theme settings from `data/theme-config.json`. */ + readonly themeConfig: Record; + readonly router: RouteResolver; + /** The active theme's loader. */ + readonly themes: ThemeLoader; + /** Load a full page from its source path. */ + loadPage(sourcePath: string): Promise; + /** Rebuild the content index. */ + rebuild(): Promise; + /** + * Switch the active theme: swaps the loader, reloads theme config, fires + * `onThemeSwitch` and rebuilds. Does not persist the choice to disk. + */ + switchTheme(name: string): Promise; + /** A loader for a theme other than the active one, for previews. */ + createPreviewTheme(name: string): Promise; +} + +/** Search across the content index, plus engine management. */ +export interface PluginSearch extends SearchEngine { + register(name: string, engine: SearchEngine): void; + setActiveEngine(name: string): void; + activeEngineName(): string; + registeredEngineNames(): string[]; + setParallelMode(enabled: boolean): void; + isParallelMode(): boolean; +} + +/** A Flex Object schema. */ +export interface FlexSchema { + title: string; + icon?: string; + description?: string; + fields: Record; + version?: number; +} + +/** A single Flex Object record. */ +export interface FlexRecord { + _id: string; + _type: string; + _createdAt: number; + _updatedAt: number; + [key: string]: unknown; +} + +/** Flex Objects: schema-driven custom content types. */ +export interface FlexEngine { + loadSchemas(): Promise>; + list(type: string): Promise; + get(type: string, id: string): Promise; + create( + type: string, + schema: FlexSchema, + data: Record, + ): Promise; + update( + type: string, + id: string, + schema: FlexSchema, + data: Record, + ): Promise; + delete(type: string, id: string): Promise; +} + +/** Public form submissions: core writes them, an admin manages them. */ +export interface SubmissionManager { + get(form: string, id: string): Promise; + list(form: string): Promise; + listForms(): Promise; + setStatus( + form: string, + id: string, + status: SubmissionStatus, + ): Promise; + delete(form: string, id: string): Promise; +} + +/** A field in a page-builder section. */ +export interface SectionField { + id: string; + type: string; + label: string; + placeholder?: string; + default?: unknown; + required?: boolean; + options?: Array<{ value: string; label: string }>; + itemFields?: SectionField[]; +} + +/** A page-builder section type. */ +export interface SectionDef { + type: string; + label: string; + icon: string; + description: string; + fields: SectionField[]; +} + +/** The page-builder section registry the renderer uses. */ +export interface SectionRegistry { + all(): SectionDef[]; + get(type: string): SectionDef | undefined; +} + +/** Latency statistics. */ +export interface LatencyStats { + p50: number; + p95: number; + p99: number; + max: number; + count: number; +} + +/** A point-in-time view of in-process performance metrics. */ +export interface MetricsSnapshot { + ts: string; + uptimeSeconds: number; + requests: { + total: number; + errors: number; + errorRate: number; + latency: LatencyStats; + }; + topRoutes: Array< + { route: string; requests: number; errors: number; latency: LatencyStats } + >; + pageCache: { + entries: number; + hits: number; + misses: number; + hitRate: number; + evictions: number; + } | null; + memory: { + heapUsed: number; + heapTotal: number; + rss: number; + external: number; + }; + engine: { + pageCount: number; + rebuildCount: number; + lastRebuildMs: number | null; + }; + slowQueries: Array<{ + ts: string; + type: "collection" | "search"; + query: string; + durationMs: number; + }>; +} + +/** In-process performance metrics. */ +export interface MetricsCollector { + snapshot(): MetricsSnapshot; +} + +/** State of one background job. */ +export interface JobState { + name: string; + lastRun: number | null; + nextRun: number | null; + status: "idle" | "running" | "errored"; + lastError: string | null; +} + +/** The background job scheduler of the serving process. */ +export interface JobScheduler { + run(name: string): Promise; + listStatus(): Promise; + getStatus(name: string): Promise; +} + +/** The booted site, as a plugin sees it. */ +export interface PluginRuntime { + readonly engine: PluginEngine; + readonly config: DuneConfig; + /** Site storage (the site root, not only the content directory). */ + readonly storage: StorageAdapter; + readonly search: PluginSearch; + /** The hook registry: fire the page events when you write a page. */ + readonly hooks: HookRegistry; + /** Content query API. */ + readonly contentApi: ContentApi; + readonly history: HistoryEngine; + readonly flexEngine: FlexEngine; + readonly metrics: MetricsCollector; + readonly submissions: SubmissionManager; + readonly sections: SectionRegistry; + /** The authorisation system; absent when authz is not enabled. */ + readonly authz?: PluginAuthz; + /** + * The job scheduler of the serving process; `undefined` until `dune serve` + * starts it, which happens after `mount()`. A getter: read `runtime.jobs` + * when you need it. Destructuring it captures `undefined` for good. + */ + readonly jobs?: JobScheduler; +} diff --git a/src/search.ts b/src/search.ts new file mode 100644 index 0000000..51d9ae5 --- /dev/null +++ b/src/search.ts @@ -0,0 +1,146 @@ +/** + * The search-engine plugin contract: `onSearchRecordsCollect`, + * `onSearchEngineCreate` and the engine a plugin registers. + * + * @module + */ + +import type { PageIndex } from "./page-index.ts"; + +/** + * A search record contributed by a plugin via the `onSearchRecordsCollect` + * hook. Indexed from memory (no file read) and surfaced as a synthetic + * {@link PageIndex} at `route`. + */ +export interface InjectedSearchRecord { + /** Result link and unique index key, e.g. `/pdf/issue-1.pdf`. */ + route: string; + /** Display title. */ + title: string; + /** Plain text body to full-text index. */ + body: string; + /** Optional additional indexed fields (field name → text). */ + fields?: Record; + /** Template label for facets/filtering. Default: `"page"`. */ + template?: string; +} + +/** A single result returned by {@link SearchEngine.search}. */ +export interface SearchResult { + /** The matched page */ + page: PageIndex; + /** Relevance score (higher = better) */ + score: number; + /** Matching excerpt/context around the hit */ + excerpt: string; + /** Query terms that matched (for highlighting) */ + highlights?: string[]; + /** Facet field values extracted from this result's frontmatter */ + facetValues?: Record; +} + +/** + * Narrows results to documents whose `field` resolves to exactly `value`. + * `field` is either a {@link PageIndex} top-level property name (`template`, + * `language`) or a facet field declared via `system.search.facets[].field` + * in `site.yaml`, resolved from {@link PageIndex.extra}. + */ +export interface SearchFilter { + field: string; + value: string; +} + +/** Options accepted by {@link SearchEngine.search}. */ +export interface SearchOptions { + /** Narrow results to a single field=value match. */ + filter?: SearchFilter; + /** Result order. Default: `"relevance"`. */ + sort?: "relevance" | "date"; + /** + * Skip this many matching results before collecting `limit` of them — + * for paging through a result set. Default: `0`. + */ + offset?: number; +} + +/** Value → count map returned by {@link SearchEngine.facetCounts}. */ +export type FacetCounts = Record; + +/** Full-text search engine over the Dune content index. Obtain via {@link createSearchEngine}. */ +export interface SearchEngine { + /** Build the search index (call after content index is ready) */ + build(): Promise; + /** + * Search for pages matching a query. + * + * Async to allow external search backends (e.g. Meilisearch) to make + * network calls. The built-in in-memory engine resolves immediately. + */ + search( + query: string, + limit?: number, + options?: SearchOptions, + ): Promise; + /** + * Count distinct values of `field` across all documents matching `query` + * (ignoring `limit` and any filter) — used to render facet tabs with + * counts. Optional: engines that don't implement faceting simply omit it, + * and callers should treat a missing method as "no counts available". + */ + facetCounts?(query: string, field: string): Promise; + /** Rebuild index (after content changes) */ + rebuild(pages: PageIndex[]): Promise; + /** + * Return autocomplete suggestions for a given prefix string. + * + * Async to allow external search backends to serve suggestions from + * a network call. The built-in engine resolves immediately. + * + * @param prefix - The text the user has typed so far + * @param limit - Maximum number of suggestions (default: 10) + */ + suggest(prefix: string, limit?: number): Promise; +} + +/** + * Payload for the `onSearchRecordsCollect` hook. + * + * Plugins push entries onto `records` to add them to the search index + * (e.g. extracted PDF text). Fired once during bootstrap before the search + * engine is created. + */ +export interface SearchRecordsCollectContext { + records: InjectedSearchRecord[]; +} + +/** + * Payload for the `onSearchEngineCreate` hook. + * + * A plugin registers an engine with {@link SearchEngineCreateContext.register} + * and may make it active. The context carries what an engine needs to index: + * the pages, the plugin-injected records, and a loader for a page's plain-text + * body. (Core's own context also carries storage, the content directory, the + * configuration and the format registry; the first-party engines read none of + * them, so they are not part of the contract.) + */ +export interface SearchEngineCreateContext { + /** + * Register a named engine with the search manager. Call + * `register("meilisearch", engine)`; the built-in engine stays active unless + * you also call `setActiveEngine`. + */ + register: (name: string, engine: SearchEngine) => void; + /** + * Switch the active engine. Must have been registered via `register` first. + */ + setActiveEngine: (name: string) => void; + /** Content pages available to index. */ + pages: PageIndex[]; + /** Records collected from the `onSearchRecordsCollect` hook. */ + injectedRecords: InjectedSearchRecord[]; + /** + * Load a page's plain-text body, so an engine can index the same text the + * built-in engine does without reading files itself. + */ + loadText: (page: PageIndex) => Promise; +} diff --git a/src/security.ts b/src/security.ts new file mode 100644 index 0000000..d53fd4b --- /dev/null +++ b/src/security.ts @@ -0,0 +1,114 @@ +/** + * Security helper option types and the logger. + * + * @module + */ + +/** Structured logger interface — text or JSON output with named fields and child scoping. */ +export interface Logger { + debug(event: string, fields?: Record): void; + info(event: string, fields?: Record): void; + warn(event: string, fields?: Record): void; + error(event: string, fields?: Record): void; + /** Return a child logger with additional bound fields (e.g. requestId). */ + child(fields: Record): Logger; +} + +/** Result of a password-strength check — pass, or fail with a reason. */ +export type PasswordCheckResult = { ok: true } | { ok: false; reason: string }; + +/** Options for the HTML sanitizer's tag/attribute allowlist. */ +export interface SanitizeOptions { + /** Allow these tags in addition to the defaults. */ + extraTags?: readonly string[]; + /** Disallow these tags even if they're in the defaults. */ + disallowTags?: readonly string[]; + /** Allow tags. Default: true. */ + allowImages?: boolean; + /** Allow with href. Default: true. */ + allowLinks?: boolean; + /** + * Allow these attributes in addition to the per-tag defaults. + * Keyed by tag name, `*` applies to all allowed tags. Entries ending in + * `*` are prefix wildcards (`aria-*`, `data-*`). `on*` event handlers and + * `is`/`xmlns` are always stripped regardless; `style` is stripped unless + * listed here explicitly (literally, not via wildcard). + */ + extraAttrs?: Record; +} + +/** Options for the SSRF destination check. */ +export interface SsrfCheckOptions { + /** Caller opts in to allowing private/loopback destinations (e.g. for CI). */ + allowPrivateDestinations?: boolean; +} + +/** + * Extract a client IP from a Request for use as a rate-limit bucket key. + * + * By default forwarded headers are ignored (they are spoofable). The + * fallback is the TCP peer address stamped by {@link rememberSocketAddr} + * at the `Deno.serve` boundary, or `"unknown"` when that was never set + * (unit tests, SSG). `"unknown"` still rate-limits — every unbound caller + * shares one bucket. + * + * Operators behind a reverse proxy that overwrites X-Forwarded-For should + * set `system.trusted_proxies: true` so {@link clientIp} prefers those + * headers (the socket address is then the proxy, not the browser). + */ +export interface ClientIpOptions { + /** Honor X-Forwarded-For / X-Real-IP. Only set when behind a trusted edge. */ + trustForwardedFor?: boolean; +} + +export interface RateLimitStore { + /** + * Atomically check and increment the request counter for `key`. + * Returns `allowed: true` when the counter is still within `maxRequests` + * for the current `windowMs`, or `allowed: false` when the limit is reached. + * `retryAfter` is the number of seconds until the window resets (0 when allowed). + */ + check( + key: string, + maxRequests: number, + windowMs: number, + ): Promise<{ allowed: boolean; retryAfter: number }>; + + /** + * Record a single failure event for `key` (e.g. a bad login attempt). + * Returns the total number of failures recorded within `windowMs`. + */ + recordFailure(key: string, windowMs: number): Promise; + + /** + * Return true if the failure count for `key` within `windowMs` is at or + * above `threshold`. + */ + isLocked(key: string, threshold: number, windowMs: number): Promise; + + /** + * Clear all recorded failures for `key` (called on successful login). + */ + clearFailures(key: string): Promise; +} + +/** + * A fixed-window rate limiter keyed by an arbitrary string (usually a client + * IP). `check` is synchronous and in-process; the async methods go through the + * limiter's {@link RateLimitStore}, so they work across processes when a shared + * store is configured. + */ +export interface RateLimiter { + /** Count one request for `key`. False when the limit is exceeded. The key `"unknown"` is never limited. */ + check(key: string): boolean; + /** Like `check`, but through the store; also reports when to retry. */ + checkAsync(key: string): Promise<{ allowed: boolean; retryAfter: number }>; + /** Seconds until `key`'s window resets. */ + retryAfter(key: string): number; + /** Record a failed attempt for `key` (for lockouts); returns the count in the window. */ + recordFailure(key: string, windowMs?: number): Promise; + /** True when `key` has `threshold` or more failures in the window. */ + isLocked(key: string, threshold: number, windowMs?: number): Promise; + /** Forget the failures recorded for `key`. */ + clearFailures(key: string): Promise; +} diff --git a/src/services.ts b/src/services.ts new file mode 100644 index 0000000..628af77 --- /dev/null +++ b/src/services.ts @@ -0,0 +1,186 @@ +/** + * Services core hands to plugins (`PluginApi.services`, `MountApi.services`, + * `CapabilityContext.services`): the infrastructure a plugin should not + * reimplement or import from core internals. + * + * @module + */ + +import type { PageFrontmatter } from "./page-index.ts"; +import type { Scheduler, StagingEngine, WorkflowEngine } from "./engines.ts"; +import type { SessionManager, UserStore } from "./identity.ts"; +import type { + ClientIpOptions, + Logger, + PasswordCheckResult, + RateLimiter, + RateLimitStore, + SanitizeOptions, + SsrfCheckOptions, +} from "./security.ts"; +import type { + BlueprintValidationError, + ResolvedBlueprint, +} from "./blueprints.ts"; + +/** Stateless security policy. */ +export interface SecurityService { + /** Strip everything but a safe subset of HTML. */ + sanitizeHtml(input: string, opts?: SanitizeOptions): string; + /** `fetch` that refuses private, loopback and otherwise unsafe destinations (SSRF guard). */ + safeFetch( + url: string, + init?: RequestInit, + opts?: SsrfCheckOptions, + ): Promise; + /** A 413 response if the declared Content-Length exceeds `maxBytes`, else null. */ + checkBodySize(req: Request, maxBytes: number): Response | null; + /** Wrap a body stream so reading past `maxBytes` fails with a `body_too_large` error. */ + limitedBody( + stream: ReadableStream | null, + maxBytes: number, + ): ReadableStream; + /** Password strength policy. */ + checkPasswordStrength(password: unknown): PasswordCheckResult; + /** The client's IP, honouring forwarded headers only when `trustForwardedFor` is set. */ + clientIp(req: Request, opts?: ClientIpOptions): string; + /** Parse YAML written by users with the safe-schema policy. */ + parseUserYaml(text: string): unknown; +} + +/** Rate limiting primitives. */ +export interface RateLimitService { + /** A limiter allowing `maxRequests` per `windowMs`, optionally backed by a shared store. */ + createLimiter( + maxRequests: number, + windowMs: number, + store?: RateLimitStore, + ): RateLimiter; + /** An in-process store (the default for a single process). */ + createLocalStore(): RateLimitStore; +} + +/** Sessions over the backend configured in `system.session_store`. */ +export interface SessionService { + /** + * A session manager whose store is chosen by core from `system.session_store` + * (local files under `sessionsDir`, Deno KV or Redis) and scoped to this site + * (`session_store.prefix`, `dune:` in multisite). + */ + createManager( + opts: { sessionsDir: string; lifetimeMs: number }, + ): Promise; +} + +/** User accounts. */ +export interface UserService { + /** The flat-file user store under `usersDir` in the site's storage. */ + createLocalStore(opts: { usersDir: string }): UserStore; +} + +/** Password hashing (PBKDF2, format `pbkdf2:::`). */ +export interface PasswordService { + hash(password: string): Promise; + verify(password: string, stored: string): Promise; + /** True if the stored hash is below the current cost and should be re-hashed on next login. */ + needsRehash(stored: string): boolean; + /** A valid hash of nothing, to verify against for unknown users (equal-cost login). */ + readonly dummyHash: string; +} + +/** Access tuples on the site (`app:site`); the authz system itself is created by core. */ +export interface AuthzService { + /** + * Make sure every given user holds the relation for their admin-tier role on + * the site (`app:site`, the object `pages.update` and the other site-wide + * actions are checked on). Idempotent. A no-op when the site has no authz + * system (core creates one when a plugin provides `editor.session` or declares + * `authzActions`, or the site has a login). + */ + seedAdminTuples(users: Array<{ id: string; role: string }>): Promise; +} + +/** + * Long-lived engines core constructs once per site. The plugin uses them; it + * does not build them, so state and on-disk layout stay under core's control. + * They live under `admin.runtime_dir` (default `.dune/admin`). + */ +export interface EngineServices { + /** Content status transitions, from `site.workflow`. */ + workflow: WorkflowEngine; + /** Scheduled publish/unpublish store. CRUD only; the process that serves requests starts the polling loop. */ + scheduler: Scheduler; + /** Staged drafts of published pages. */ + staging: StagingEngine; +} + +/** The site's frontmatter schemas (blueprints), as currently loaded. */ +export interface BlueprintService { + /** True if the template has a blueprint. */ + has(template: string): boolean; + /** The template's blueprint with its `extends` chain flattened, or null if it has none. */ + resolve(template: string): ResolvedBlueprint | null; + /** Validate frontmatter against the template's blueprint. Empty means valid, or no blueprint. */ + validate( + frontmatter: PageFrontmatter, + template: string, + ): BlueprintValidationError[]; +} + +/** Search-related services. */ +export interface SearchServices { + analytics: { + /** True when `system.search.analytics` is on and queries are being recorded. */ + readonly enabled: boolean; + /** Aggregate the recorded queries; an empty summary when analytics is off or nothing is recorded yet. */ + summarize(limit?: number): Promise; + }; +} + +/** Where a site keeps its data. Site-relative, as storage paths. */ +export interface PathServices { + /** The persistent data directory: `system.data_dir`, default `data`. */ + readonly dataDir: string; + /** Plugin runtime state: `admin.runtime_dir`, default `.dune/admin`. */ + readonly runtimeDir: string; +} + +/** Everything core hands a plugin. */ +export interface PluginServices { + security: SecurityService; + rateLimit: RateLimitService; + sessions: SessionService; + users: UserService; + passwords: PasswordService; + logger: Logger; + engines: EngineServices; + authz: AuthzService; + blueprints: BlueprintService; + search: SearchServices; + paths: PathServices; +} + +/** Aggregated summary of recorded search events */ +export interface SearchAnalyticsSummary { + /** Total number of search events recorded since `since` */ + totalSearches: number; + /** Unix epoch milliseconds of the first recorded search; null when none */ + since: number | null; + /** + * Most-searched queries, sorted by count descending. + * Each entry also carries the average result count for that query. + */ + topQueries: Array<{ + query: string; + count: number; + avgResults: number; + }>; + /** + * Queries that returned zero results, sorted by count descending. + * Useful for discovering missing content. + */ + zeroResultQueries: Array<{ + query: string; + count: number; + }>; +} diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..b144454 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,81 @@ +/** + * Storage abstraction layer types. + * Both filesystem and Deno KV adapters implement StorageAdapter. + */ + +/** Unified storage interface */ +export interface StorageAdapter { + /** Read raw bytes from a path */ + read(path: string): Promise; + + /** Read text content from a path */ + readText(path: string): Promise; + + /** Write data to a path (creates parent directories as needed) */ + write(path: string, data: Uint8Array | string): Promise; + + /** Check if a path exists */ + exists(path: string): Promise; + + /** Delete a file at path */ + delete(path: string): Promise; + + /** + * Atomically rename/move a file or directory. + * On the filesystem adapter this uses Deno.rename() — an OS-level atomic + * rename that works across files and whole directory trees. + * Throws StorageError if oldPath does not exist. + */ + rename(oldPath: string, newPath: string): Promise; + + /** List immediate children of a directory */ + list(path: string): Promise; + + /** List all descendants of a directory recursively */ + listRecursive(path: string): Promise; + + /** Get file/directory metadata */ + stat(path: string): Promise; + + /** Get a cached JSON value by key */ + getJSON(key: string): Promise; + + /** Set a cached JSON value with optional TTL (seconds) */ + setJSON(key: string, value: T, ttl?: number): Promise; + + /** Delete a cached JSON value */ + deleteJSON(key: string): Promise; + + /** Watch a path for changes (returns unsubscribe function) */ + watch(path: string, callback: (event: WatchEvent) => void): () => void; +} + +/** Entry in a directory listing */ +export interface StorageEntry { + /** Filename (not full path) */ + name: string; + /** Full path relative to storage root */ + path: string; + /** Whether this entry is a file */ + isFile: boolean; + /** Whether this entry is a directory */ + isDirectory: boolean; +} + +/** File/directory metadata */ +export interface StorageStat { + /** File size in bytes */ + size: number; + /** Last modification time as unix timestamp (ms) */ + mtime: number; + /** Whether this is a file */ + isFile: boolean; + /** Whether this is a directory */ + isDirectory: boolean; +} + +/** Filesystem change event */ +export interface WatchEvent { + kind: "create" | "modify" | "remove"; + paths: string[]; +} diff --git a/src/workflow.ts b/src/workflow.ts new file mode 100644 index 0000000..c31abf4 --- /dev/null +++ b/src/workflow.ts @@ -0,0 +1,62 @@ +/** + * Scheduled page actions. + * + * @module + */ + +/** Scheduled action */ +export interface ScheduledAction { + /** Unique ID */ + id: string; + /** Source path of the page */ + sourcePath: string; + /** Action to perform */ + action: "publish" | "unpublish" | "archive"; + /** When to perform the action (epoch ms) */ + scheduledAt: number; + /** When this was created */ + createdAt: number; + /** Who scheduled it */ + createdBy?: string; +} + +/** A workflow stage identifier — built-in or custom */ +export type ContentStatus = string; + +/** A single workflow stage definition */ +export interface WorkflowStage { + /** Unique identifier — used as the status value in frontmatter */ + id: string; + /** Display label */ + label: string; + /** + * Badge color for admin UI. + * Named colors: "amber" | "blue" | "green" | "gray" | "orange" | "teal" | "red" | "purple" + * Hex values are also accepted (e.g. "#f59e0b"). + */ + color?: string; + /** + * When true, transitioning to this stage sets page.published = true. + * When false/absent, sets page.published = false. + */ + publish?: boolean; + /** + * Terminal stage — no transitions away from this stage are allowed unless + * explicitly defined. Informational only; the engine uses the transitions array. + */ + terminal?: boolean; +} + +/** A single allowed transition between stages */ +export interface WorkflowTransition { + from: string; + to: string; + /** Human-readable label for the transition button */ + label?: string; + /** + * Roles that can perform this transition. + * If omitted or empty, any authenticated user can perform it. + * Valid values: "admin" | "editor" | "author" + */ + roles?: string[]; +} diff --git a/tests/errors_test.ts b/tests/errors_test.ts new file mode 100644 index 0000000..e67fc77 --- /dev/null +++ b/tests/errors_test.ts @@ -0,0 +1,11 @@ +import { assertEquals } from "jsr:@std/assert@^1"; +import { isDuneError } from "../src/mod.ts"; + +Deno.test("isDuneError tells an error by its code, not its class", () => { + assertEquals(isDuneError({ code: "ssrf_blocked" }, "ssrf_blocked"), true); + assertEquals(isDuneError({ code: "ssrf_blocked" }, "weak_password"), false); + // A plain Error, a string and null are not Dune errors. + assertEquals(isDuneError(new Error("x"), "ssrf_blocked"), false); + assertEquals(isDuneError("ssrf_blocked", "ssrf_blocked"), false); + assertEquals(isDuneError(null, "ssrf_blocked"), false); +});