/** * Plugin System Types v2 * * New plugin API with: * - Single unified context shape for all hooks and routes * - Paginated storage queries (no async iterators) * - Unified KV API (replaces settings + options) * - Explicit ctx.http and ctx.log * */ import type { Permission } from "@premium-cms/auth"; import type { Element } from "@premium-cms/blocks"; // The plugin capability vocabulary, the legacy-rename map, and the manifest // shape are authored once in @premium-cms/plugin-types and shared between core // (the manifest reader at install/runtime) and @premium-cms/plugin-cli (the // manifest writer at bundle/publish time). // // We import-and-re-export here so existing internal callers keep working // (e.g. `import { PluginCapability } from "../plugins/types.js"`). import { CAPABILITY_RENAMES, capabilitiesToDeclaredAccess, declaredAccessToCapabilities, isDeprecatedCapability, normalizeCapabilities, normalizeCapability, type CurrentPluginCapability, type DeclaredAccess, type DeprecatedPluginCapability, type ManifestHookEntry, type ManifestMcpTool, type ManifestRouteEntry, type PluginMcpManifestConfig, type PluginCapability, type PluginStorageConfig, type StorageCollectionConfig, } from "@premium-cms/plugin-types"; import type { JSX } from "astro/jsx-runtime"; import type { z } from "astro/zod"; // ============================================================================= // Core Types // ============================================================================= import type { ContentFieldFilters } from "../content-list-query.js"; import type { FieldType } from "../schema/types.js"; export type { ContentFieldFilterScalar, ContentFieldFilterValue, ContentFieldFilters, ContentFieldInFilter, ContentFieldRangeFilter, } from "../content-list-query.js"; export { CAPABILITY_RENAMES, capabilitiesToDeclaredAccess, declaredAccessToCapabilities, isDeprecatedCapability, normalizeCapabilities, normalizeCapability, type CurrentPluginCapability, type DeclaredAccess, type DeprecatedPluginCapability, type ManifestHookEntry, type ManifestMcpTool, type ManifestRouteEntry, type PluginMcpManifestConfig, type PluginCapability, type PluginStorageConfig, type StorageCollectionConfig, }; // ============================================================================= // Storage Types // ============================================================================= // // `StorageCollectionConfig` and `PluginStorageConfig` are re-exported above // from `@premium-cms/plugin-types`. The manifest carries these shapes // verbatim; both this package (reader) and plugin-cli (writer) agree on // the same types via the shared package. /** * Query filter operators */ export interface RangeFilter { gt?: number | string; gte?: number | string; lt?: number | string; lte?: number | string; } export interface InFilter { in: Array; } export interface StartsWithFilter { startsWith: string; } /** * Where clause value types */ export type WhereValue = | string | number | boolean | null | RangeFilter | InFilter | StartsWithFilter; /** * Where clause for storage queries */ export type WhereClause = Record; /** * Query options for storage.query() */ export interface QueryOptions { where?: WhereClause; orderBy?: Record; limit?: number; // Default 50, max 1000 cursor?: string; } /** * Paginated result (used by storage.query, content.list, media.list) */ export interface PaginatedResult { items: T[]; cursor?: string; hasMore: boolean; } /** * Storage collection interface - the API exposed to plugins * No async iterators - all operations return promises with pagination */ export interface StorageCollection { // Basic CRUD get(id: string): Promise; put(id: string, data: T): Promise; delete(id: string): Promise; exists(id: string): Promise; // Batch operations getMany(ids: string[]): Promise>; putMany(items: Array<{ id: string; data: T }>): Promise; deleteMany(ids: string[]): Promise; // Query - always paginated query(options?: QueryOptions): Promise>; count(where?: WhereClause): Promise; } /** * Plugin storage context - typed based on declared collections */ export type PluginStorage = { [K in keyof T]: StorageCollection; }; // ============================================================================= // Context APIs // ============================================================================= /** * KV store interface - unified replacement for settings + options * * Convention: * - `settings:*` - User-configurable preferences (shown in admin UI) * - `state:*` - Internal plugin state (not shown to users) */ export interface KVAccess { get(key: string): Promise; set(key: string, value: unknown): Promise; delete(key: string): Promise; list(prefix?: string): Promise>; } /** * SEO metadata for a content item, as stored in the core SEO panel. * * Only present on items in collections with `has_seo = 1`. For collections * without SEO enabled, `ContentItem.seo` is `undefined`. */ export interface ContentItemSeo { title: string | null; description: string | null; image: string | null; canonical: string | null; noIndex: boolean; } /** * SEO input accepted by content write operations. * * All fields are optional — only fields that are present overwrite existing * values. An empty object is treated as a no-op. */ export interface ContentItemSeoInput { title?: string | null; description?: string | null; image?: string | null; canonical?: string | null; noIndex?: boolean; } /** * Content item returned from content API */ export interface ContentItem { id: string; type: string; slug: string | null; status: string; locale: string | null; data: Record; /** * SEO metadata, populated when the collection has SEO enabled * (`has_seo = 1`). `undefined` for non-SEO collections. */ seo?: ContentItemSeo; createdAt: string; updatedAt: string; publishedAt: string | null; /** Scheduled publication time, if set (e.g. scheduled items or scheduled draft changes). */ scheduledAt?: string | null; } export interface ContentListWhere { /** Exact match on `status` (e.g. `"published"`, `"draft"`). */ status?: string; /** Exact match on `locale` (e.g. `"en"`, `"fr-CA"`). */ locale?: string; /** AND-combined filters over custom fields explicitly marked as indexed. */ fieldFilters?: ContentFieldFilters; } /** * Content list options */ export interface ContentListOptions { limit?: number; cursor?: string; orderBy?: Record; where?: ContentListWhere; } /** * Input accepted by `content.create` / `content.update`. * * Most entries are field slugs mapped to their values. The reserved `seo` * key is extracted and routed to the core SEO panel (the `_emdash_seo` * table), matching the shape accepted by the REST API. Passing `seo` for a * collection that does not have SEO enabled throws a validation error. */ export type ContentWriteInput = Record & { seo?: ContentItemSeoInput; }; /** Options accepted by `content.create`. */ export interface ContentCreateOptions { /** Locale for the new content row. Defaults to the configured site locale, then `en`. */ locale?: string; } /** * Taxonomy definition returned from the taxonomy API (e.g. "category", "tag"). */ export interface TaxonomyDefInfo { name: string; label: string; labelSingular: string | null; hierarchical: boolean; /** Collections this taxonomy is attached to (e.g. `["posts"]`). */ collections: string[]; locale: string; } /** * Taxonomy term returned from the taxonomy API. Flat shape — for hierarchical * taxonomies the tree is reconstructed via `parentId` (which stores the * parent's locale-agnostic `translationGroup`). */ export interface TaxonomyTermInfo { id: string; /** Taxonomy name this term belongs to (e.g. "category"). */ taxonomy: string; slug: string; label: string; parentId: string | null; /** Term metadata as edited in the admin (`description` etc.). */ data: Record | null; locale: string; translationGroup: string | null; } /** * Options accepted by taxonomy read operations. Omitting `locale` returns * rows for every locale. */ export interface TaxonomyReadOptions { locale?: string; } /** * Content access interface - capability-gated */ export interface ContentAccess { // Read operations (requires read:content) get(collection: string, id: string): Promise; list(collection: string, options?: ContentListOptions): Promise>; // Write operations (requires write:content) - optional on interface create?( collection: string, data: ContentWriteInput, options?: ContentCreateOptions, ): Promise; update?(collection: string, id: string, data: ContentWriteInput): Promise; delete?(collection: string, id: string): Promise; } /** * Taxonomy access interface — capability-gated on `taxonomies:read`. * Read-only: there is no plugin-facing taxonomy write API. */ export interface TaxonomyAccess { /** List taxonomy definitions. */ getAll(options?: TaxonomyReadOptions): Promise; /** All terms of a taxonomy, ordered by label. */ getTerms(taxonomy: string, options?: TaxonomyReadOptions): Promise; /** Terms assigned to a content entry, optionally scoped to one taxonomy. */ getEntryTerms( collection: string, entryId: string, options?: TaxonomyReadOptions & { taxonomy?: string }, ): Promise; } /** * Full content access with write operations */ export interface ContentAccessWithWrite extends ContentAccess { create( collection: string, data: ContentWriteInput, options?: ContentCreateOptions, ): Promise; update(collection: string, id: string, data: ContentWriteInput): Promise; delete(collection: string, id: string): Promise; /** * Delete an entry outright (no trash). Fires `content:afterDelete` with * `permanent: true`. For entries whose existence IS the resource (a * provisioned project), where a trashed row would be a lie. */ permanentDelete(collection: string, id: string): Promise; } /** * Media item returned from media API */ export interface MediaItem { id: string; filename: string; mimeType: string; size: number | null; url: string; createdAt: string; } /** * Media list options */ export interface MediaListOptions { limit?: number; cursor?: string; mimeType?: string; // Filter by mime type prefix, e.g., "image/" } /** * Media access interface - capability-gated */ export interface MediaAccess { // Read operations (requires read:media) get(id: string): Promise; list(options?: MediaListOptions): Promise>; // Write operations (requires write:media) - optional on interface getUploadUrl?( filename: string, contentType: string, ): Promise<{ uploadUrl: string; mediaId: string }>; /** * Upload media bytes directly. Preferred in sandboxed mode where * plugins cannot make external requests to a presigned URL. * Returns the created media item. */ upload?( filename: string, contentType: string, bytes: ArrayBuffer, ): Promise<{ mediaId: string; storageKey: string; url: string }>; delete?(id: string): Promise; } /** * Full media access with write operations */ export interface MediaAccessWithWrite extends MediaAccess { getUploadUrl( filename: string, contentType: string, ): Promise<{ uploadUrl: string; mediaId: string }>; upload( filename: string, contentType: string, bytes: ArrayBuffer, ): Promise<{ mediaId: string; storageKey: string; url: string }>; delete(id: string): Promise; } /** * HTTP client interface - requires network:fetch capability */ export interface HttpAccess { fetch(url: string, init?: RequestInit): Promise; } /** * Logger interface - always available */ export interface LogAccess { debug(message: string, data?: unknown): void; info(message: string, data?: unknown): void; warn(message: string, data?: unknown): void; error(message: string, data?: unknown): void; } // ============================================================================= // Site & User Access // ============================================================================= /** * Site information available to all plugins */ export interface SiteInfo { /** Site name (from settings) */ name: string; /** Site URL (from settings or request) */ url: string; /** * The site's origin on the hosting platform (`https://p.premium-cms.com`), * which stays the same when a custom domain becomes the site URL. Absent on * sites that are not hosted by a control plane. */ platformUrl?: string; /** Site locale (from settings, defaults to "en") */ locale: string; /** * Astro's `trailingSlash` routing policy, from the host's Astro config. * Plugins that build absolute URLs (sitemap, canonical, hreflang) should * honor this so the URLs they emit match what the site serves. `createSiteInfo` * always populates it (defaulting to `"ignore"`, Astro's default); it is * optional on the type so pre-existing `SiteInfo` construction stays valid. */ trailingSlash?: "always" | "never" | "ignore"; } /** * Read-only user information exposed to plugins. * Sensitive fields (password hashes, sessions, passkeys) are excluded. */ export interface UserInfo { id: string; email: string; name: string | null; /** Legacy role level (10 subscriber … 50 admin); the role itself is `roleId`. */ role: number; /** The role the user holds (`role:admin`, a custom role id …); null on rows predating roles. */ roleId?: string | null; createdAt: string; /** True when the request was authenticated by an API token rather than a signed-in session. */ tokenAuth?: boolean; } /** A role of the site, as plugins may list them (`ctx.users.listRoles()`). */ export interface RoleInfo { id: string; slug: string; name: string; /** Legacy level the role most resembles. */ level: number; builtin: boolean; } /** * User access interface - requires read:users capability */ export interface UserAccess { /** Get a user by ID */ get(id: string): Promise; /** Get a user by email */ getByEmail(email: string): Promise; /** List users with optional filters */ list(opts?: { role?: number; limit?: number; cursor?: string }): Promise<{ items: UserInfo[]; nextCursor?: string; }>; /** The site's roles, highest level first (so a plugin can offer a role picker or scope something per role). */ listRoles(): Promise; } // ============================================================================= // Plugin Context // ============================================================================= /** * The unified plugin context - same shape for all hooks and routes */ export interface PluginContext { /** Plugin metadata */ plugin: { id: string; version: string; }; /** Storage collections - only if plugin declares storage */ storage: PluginStorage; /** Key-value store for config and state */ kv: KVAccess; /** Content access - only if read:content or write:content capability */ content?: ContentAccess | ContentAccessWithWrite; /** Taxonomy access (read-only) - only if taxonomies:read capability */ taxonomies?: TaxonomyAccess; /** Media access - only if read:media or write:media capability */ media?: MediaAccess | MediaAccessWithWrite; /** HTTP client - only if network:fetch capability */ http?: HttpAccess; /** Logger - always available */ log: LogAccess; /** Site information - always available */ site: SiteInfo; /** URL helper - generates absolute URLs from paths. Always available. */ url(path: string): string; /** User access - only if read:users capability */ users?: UserAccess; /** * The site's connected GitHub repository — requires `github:connection`. * Undefined until the owner connects GitHub (Settings → General). */ github?: GitHubConnectionAccess; /** Cron task scheduling - always available, scoped to plugin */ cron?: CronAccess; /** Email access - only if email:send capability and a provider is configured */ email?: EmailAccess; /** Think agents hosted by this instance — requires `agents:run`. */ agents?: AgentsAccess; /** The instance's build sandbox — requires `sandbox:build`. */ sandbox?: SandboxAccess; } // ============================================================================= // Agents (Think) and the build sandbox, hosted by the instance // ============================================================================= /** A skill handed to an agent: the same shape as a bundled Think skill. */ export interface AgentSkillSpec { name: string; description: string; body: string; } export interface AgentMcpServerSpec { name: string; url: string; headers?: Record; transport?: "streamable-http" | "sse" | "auto"; } /** Where the runtime reports: a route of the calling plugin, signed with `secret` (`X-Agent-Signature: sha256=` over the body). */ export interface AgentCallbackSpec { /** Route name within the plugin (public route). */ route: string; secret: string; /** Copied into every callback body. */ data?: Record; } export interface AgentRunSpec { /** Stable per task (an issue number, a job id): reruns land on the same agent. */ id: string; model?: string; reasoning?: "low" | "medium" | "high"; systemPrompt?: string; skills?: AgentSkillSpec[]; mcp?: AgentMcpServerSpec[]; /** Also load the repository's own `.agents/skills` and `.mcp.json`. */ repo?: { owner: string; repo: string; branch: string; token: string }; /** The message that starts (or continues) the run. */ input: string; idempotencyKey?: string; /** Tool names matching this regex source are refused. */ forbidTools?: string; maxSteps?: number; callback?: AgentCallbackSpec; } export interface AgentSessionSpec { id: string; model?: string; reasoning?: "low" | "medium" | "high"; systemPrompt?: string; skills?: AgentSkillSpec[]; mcp?: AgentMcpServerSpec[]; /** Attach the browser bridge (tools that run in the editor's tab). */ browser?: boolean; user: { id: string; name: string | null; email: string; role: number }; /** Kept on the runtime for child sessions; `{{secret:name}}` in MCP headers expands to them. */ secrets?: Record; expiresAt: string; /** Inherit user, secrets and expiry from this session of the same plugin. */ parent?: string; maxSteps?: number; } export interface AgentsAccess { /** Queue a run; the callback (if any) reports `{status, answer, submissionId, ...data}` when it ends. */ run(spec: AgentRunSpec): Promise<{ submissionId: string; accepted: boolean }>; status(id: string, submissionId: string): Promise<{ status: string; answer: string | null }>; transcript(id: string, limit?: number): Promise>; /** Open a chat session; the browser connects to `/_emdash/agents/chat/plugin-agent/?ticket=…` on the site origin. */ session(spec: AgentSessionSpec): Promise<{ agent: string; ticket: string; expiresAt: string }>; sessionInfo(id: string): Promise<{ open: boolean; user: AgentSessionSpec["user"] | null; expiresAt: string | null }>; endSession(id: string): Promise; /** One turn without a client (operators, tests). */ say(id: string, text: string): Promise<{ status: string; answer: string | null }>; } /** A site build in the instance's container: check → build → static branch → tests → preview wait → preview tests. */ export interface SandboxBuildSpec { /** Stable key for the build lane (a PR number, a branch). */ id: string; owner: string; repo: string; headRef: string; headSha: string; pr: number; attempt: number; staticBranch: string; token: string; backendUrl: string; /** The frontend service account's API token (EMDASH_API_TOKEN in the build). */ apiToken: string; siteUrl: string; previewUrl?: string | null; previous?: number; previousUrls?: Array; /** Final result → this route; stage reports → `stageRoute` (same secret). */ callback: AgentCallbackSpec; stageRoute?: string; } export interface SandboxAccess { build(spec: SandboxBuildSpec): Promise<{ accepted: true }>; buildStatus(id: string): Promise<{ running: unknown; last: unknown }>; } // ============================================================================= // Cron Types // ============================================================================= /** * Cron access interface �� always available on plugin context, scoped to plugin. */ export interface CronAccess { /** Schedule a recurring or one-shot task */ schedule(name: string, opts: { schedule: string; data?: Record }): Promise; /** Cancel a scheduled task */ cancel(name: string): Promise; /** List this plugin's scheduled tasks */ list(): Promise; } /** * Task info returned from CronAccess.list() */ export interface CronTaskInfo { name: string; schedule: string; nextRunAt: string; lastRunAt: string | null; } /** * Event passed to the `cron` hook handler */ /** Read access to the site's GitHub connection (`github:connection`). */ export interface GitHubConnectionAccess { /** * The connection, or null while GitHub is not connected. Never cached. * `frontendToken` is the frontend service account's API token — what a * build passes as EMDASH_API_TOKEN to read the content snapshot — so a * plugin can build the site elsewhere; empty when the site has none yet. */ get(): Promise<{ token: string; owner: string; repo: string; branch: string; frontendToken: string; } | null>; } export interface CronEvent { name: string; data?: Record; scheduledAt: string; } /** * Cron hook handler type */ export type CronHandler = (event: CronEvent, ctx: PluginContext) => Promise; // ============================================================================= // Email Types // ============================================================================= /** * Email access interface — requires `email:send` capability. * Undefined when no `email:deliver` provider is configured. * * Related capabilities: * - `email:send` — grants ctx.email (this interface) * - `email:provide` — allows registering the `email:deliver` exclusive hook * - `email:intercept` — allows registering `email:beforeSend` / `email:afterSend` hooks */ export interface EmailAccess { send(message: EmailMessage): Promise; } /** * Email message shape */ export interface EmailMessage { to: string; subject: string; text: string; html?: string; } /** * Event passed to email:beforeSend hooks (middleware — transform, validate, cancel) */ export interface EmailBeforeSendEvent { message: EmailMessage; /** Where the email originated — "system" for auth emails, plugin ID for plugin emails */ source: string; } /** * Event passed to email:deliver hook (exclusive — exactly one provider delivers) */ export interface EmailDeliverEvent { message: EmailMessage; source: string; } /** * Event passed to email:afterSend hooks (logging, analytics, fire-and-forget) */ export interface EmailAfterSendEvent { message: EmailMessage; source: string; } /** * Handler type for email:beforeSend hooks. * Returns modified message, or false to cancel delivery. */ export type EmailBeforeSendHandler = ( event: EmailBeforeSendEvent, ctx: PluginContext, ) => Promise; /** * Handler type for email:deliver hooks (exclusive provider). */ export type EmailDeliverHandler = (event: EmailDeliverEvent, ctx: PluginContext) => Promise; /** * Handler type for email:afterSend hooks (fire-and-forget). */ export type EmailAfterSendHandler = ( event: EmailAfterSendEvent, ctx: PluginContext, ) => Promise; // ============================================================================= // Comment Types // ============================================================================= /** * Collection comment settings (read from _emdash_collections) */ export interface CollectionCommentSettings { commentsEnabled: boolean; commentsModeration: "all" | "first_time" | "none"; commentsClosedAfterDays: number; commentsAutoApproveUsers: boolean; } /** * Event passed to comment:beforeCreate hooks (middleware — transform, enrich, reject) */ export interface CommentBeforeCreateEvent { comment: { collection: string; contentId: string; parentId: string | null; authorName: string; authorEmail: string; authorUserId: string | null; body: string; ipHash: string | null; userAgent: string | null; }; /** Metadata bag — plugins can attach signals for the moderator */ metadata: Record; } /** * Event passed to comment:moderate hook (exclusive — decides initial status) */ export interface CommentModerateEvent { comment: CommentBeforeCreateEvent["comment"]; metadata: Record; collectionSettings: CollectionCommentSettings; /** Number of prior approved comments from this email address */ priorApprovedCount: number; } /** * Moderation decision returned by the comment:moderate handler */ export interface ModerationDecision { status: "approved" | "pending" | "spam"; /** Optional reason for admin visibility */ reason?: string; } /** * Stored comment shape (full record with id, status, timestamps) */ export interface StoredComment { id: string; collection: string; contentId: string; parentId: string | null; authorName: string; authorEmail: string; authorUserId: string | null; body: string; status: string; moderationMetadata: Record | null; createdAt: string; updatedAt: string; } /** * Event passed to comment:afterCreate hooks (fire-and-forget) */ export interface CommentAfterCreateEvent { comment: StoredComment; metadata: Record; /** The content item the comment is on */ content: { id: string; collection: string; slug: string; title?: string }; /** The content author (for notifications) */ contentAuthor?: { id: string; name: string | null; email: string }; } /** * Event passed to comment:afterModerate hooks (fire-and-forget, admin status change) */ export interface CommentAfterModerateEvent { comment: StoredComment; previousStatus: string; newStatus: string; /** The admin who moderated */ moderator: { id: string; name: string | null }; } /** * Handler type for comment:beforeCreate hooks. * Returns modified event, or false to reject the comment. */ export type CommentBeforeCreateHandler = ( event: CommentBeforeCreateEvent, ctx: PluginContext, ) => Promise; /** * Handler type for comment:moderate hook (exclusive provider). */ export type CommentModerateHandler = ( event: CommentModerateEvent, ctx: PluginContext, ) => Promise; /** * Handler type for comment:afterCreate hooks (fire-and-forget). */ export type CommentAfterCreateHandler = ( event: CommentAfterCreateEvent, ctx: PluginContext, ) => Promise; /** * Handler type for comment:afterModerate hooks (fire-and-forget). */ export type CommentAfterModerateHandler = ( event: CommentAfterModerateEvent, ctx: PluginContext, ) => Promise; // ============================================================================= // Hook Types // ============================================================================= /** * Hook configuration */ export interface HookConfig { /** Explicit ordering - lower numbers run first (default: 100) */ priority?: number; /** Max execution time in ms (default: 5000) */ timeout?: number; /** Run after these plugins */ dependencies?: string[]; /** Error handling policy */ errorPolicy?: "continue" | "abort"; /** * Mark this hook as exclusive — only one plugin can be the active provider. * Exclusive hooks skip the priority pipeline and dispatch only to the * admin-selected provider. Used for email:deliver, search, image optimization, etc. */ exclusive?: boolean; /** The hook handler */ handler: THandler; } /** * Content hook event */ export interface ContentHookEvent { content: Record; collection: string; isNew: boolean; } /** * Content delete hook event */ export interface ContentDeleteEvent { id: string; collection: string; /** `true` when the content is permanently deleted (not just trashed). */ permanent: boolean; } /** * Content state-change hook event (fired after publish, unpublish, restore, * schedule, or unschedule). */ export interface ContentStateChangeEvent { content: Record; collection: string; } /** * Content publish/unpublish hook event. */ export type ContentPublishStateChangeEvent = ContentStateChangeEvent; /** * Content restore hook event. */ export type ContentRestoreStateChangeEvent = ContentStateChangeEvent; /** * Content schedule/unschedule hook event. */ export type ContentScheduleStateChangeEvent = ContentStateChangeEvent; /** * Media hook event */ export interface MediaUploadEvent { file: { name: string; type: string; size: number }; } /** * Media after upload event */ export interface MediaAfterUploadEvent { media: MediaItem; } /** * Lifecycle hook event */ export interface LifecycleEvent { // Empty for install/activate/deactivate } /** * Uninstall hook event */ export interface UninstallEvent { deleteData: boolean; } // Hook handler types - all receive (event, ctx) with unified context export type ContentBeforeSaveHandler = ( event: ContentHookEvent, ctx: PluginContext, ) => Promise | void>; export type ContentAfterSaveHandler = ( event: ContentHookEvent, ctx: PluginContext, ) => Promise; export type ContentBeforeDeleteHandler = ( event: ContentDeleteEvent, ctx: PluginContext, ) => Promise; export type ContentAfterDeleteHandler = ( event: ContentDeleteEvent, ctx: PluginContext, ) => Promise; export type ContentAfterPublishHandler = ( event: ContentPublishStateChangeEvent, ctx: PluginContext, ) => Promise; export type ContentAfterUnpublishHandler = ( event: ContentPublishStateChangeEvent, ctx: PluginContext, ) => Promise; export type ContentAfterRestoreHandler = ( event: ContentRestoreStateChangeEvent, ctx: PluginContext, ) => Promise; export type ContentAfterScheduleHandler = ( event: ContentScheduleStateChangeEvent, ctx: PluginContext, ) => Promise; export type ContentAfterUnscheduleHandler = ( event: ContentScheduleStateChangeEvent, ctx: PluginContext, ) => Promise; export type MediaBeforeUploadHandler = ( event: MediaUploadEvent, ctx: PluginContext, ) => Promise<{ name: string; type: string; size: number } | void>; export type MediaAfterUploadHandler = ( event: MediaAfterUploadEvent, ctx: PluginContext, ) => Promise; export type LifecycleHandler = (event: LifecycleEvent, ctx: PluginContext) => Promise; export type UninstallHandler = (event: UninstallEvent, ctx: PluginContext) => Promise; // ============================================================================= // Public Page Contribution Types // ============================================================================= /** Placement targets for page fragment contributions */ export type PagePlacement = "head" | "body:start" | "body:end"; /** * A single breadcrumb trail item. Used by `PublicPageContext.breadcrumbs` * so themes can publish breadcrumb trails that SEO plugins consume. */ export interface BreadcrumbItem { /** Display name for this crumb (e.g. "Home", "Blog", "My Post"). */ name: string; /** Absolute or root-relative URL for this crumb. */ url: string; } /** * Describes the page being rendered. Passed to page hooks so plugins * can decide what to contribute without fetching content themselves. */ export interface PublicPageContext { url: string; path: string; locale: string | null; kind: "content" | "custom"; pageType: string; /** Full document title for the rendered page */ title: string | null; /** Page-only title for OG/Twitter/JSON-LD headline output */ pageTitle?: string | null; description: string | null; canonical: string | null; image: string | null; content?: { collection: string; id: string; slug: string | null; }; /** SEO meta for base metadata generation in EmDashHead */ seo?: { ogTitle?: string | null; ogDescription?: string | null; ogImage?: string | null; robots?: string | null; }; /** Article metadata for Open Graph article: tags */ articleMeta?: { publishedTime?: string | null; modifiedTime?: string | null; author?: string | null; }; /** Site name for structured data and og:site_name */ siteName?: string; /** * Optional breadcrumb trail for this page, root first. When set, * SEO plugins should use this verbatim rather than deriving a trail * from `path`. Themes typically populate this at the point they * build the context (e.g. from a content hierarchy walk, taxonomy * lookup, or per-`pageType` routing logic). * * Semantics for consumers: * - `undefined` — theme has no opinion; consumer falls back to * its own derivation. * - `[]` — this page has no breadcrumbs (e.g. homepage); consumer * should skip `BreadcrumbList` emission entirely. * - Non-empty array — used verbatim for `BreadcrumbList` output. */ breadcrumbs?: BreadcrumbItem[]; /** Public-facing site URL (origin) for structured data */ siteUrl?: string; } // ── page:metadata ─────────────────────────────────────────────── export interface PageMetadataEvent { page: PublicPageContext; } /** * Allowed rel values for link contributions. * This is a security-critical allowlist -- sandboxed plugins can only inject * link tags with these rel values. Adding "stylesheet", "prefetch", "prerender" * etc. would allow sandboxed plugins to inject external resources. */ export type PageMetadataLinkRel = | "canonical" | "alternate" | "author" | "license" | "nlweb" | "site.standard.document"; export type PageMetadataContribution = | { kind: "meta"; name: string; content: string; key?: string } | { kind: "property"; property: string; content: string; key?: string } | { kind: "link"; rel: PageMetadataLinkRel; href: string; hreflang?: string; key?: string } | { kind: "jsonld"; id?: string; graph: Record | Array>; }; export type PageMetadataHandler = ( event: PageMetadataEvent, ctx: PluginContext, ) => | PageMetadataContribution | PageMetadataContribution[] | null | Promise; // ── page:fragments (trusted-only) ────────────────────────────── export interface PageFragmentEvent { page: PublicPageContext; } export type PageFragmentContribution = | { kind: "external-script"; placement: PagePlacement; src: string; async?: boolean; defer?: boolean; attributes?: Record; key?: string; } | { kind: "inline-script"; placement: PagePlacement; code: string; attributes?: Record; key?: string; } | { kind: "html"; placement: PagePlacement; html: string; key?: string; }; export type PageFragmentHandler = ( event: PageFragmentEvent, ctx: PluginContext, ) => | PageFragmentContribution | PageFragmentContribution[] | null | Promise; /** * Plugin hooks definition */ export interface PluginHooks { // Lifecycle hooks "plugin:install"?: HookConfig | LifecycleHandler; "plugin:activate"?: HookConfig | LifecycleHandler; "plugin:deactivate"?: HookConfig | LifecycleHandler; "plugin:uninstall"?: HookConfig | UninstallHandler; // Content hooks "content:beforeSave"?: HookConfig | ContentBeforeSaveHandler; "content:afterSave"?: HookConfig | ContentAfterSaveHandler; "content:beforeDelete"?: HookConfig | ContentBeforeDeleteHandler; "content:afterDelete"?: HookConfig | ContentAfterDeleteHandler; "content:afterPublish"?: HookConfig | ContentAfterPublishHandler; "content:afterUnpublish"?: | HookConfig | ContentAfterUnpublishHandler; "content:afterRestore"?: HookConfig | ContentAfterRestoreHandler; "content:afterSchedule"?: HookConfig | ContentAfterScheduleHandler; "content:afterUnschedule"?: | HookConfig | ContentAfterUnscheduleHandler; // Media hooks "media:beforeUpload"?: HookConfig | MediaBeforeUploadHandler; "media:afterUpload"?: HookConfig | MediaAfterUploadHandler; // Cron hook cron?: HookConfig | CronHandler; // Email hooks "email:beforeSend"?: HookConfig | EmailBeforeSendHandler; "email:deliver"?: HookConfig | EmailDeliverHandler; "email:afterSend"?: HookConfig | EmailAfterSendHandler; // Comment hooks "comment:beforeCreate"?: HookConfig | CommentBeforeCreateHandler; "comment:moderate"?: HookConfig | CommentModerateHandler; "comment:afterCreate"?: HookConfig | CommentAfterCreateHandler; "comment:afterModerate"?: HookConfig | CommentAfterModerateHandler; // Public page hooks "page:metadata"?: HookConfig | PageMetadataHandler; "page:fragments"?: HookConfig | PageFragmentHandler; } /** * Hook names */ /** * Hook name in a manifest. Core's exhaustive union of recognised hook names, * derived from the `PluginHooks` registry. The serialised manifest carries * these as opaque strings; this stricter type is only used for type-checking * inside core. `ManifestHookEntry` is re-exported from * `@premium-cms/plugin-types` near the top of this file. */ export type HookName = keyof PluginHooks; /** * Resolved hook with normalized config */ export interface ResolvedHook { priority: number; timeout: number; dependencies: string[]; errorPolicy: "continue" | "abort"; /** Whether this hook is exclusive (provider pattern) */ exclusive: boolean; handler: THandler; pluginId: string; } // ============================================================================= // Request Metadata Types // ============================================================================= /** * Geographic location information derived from the request. * Available when running on Cloudflare Workers (via the `cf` object). */ export interface GeoInfo { country: string | null; region: string | null; city: string | null; } /** * Normalized request metadata available to plugin route handlers. * Extracted from request headers and platform-specific properties. */ export interface RequestMeta { ip: string | null; userAgent: string | null; referer: string | null; geo: GeoInfo | null; } // ============================================================================= // Route Types // ============================================================================= /** * Route handler context extends plugin context with request-specific data */ export interface RouteContext extends PluginContext { /** Validated input from request body */ input: TInput; /** Original request */ request: Request; /** Normalized request metadata (IP, user agent, geo) */ requestMeta: RequestMeta; /** * Authenticated caller, if the route is private. The host has already * authenticated and authorized this user before dispatch, so the value * is trustworthy — unlike a user id read from the request body. * * `undefined` for public routes (which skip auth entirely) and for * token-authed requests where no user is bound to the token. * * Not gated by the `users:read` capability: this is the caller's own * identity for the current request, not a user directory lookup. */ user?: UserInfo; } /** * Route definition */ export interface PluginRoute { /** Zod schema for input validation */ input?: z.ZodType; /** * Mark this route as publicly accessible (no authentication required). * Public routes skip session/token auth and CSRF checks. */ public?: boolean; /** RBAC permission required to invoke the route. Legacy routes default to plugins:manage. */ permission?: Permission; /** * `Cache-Control` header value for successful GET responses, e.g. * `"public, max-age=60, stale-while-revalidate=300"`. Only honored on * routes that are also `public: true` — authenticated responses always * keep the default `private, no-store`. Errors are never cached. */ cacheControl?: string; /** Route handler */ handler: (ctx: RouteContext) => Promise; } export interface PluginMcpToolDefinition { description: string; route: string; input: z.ZodType; output?: z.ZodType; destructive?: boolean; } export interface PluginMcpConfig { tools: Record; } // ============================================================================= // Plugin Definition // ============================================================================= /** * Admin page definition */ export interface PluginAdminPage { path: string; label: string; icon?: string; } /** * Dashboard widget definition */ export interface PluginDashboardWidget { id: string; size?: "full" | "half" | "third"; title?: string; } /** * Settings field types (for admin UI generation) */ export type SettingFieldType = | "string" | "number" | "boolean" | "select" | "secret" | "url" | "email"; export interface BaseSettingField { type: SettingFieldType; label: string; description?: string; } export interface StringSettingField extends BaseSettingField { type: "string"; default?: string; multiline?: boolean; } export interface NumberSettingField extends BaseSettingField { type: "number"; default?: number; min?: number; max?: number; } export interface BooleanSettingField extends BaseSettingField { type: "boolean"; default?: boolean; } export interface SelectSettingField extends BaseSettingField { type: "select"; options: Array<{ value: string; label: string }>; default?: string; } export interface SecretSettingField extends BaseSettingField { type: "secret"; } export interface UrlSettingField extends BaseSettingField { type: "url"; default?: string; placeholder?: string; } export interface EmailSettingField extends BaseSettingField { type: "email"; default?: string; placeholder?: string; } export type SettingField = | StringSettingField | NumberSettingField | BooleanSettingField | SelectSettingField | SecretSettingField | UrlSettingField | EmailSettingField; /** * Block Kit element for block editing fields. * This is the `Element` discriminated union from `@premium-cms/blocks`. * Plugin authors should use `@premium-cms/blocks` builder functions to create these. */ export type PortableTextBlockField = Element; /** * Configuration for a Portable Text block type contributed by a plugin */ export interface PortableTextBlockConfig { /** Block type name (must match the `_type` in Portable Text) */ type: string; /** Human-readable label shown in slash commands and modals */ label: string; /** Icon key (e.g., "video", "code", "link", "link-external") */ icon?: string; /** Description shown in slash command menu */ description?: string; /** Placeholder text for the URL input */ placeholder?: string; /** Block Kit form fields for the editing UI. If declared, replaces the simple URL input. */ fields?: PortableTextBlockField[]; /** * Optional. Display category in the slash menu. Defaults to "Embeds". * * Plugin authors should pick a meaningful category that reflects what the * block actually is — e.g. "Sections", "Marketing", "Media", "Embeds", * "Layout". Blocks with the same category are grouped together in the * editor's slash menu. */ category?: string; } /** * Configuration for a field widget type contributed by a plugin. * A field widget provides a custom editing UI for a schema field. * The field references the widget via `widget: "pluginId:widgetName"`. */ export interface FieldWidgetConfig { /** Widget name (without plugin ID prefix) */ name: string; /** Human-readable label for the admin UI */ label: string; /** Which field types this widget can edit (e.g., ["json", "string"]) */ fieldTypes: FieldType[]; /** Block Kit elements for sandboxed rendering. Omit for trusted plugins using React. */ elements?: Element[]; } /** * Admin configuration */ export interface PluginAdminConfig { /** Module specifier for admin UI exports (e.g., "@premium-cms/plugin-audit-log/admin") */ entry?: string; /** Settings schema for auto-generated UI */ settingsSchema?: Record; /** Admin pages */ pages?: PluginAdminPage[]; /** Dashboard widgets */ widgets?: PluginDashboardWidget[]; /** Portable Text block types this plugin provides */ portableTextBlocks?: PortableTextBlockConfig[]; /** Field widget types this plugin provides */ fieldWidgets?: FieldWidgetConfig[]; } /** * Plugin definition - input to definePlugin() */ export interface PluginDefinition { /** Unique plugin identifier */ id: string; /** Plugin version (semver) */ version: string; /** Declared capabilities */ capabilities?: PluginCapability[]; /** Allowed hosts for network:fetch (wildcards supported: *.example.com) */ allowedHosts?: string[]; /** Storage collections with indexes */ storage?: TStorage; /** Hooks */ hooks?: PluginHooks; /** API routes */ routes?: Record; /** Routes explicitly exposed as agent-callable MCP tools. */ mcp?: PluginMcpConfig; /** Admin UI configuration */ admin?: PluginAdminConfig; } /** * Resolved plugin - after definePlugin() processing */ export interface ResolvedPlugin { id: string; version: string; capabilities: PluginCapability[]; allowedHosts: string[]; storage: TStorage; hooks: ResolvedPluginHooks; routes: Record; mcp?: PluginMcpConfig; admin: PluginAdminConfig; } /** * Resolved hooks with normalized config */ export interface ResolvedPluginHooks { "plugin:install"?: ResolvedHook; "plugin:activate"?: ResolvedHook; "plugin:deactivate"?: ResolvedHook; "plugin:uninstall"?: ResolvedHook; "content:beforeSave"?: ResolvedHook; "content:afterSave"?: ResolvedHook; "content:beforeDelete"?: ResolvedHook; "content:afterDelete"?: ResolvedHook; "content:afterPublish"?: ResolvedHook; "content:afterUnpublish"?: ResolvedHook; "content:afterRestore"?: ResolvedHook; "content:afterSchedule"?: ResolvedHook; "content:afterUnschedule"?: ResolvedHook; "media:beforeUpload"?: ResolvedHook; "media:afterUpload"?: ResolvedHook; cron?: ResolvedHook; "email:beforeSend"?: ResolvedHook; "email:deliver"?: ResolvedHook; "email:afterSend"?: ResolvedHook; "comment:beforeCreate"?: ResolvedHook; "comment:moderate"?: ResolvedHook; "comment:afterCreate"?: ResolvedHook; "comment:afterModerate"?: ResolvedHook; "page:metadata"?: ResolvedHook; "page:fragments"?: ResolvedHook; } // ============================================================================= // Plugin Admin Exports // ============================================================================= /** * What a plugin exports from its /admin entrypoint * Uses generic component type to avoid React dependency */ export interface PluginAdminExports { widgets?: Record; pages?: Record; fields?: Record; } // ============================================================================= // Sandbox Types // ============================================================================= /** * Plugin manifest — the metadata portion of a plugin bundle, used for * sandboxed plugins loaded from the marketplace. * * This interface is core's stricter version of the manifest contract: it * uses the exhaustive `HookName` union and core's typed `PluginAdminConfig`. * The wire-shape lives in `@premium-cms/plugin-types` as `PluginManifest` * with looser types (so the registry CLI can serialise hook names it * doesn't know about). Both must stay structurally compatible: every value * of this type must be assignable to the shared one. The static assertion * below catches any drift at compile time. */ export interface PluginManifest { id: string; version: string; /** * The trust contract (see `@premium-cms/plugin-types`). Authoritative; * `capabilities`/`allowedHosts` are derived from it at the parse boundary * via `reconcileManifestAccess`. Optional during the wire-format migration. */ declaredAccess?: DeclaredAccess; capabilities: PluginCapability[]; allowedHosts: string[]; storage: PluginStorageConfig; /** Hook declarations — either plain name strings or structured objects */ hooks: Array; /** Route declarations — either plain name strings or structured objects */ routes: Array; mcp?: PluginMcpManifestConfig; admin: PluginAdminConfig; } // Type-level guard: core's `PluginManifest` is intentionally a SUBTYPE of // the shared wire shape (`@premium-cms/plugin-types` `PluginManifest`). The // wire shape uses looser types like `string` for hook names so the registry // CLI can serialise plugins targeting hook versions this core doesn't yet // know about. Core narrows `string` to `HookName` and `Record` to `PluginAdminConfig` because core's loader actually executes // against those types. // // We assert one direction at compile time: `core extends shared`. The // reverse direction (`shared extends core`) intentionally does NOT hold // because shared is wider -- a manifest written against the wire shape // could carry a hook name core doesn't know. That runtime narrowing is the // job of `manifest-schema.ts` (zod-validated, called at every JSON.parse // of a manifest.json), not of the type system. The static check below // catches the OTHER failure mode: core adding a required field or // non-assignable type that the wire shape doesn't allow. // // `type X = never` is itself legal as a type alias, so the assertion has to // be in a value position (`const _check: T = true`) for the compiler to // error when T resolves to `never`. Don't replace this with a bare type // alias. type _AssertManifestCompat = PluginManifest extends import("@premium-cms/plugin-types").PluginManifest ? true : never; // eslint-disable-next-line @typescript-eslint/no-unused-vars const _MANIFEST_COMPAT: _AssertManifestCompat = true;