From a5580527e57cdd98a8949e8ac5fdba0be377da0f Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 13:41:55 +0100 Subject: [PATCH 1/9] feat: declarations for the app, storage, content API, search-engine and form contracts First slice of the plugin contract. Copied from @dune/core where the shape is already the public one, narrowed where core's is wider than a plugin needs (SearchEngineCreateContext). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 23 +++++++ README.md | 12 ++-- src/app.ts | 57 ++++++++++++++++ src/blueprints.ts | 134 +++++++++++++++++++++++++++++++++++++ src/content-api.ts | 162 +++++++++++++++++++++++++++++++++++++++++++++ src/errors.ts | 23 +++++++ src/forms.ts | 38 +++++++++++ src/inline-edit.ts | 67 +++++++++++++++++++ src/mod.ts | 53 +++++++++++++-- src/page-index.ts | 58 ++++++++++++++++ src/search.ts | 146 ++++++++++++++++++++++++++++++++++++++++ src/storage.ts | 81 +++++++++++++++++++++++ src/workflow.ts | 21 ++++++ 13 files changed, 867 insertions(+), 8 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/app.ts create mode 100644 src/blueprints.ts create mode 100644 src/content-api.ts create mode 100644 src/errors.ts create mode 100644 src/forms.ts create mode 100644 src/inline-edit.ts create mode 100644 src/page-index.ts create mode 100644 src/search.ts create mode 100644 src/storage.ts create mode 100644 src/workflow.ts diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7de668a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# 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`. + +Still to come: `DunePlugin`, `PluginApi`, `MountApi`, `HookRegistry` and the +hook events, `PluginRuntime`, `PluginServices`, the capability types. 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/blueprints.ts b/src/blueprints.ts new file mode 100644 index 0000000..f02d0a8 --- /dev/null +++ b/src/blueprints.ts @@ -0,0 +1,134 @@ +/** + * 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; +} 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/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/inline-edit.ts b/src/inline-edit.ts new file mode 100644 index 0000000..802da41 --- /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 `inline-edit` 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..8f00f9b 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -1,9 +1,54 @@ /** - * @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, 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 { ScheduledAction } from "./workflow.ts"; +export { type DuneErrorCode, isDuneError } from "./errors.ts"; diff --git a/src/page-index.ts b/src/page-index.ts new file mode 100644 index 0000000..409134f --- /dev/null +++ b/src/page-index.ts @@ -0,0 +1,58 @@ +/** + * 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 the cache and gating fields as opaque + * (pass them through unchanged; do not build entries by hand). + * + * @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; + /** `publish_date` as epoch ms, when set and valid. */ + publishAt?: number; + /** `unpublish_date` as epoch ms, when set and valid. */ + unpublishAt?: number; + /** True when the page is role-gated. Opaque: pass through. */ + gated?: boolean; + /** Raw `frontmatter.roles` when gated. Opaque: pass through. */ + roles?: unknown; + /** File modification time, for cache invalidation. Opaque: pass through. */ + mtime: number; + /** Hash of the frontmatter, for change detection. Opaque: pass through. */ + hash: string; + /** URL path to the cover image, derived from `frontmatter.image`. */ + coverImage?: string; + /** When set, visiting the route redirects to this URL (a media file). */ + fileUrl?: string; + /** Taxonomy term this page is the term page for, normalised to `{ vocab: value }`. */ + termPageFor?: Record; + /** Site-declared custom frontmatter fields extracted for facet filtering. */ + extra?: Record; +} 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/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..c832536 --- /dev/null +++ b/src/workflow.ts @@ -0,0 +1,21 @@ +/** + * 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; +} From 4d6f70f4f69359d55b48a16b3f991f31a78f37a5 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 14:08:12 +0100 Subject: [PATCH 2/9] feat: services, identity, engines, history and blueprint declarations (slice 2a) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 ++++ src/blueprints.ts | 43 +++++++++++ src/engines.ts | 99 +++++++++++++++++++++++++ src/history.ts | 75 +++++++++++++++++++ src/identity.ts | 151 +++++++++++++++++++++++++++++++++++++ src/mod.ts | 62 +++++++++++++++- src/page-index.ts | 14 ++++ src/security.ts | 114 ++++++++++++++++++++++++++++ src/services.ts | 185 ++++++++++++++++++++++++++++++++++++++++++++++ src/workflow.ts | 41 ++++++++++ 10 files changed, 797 insertions(+), 2 deletions(-) create mode 100644 src/engines.ts create mode 100644 src/history.ts create mode 100644 src/identity.ts create mode 100644 src/security.ts create mode 100644 src/services.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7de668a..57f05d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,5 +19,20 @@ itself): `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 `auth.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. + Still to come: `DunePlugin`, `PluginApi`, `MountApi`, `HookRegistry` and the hook events, `PluginRuntime`, `PluginServices`, the capability types. diff --git a/src/blueprints.ts b/src/blueprints.ts index f02d0a8..2e0cf71 100644 --- a/src/blueprints.ts +++ b/src/blueprints.ts @@ -132,3 +132,46 @@ export interface BlueprintFieldValidation { */ 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/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/history.ts b/src/history.ts new file mode 100644 index 0000000..608fc1a --- /dev/null +++ b/src/history.ts @@ -0,0 +1,75 @@ +/** + * Revision history. + * + * @module + */ + +import type { ContentStatus } from "./workflow.ts"; + +/** 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/identity.ts b/src/identity.ts new file mode 100644 index 0000000..e00faab --- /dev/null +++ b/src/identity.ts @@ -0,0 +1,151 @@ +/** + * Identity: users, sessions and password helpers, the pieces an `auth.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; + avatarUrl?: 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; + lastSeenAt: number; + enabled: boolean; + /** + * Stripe customer ID assigned after a successful checkout. + * Used by the billing portal handler — never accepted from client input. + */ + stripeCustomerId?: string; +} + +/** + * 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" + | "avatarUrl" + | "username" + | "passwordHash" + | "roles" + | "lastSeenAt" + | "enabled" + | "stripeCustomerId" + > + >, + ): 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/mod.ts b/src/mod.ts index 8f00f9b..d7bcb9b 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -33,7 +33,11 @@ export type { DocumentPresence, InlineEditManager, } from "./inline-edit.ts"; -export type { ContentFormat, PageIndex } from "./page-index.ts"; +export type { + ContentFormat, + PageFrontmatter, + PageIndex, +} from "./page-index.ts"; export type { FacetCounts, InjectedSearchRecord, @@ -50,5 +54,59 @@ export type { StorageStat, WatchEvent, } from "./storage.ts"; -export type { ScheduledAction } from "./workflow.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"; diff --git a/src/page-index.ts b/src/page-index.ts index 409134f..307deef 100644 --- a/src/page-index.ts +++ b/src/page-index.ts @@ -56,3 +56,17 @@ export interface PageIndex { /** 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/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..b1e5711 --- /dev/null +++ b/src/services.ts @@ -0,0 +1,185 @@ +/** + * 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 { StorageAdapter } from "./storage.ts"; +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; +} + +/** Admin-tier access tuples (the authz system itself is created by bootstrap). */ +export interface AuthzService { + /** + * Make sure every given user holds the tuple for their admin-tier role. + * Idempotent. A no-op when the site has no authz system (admin disabled, or + * `admin.authz_store` is not local). + */ + 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/workflow.ts b/src/workflow.ts index c832536..c31abf4 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -19,3 +19,44 @@ export interface ScheduledAction { /** 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[]; +} From b03cf730e7ef36423edeccca3678245b85b92647 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 21:45:07 +0100 Subject: [PATCH 3/9] feat: plugin object, hooks, capabilities, config and runtime declarations (slice 2b) DunePlugin, PluginApi, MountApi, HookRegistry, HookContext and the hook events; the capability slot and contribution types; a narrow DuneConfig and PluginAuthz; PluginRuntime with narrow structural interfaces for the engine, router, theme loader, search, flex, submissions, sections, metrics and jobs. Handlers and plugin callbacks are bivariant so core's wider contexts are accepted. Also drops two unused imports left by slice 2a. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 26 +++- src/authz.ts | 35 +++++ src/capabilities.ts | 139 +++++++++++++++++++ src/config.ts | 52 +++++++ src/history.ts | 2 - src/hooks.ts | 324 ++++++++++++++++++++++++++++++++++++++++++++ src/mod.ts | 60 ++++++++ src/runtime.ts | 274 +++++++++++++++++++++++++++++++++++++ src/services.ts | 1 - 9 files changed, 908 insertions(+), 5 deletions(-) create mode 100644 src/authz.ts create mode 100644 src/capabilities.ts create mode 100644 src/config.ts create mode 100644 src/hooks.ts create mode 100644 src/runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f05d0..91e8746 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,5 +34,27 @@ Slice 2a (services and what they return): - `HistoryEngine`, `RecordInput`, `ContentRevision`, `ContentDiff`; `RateLimiter`, `RateLimitStore`, `Logger` and the security option types. -Still to come: `DunePlugin`, `PluginApi`, `MountApi`, `HookRegistry` and the -hook events, `PluginRuntime`, `PluginServices`, the capability 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`. 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/capabilities.ts b/src/capabilities.ts new file mode 100644 index 0000000..c0a65cc --- /dev/null +++ b/src/capabilities.ts @@ -0,0 +1,139 @@ +/** + * 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; +} + +/** Slots: at most one active provider each. First-party keys only in 1.0. */ +export interface Capabilities { + /** Who is the admin on this request. */ + "auth.session": RequestAuthenticator; + /** Executes due scheduled actions. */ + "scheduler.executor": ScheduledActionExecutor; + /** Real-time inline editing. */ + "inline-edit": 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/history.ts b/src/history.ts index 608fc1a..74455e7 100644 --- a/src/history.ts +++ b/src/history.ts @@ -4,8 +4,6 @@ * @module */ -import type { ContentStatus } from "./workflow.ts"; - /** Records page revisions and provides diff and restore capabilities. Obtain via {@link createHistoryEngine}. */ export interface HistoryEngine { /** Record a new revision for a page */ 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/mod.ts b/src/mod.ts index d7bcb9b..1cac25b 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -110,3 +110,63 @@ export type { 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 { + 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/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/services.ts b/src/services.ts index b1e5711..7cf6e06 100644 --- a/src/services.ts +++ b/src/services.ts @@ -6,7 +6,6 @@ * @module */ -import type { StorageAdapter } from "./storage.ts"; import type { PageFrontmatter } from "./page-index.ts"; import type { Scheduler, StagingEngine, WorkflowEngine } from "./engines.ts"; import type { SessionManager, UserStore } from "./identity.ts"; From 82f2fbfd30b0982dfd9a42dbe6722fd4fc904eb9 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 22:16:50 +0100 Subject: [PATCH 4/9] refactor: declare only the User and PageIndex fields plugins read User loses avatarUrl, providerId, linkedProviders, lastSeenAt and stripeCustomerId; UserStore loses getByProvider, linkProvider and unlinkProvider. PageIndex loses gated, roles, publishAt, unpublishAt, fileUrl, termPageFor and coverImage. Nothing in the first-party plugins or the testing harness reads them; adding a field later is additive, removing one is not. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 9 +++++++++ src/identity.ts | 49 ++--------------------------------------------- src/page-index.ts | 20 ++++--------------- 3 files changed, 15 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91e8746..f79e5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,3 +58,12 @@ Slice 2b (the plugin object, hooks, capabilities and the runtime): 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`, `providerId`, +`linkedProviders`, `lastSeenAt` or `stripeCustomerId`, and `UserStore` no longer +declares `getByProvider`, `linkProvider` or `unlinkProvider`. `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. diff --git a/src/identity.ts b/src/identity.ts index e00faab..0f2103e 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -9,7 +9,6 @@ export interface User { id: string; email: string; name?: string; - avatarUrl?: string; /** * Login name for password-based (local admin) auth. Absent for * OAuth/magic-link-only accounts, which have no username concept. @@ -20,40 +19,21 @@ export interface User { /** * 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. + * This is 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; - lastSeenAt: number; enabled: boolean; - /** - * Stripe customer ID assigned after a successful checkout. - * Used by the billing portal handler — never accepted from client input. - */ - stripeCustomerId?: string; } /** * Input for creating a new {@link User} — server-generated fields are omitted. */ export type UserCreate = - & Omit + & Omit & { enabled?: boolean; }; @@ -63,30 +43,8 @@ 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, @@ -95,13 +53,10 @@ export interface UserStore { User, | "email" | "name" - | "avatarUrl" | "username" | "passwordHash" | "roles" - | "lastSeenAt" | "enabled" - | "stripeCustomerId" > >, ): Promise; diff --git a/src/page-index.ts b/src/page-index.ts index 307deef..8cf4904 100644 --- a/src/page-index.ts +++ b/src/page-index.ts @@ -3,8 +3,10 @@ * * 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 the cache and gating fields as opaque - * (pass them through unchanged; do not build entries by hand). + * `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 */ @@ -35,24 +37,10 @@ export interface PageIndex { depth: number; parentPath: string | null; taxonomy: Record; - /** `publish_date` as epoch ms, when set and valid. */ - publishAt?: number; - /** `unpublish_date` as epoch ms, when set and valid. */ - unpublishAt?: number; - /** True when the page is role-gated. Opaque: pass through. */ - gated?: boolean; - /** Raw `frontmatter.roles` when gated. Opaque: pass through. */ - roles?: unknown; /** File modification time, for cache invalidation. Opaque: pass through. */ mtime: number; /** Hash of the frontmatter, for change detection. Opaque: pass through. */ hash: string; - /** URL path to the cover image, derived from `frontmatter.image`. */ - coverImage?: string; - /** When set, visiting the route redirects to this URL (a media file). */ - fileUrl?: string; - /** Taxonomy term this page is the term page for, normalised to `{ vocab: value }`. */ - termPageFor?: Record; /** Site-declared custom frontmatter fields extracted for facet filtering. */ extra?: Record; } From 5a66402a775c34c27296a7cc3e537322648df036 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 22:23:39 +0100 Subject: [PATCH 5/9] feat: keep the OAuth identity fields and UserStore provider methods providerId, linkedProviders, getByProvider, linkProvider and unlinkProvider are what a third-party OAuth login provider is built from. No first-party plugin uses them yet; they stay so one can be written against the 1.0 contract. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 15 ++++++++------- src/identity.ts | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f79e5e7..2d9c5a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,10 +60,11 @@ 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`, `providerId`, -`linkedProviders`, `lastSeenAt` or `stripeCustomerId`, and `UserStore` no longer -declares `getByProvider`, `linkProvider` or `unlinkProvider`. `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. +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. diff --git a/src/identity.ts b/src/identity.ts index 0f2103e..8edaac5 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -19,9 +19,22 @@ export interface User { /** * External identity provider this account was originally created with: * "local" (password) | "github" | "google" | "discord" | "magic" | ... - * This is the account's original signup method. + * 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 @@ -43,8 +56,30 @@ 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, From fb57ee5958153c9e1609c40faea50441d4fc0d63 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Fri, 6 Nov 2026 23:18:56 +0100 Subject: [PATCH 6/9] feat: admin.prefix capability slot Where the admin is mounted. Core reads it instead of assuming /admin; with no provider there is no admin. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ src/capabilities.ts | 12 ++++++++++++ src/mod.ts | 1 + 3 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9c5a8..c219bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,3 +68,7 @@ first-party plugin uses them yet: an OAuth login provider is built from them. `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/src/capabilities.ts b/src/capabilities.ts index c0a65cc..567dbfa 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -65,8 +65,20 @@ 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. */ "auth.session": RequestAuthenticator; /** Executes due scheduled actions. */ diff --git a/src/mod.ts b/src/mod.ts index 1cac25b..901288e 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -119,6 +119,7 @@ export type { ThemeConfig, } from "./config.ts"; export type { + AdminPrefix, AuthenticateResult, Capabilities, CapabilityContext, From 1141d11829e67723fb8e075479f3a76af626976c Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 16:21:12 +0100 Subject: [PATCH 7/9] docs: AuthzService seeds the site resource and says when authz exists Co-Authored-By: Claude Sonnet 5.5 --- src/services.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/services.ts b/src/services.ts index 7cf6e06..ae24a62 100644 --- a/src/services.ts +++ b/src/services.ts @@ -88,12 +88,14 @@ export interface PasswordService { readonly dummyHash: string; } -/** Admin-tier access tuples (the authz system itself is created by bootstrap). */ +/** 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 tuple for their admin-tier role. - * Idempotent. A no-op when the site has no authz system (admin disabled, or - * `admin.authz_store` is not local). + * 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 `auth.session` or declares + * `authzActions`, or the site has a login). */ seedAdminTuples(users: Array<{ id: string; role: string }>): Promise; } From aa90036255c02d974fa3292305e65ce7206b269b Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Sat, 7 Nov 2026 19:37:46 +0100 Subject: [PATCH 8/9] chore: use the editor.session and editor.inline slot names (duneorg/dune#125) Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- src/capabilities.ts | 4 ++-- src/identity.ts | 2 +- src/inline-edit.ts | 2 +- src/services.ts | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c219bfa..6eb4092 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ 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 `auth.session` provider builds local +- 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`, diff --git a/src/capabilities.ts b/src/capabilities.ts index 567dbfa..aa8dd3b 100644 --- a/src/capabilities.ts +++ b/src/capabilities.ts @@ -80,11 +80,11 @@ export interface Capabilities { /** Where the admin is mounted. */ "admin.prefix": AdminPrefix; /** Who is the admin on this request. */ - "auth.session": RequestAuthenticator; + "editor.session": RequestAuthenticator; /** Executes due scheduled actions. */ "scheduler.executor": ScheduledActionExecutor; /** Real-time inline editing. */ - "inline-edit": InlineEditManager; + "editor.inline": InlineEditManager; /** A replacement page editor. */ "editor.content": ContentEditorPlugin; } diff --git a/src/identity.ts b/src/identity.ts index 8edaac5..2e97fa5 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -1,5 +1,5 @@ /** - * Identity: users, sessions and password helpers, the pieces an `auth.session` provider builds local login from. + * Identity: users, sessions and password helpers, the pieces an `editor.session` provider builds local login from. * * @module */ diff --git a/src/inline-edit.ts b/src/inline-edit.ts index 802da41..6fa89f7 100644 --- a/src/inline-edit.ts +++ b/src/inline-edit.ts @@ -4,7 +4,7 @@ * 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 `inline-edit` capability (e.g. + * 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. * diff --git a/src/services.ts b/src/services.ts index ae24a62..628af77 100644 --- a/src/services.ts +++ b/src/services.ts @@ -94,7 +94,7 @@ 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 `auth.session` or declares + * 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; From bd1ef4aaea3f629af93f18d51dc7bb9246024cb2 Mon Sep 17 00:00:00 2001 From: zumbrunn Date: Tue, 10 Nov 2026 13:50:01 +0100 Subject: [PATCH 9/9] test: isDuneError (CI's test task needs a tests/ directory) Co-Authored-By: Claude Sonnet 5.5 --- tests/errors_test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/errors_test.ts 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); +});