import { r as ContentFieldFilters } from "./content-list-query-BhejKVqb.mjs"; import { _ as UpdateCollectionInput, a as CollectionWithFields, c as CreateFieldInput, d as Field, p as FieldValidation, s as CreateCollectionInput, t as Collection, v as UpdateFieldInput } from "./types-BjBDp25t.mjs"; import { f as UpdateContentInput, i as ContentItem, l as FindManyOptions, o as ContentSeoInput, r as ContentDateField, s as CreateContentInput, t as BylineSummary, u as FindManyResult } from "./types-C4ZXxSAE.mjs"; import { d as ContentListResponse, f as ContentResponse, h as ManifestResponse, u as ApiResult } from "./options-B5K8QEV1.mjs"; import { t as Database } from "./types-BCOU_CXE.mjs"; import { At as PortableTextBlockConfig, Dt as PluginMcpManifestConfig, Et as PluginManifest, Ht as StorageCollection, I as CronAccess, It as ResolvedPlugin, J as EmailMessage, L as CronEvent, Mt as PublicPageContext, Nt as QueryOptions, Pt as RequestMeta, Q as HttpAccess, Qt as WhereClause, St as PluginCapability, Vt as SettingField, Y as FieldWidgetConfig, Zt as UserInfo, dt as PageFragmentEvent, f as CommentAfterModerateEvent, j as ContentItem$1, kt as PluginStorageConfig, m as CommentBeforeCreateEvent, mt as PageMetadataEvent, pt as PageMetadataContribution, st as MediaItem$1, u as CommentAfterCreateEvent, ut as PageFragmentContribution, vt as PaginatedResult, wt as PluginDefinition, z as CronTaskInfo } from "./types-XJPFqvc1.mjs"; import { _ as MediaValue, f as MediaProvider, m as MediaProviderDescriptor, p as MediaProviderCapabilities } from "./placeholder-CZAsZ2bK.mjs"; import { T as ContentBylineInput } from "./index-BFUET8yL.mjs"; import { r as DatabaseDescriptor } from "./adapters-D8N6Sa1v.mjs"; import { r as AuthProviderDescriptor, t as AuthDescriptor } from "./types-DmfO8RJk.mjs"; import { r as ObjectCacheDescriptor } from "./types-D5VVLr7J.mjs"; import { d as Storage } from "./types-D_DWe7k9.mjs"; import { i as SiteSettings, r as SiteSettingKey } from "./types-Blb-MRC3.mjs"; import { EmDashManifest } from "./astro/types.mjs"; import { Dialect, Kysely, sql } from "kysely"; import { z } from "astro/zod"; import { z as z$1 } from "zod"; import { ManifestHookEntry, ManifestRouteEntry } from "@premium-cms/plugin-types"; import "@premium-cms/registry-client/env"; import { LiveLoader } from "astro/loaders"; //#region src/database/errors.d.ts /** * Database error types. Kept in their own module (no driver imports) so the * public package barrel can re-export them without dragging native database * drivers into the module graph of consumers that picked a different dialect. */ declare class EmDashDatabaseError extends Error { cause?: unknown | undefined; constructor(message: string, cause?: unknown | undefined); } //#endregion //#region src/database/connection.d.ts interface DatabaseConfig { url: string; authToken?: string; } //#endregion //#region src/database/repositories/content.d.ts /** * Repository for content CRUD operations * * Content is stored in per-collection tables (ec_posts, ec_pages, etc.) * Each field becomes a real column in the table. */ declare class ContentRepository { private db; constructor(db: Kysely); /** * Create a new content item */ create(input: CreateContentInput): Promise; /** * Generate a unique slug for a content item within a collection. * * Checks the collection table for existing slugs that match `baseSlug` * (optionally scoped to a locale) and appends a numeric suffix (`-1`, * `-2`, etc.) on collision to guarantee uniqueness. * * Returns null when slug normalization cannot produce a value. */ generateUniqueSlug(type: string, text: string, locale?: string): Promise; /** * Duplicate a content item * Creates a new draft copy with "(Copy)" appended to the title. * A slug is auto-generated from the new title by the handler layer. */ duplicate(type: string, id: string, authorId?: string): Promise; /** * Find content by ID */ findById(type: string, id: string): Promise; /** * Find content by id, including trashed (soft-deleted) items. * Used by restore endpoint for ownership checks. */ findByIdIncludingTrashed(type: string, id: string): Promise; /** * Find content by ID or slug. Tries ID first if it looks like a ULID, * otherwise tries slug. Falls back to the other if the first lookup misses. */ findByIdOrSlug(type: string, identifier: string, locale?: string): Promise; /** * Find content by ID or slug, including trashed (soft-deleted) items. * Used by restore/permanent-delete endpoints. */ findByIdOrSlugIncludingTrashed(type: string, identifier: string, locale?: string): Promise; private _findByIdOrSlug; /** * Find content by slug */ findBySlug(type: string, slug: string, locale?: string): Promise; /** * Find content by slug, including trashed (soft-deleted) items. * Used by restore/permanent-delete endpoints. */ findBySlugIncludingTrashed(type: string, slug: string, locale?: string): Promise; /** * Find many content items with filtering and pagination */ findMany(type: string, options?: FindManyOptions): Promise>; /** * Update content */ update(type: string, id: string, input: UpdateContentInput): Promise; /** * Update plugin-authored fields without letting content columns diverge * from the revision pointers that publication promotes. */ updateDraftAware(type: string, id: string, input: UpdateContentInput): Promise; private deleteUnstagedRevision; private replaceDraftRevisionForUpdate; /** * Delete content (soft delete - moves to trash) */ delete(type: string, id: string): Promise; /** * Restore content from trash */ restore(type: string, id: string): Promise; /** * Permanently delete content (cannot be undone) */ /** * Permanently delete a soft-deleted content row. * * Returns `true` only when a soft-deleted (trashed) row was removed. * Returns `false` when no row exists OR when the row exists but is live — * the caller is responsible for distinguishing these cases (typically via * a follow-up `findByIdOrSlugIncludingTrashed` to surface NOT_FOUND vs * NOT_TRASHED). The `AND deleted_at IS NOT NULL` clause is the safety net * that prevents permanent delete from bypassing the trash workflow. */ permanentDelete(type: string, id: string): Promise; /** * Find trashed content items */ findTrashed(type: string, options?: Omit): Promise>; /** * Count trashed content items */ countTrashed(type: string): Promise; /** * Apply the optional `q` filter. * * When the handler sets `useFts` (collection has a healthy FTS5 index * covering the display columns; SQLite only), the filter is served from * the index: a token-prefix MATCH against `_emdash_fts_` OR'd with * an index-served `slug GLOB 'term*'` prefix (the slug is not in the FTS * index). Both sides are index-backed, so SQLite's OR optimization avoids * the full-table scan the LIKE fallback needs (#1517). The trade-off is * search semantics: token-prefix matching instead of arbitrary substring. * * Fallback (Postgres, search disabled, or no usable terms): case- * insensitive substring LIKE across the handler-resolved `searchColumns` * (OR'd). User input is treated literally (LIKE wildcards escaped) and * `lower()` is applied on both sides for SQLite/Postgres parity. */ private applySearchFilter; /** * Apply the optional inclusive date-range filter. The field is mapped * through `DATE_FILTER_COLUMNS` (a closed whitelist), and bounds compare * lexicographically against the stored ISO 8601 timestamps. A `publishedAt` * range naturally excludes never-published rows (their column is NULL). */ private applyDateFilter; /** * Apply the optional byline filter as a correlated (NOT) EXISTS against * `_emdash_content_bylines`. * * Correlating from the content table preserves the outer sort index so * `LIMIT` can short-circuit. `mode: "none"` tests the junction rather than * `primary_byline_id` because the two are written in the same call but * are not atomically consistent, so the junction is authoritative. * * Whether a credit *renders* is locale-scoped; whether one *exists* is * not. Both are needed: the first decides what the filter matches, the * second decides whether the author fallback applies at all. */ private applyBylineFilter; /** * Count content items */ count(type: string, where?: FindManyOptions["where"]): Promise; private countWithResolvedFilters; /** * Distinct, non-null `author_id` values across the collection's live * (non-trashed) content. Used to populate the admin author filter with * only the users who have actually authored entries, rather than the * full user directory (which requires admin privileges to read). */ findDistinctAuthorIds(type: string): Promise; getStats(type: string, now?: Date): Promise<{ total: number; published: number; draft: number; scheduled: number; overdueScheduled: number; }>; /** * Schedule content for future publishing * * Sets status to 'scheduled' and stores the scheduled publish time. * The content will be auto-published when the scheduled time is reached. */ schedule(type: string, id: string, scheduledAt: string): Promise; /** * Unschedule content * * Clears the scheduled time. Published posts stay published; * draft/scheduled posts revert to 'draft'. */ unschedule(type: string, id: string): Promise; /** * Find content that is ready to be published * * Returns all content where scheduled_at <= now, regardless of status. * This covers both draft-scheduled posts (status='scheduled') and * published posts with scheduled draft changes (status='published'). * * `limit` (optional) caps how many due rows are returned, oldest-due first. * The scheduled-publishing sweep passes a limit so a large backlog can't * fan out unbounded publish/webhook work in a single tick (and blow a Worker * invocation's CPU/subrequest budget); the remainder drains on later ticks. */ findReadyToPublish(type: string, limit?: number): Promise; /** * Find all translations in a translation group */ findTranslations(type: string, translationGroup: string): Promise; /** * Batch variant of {@link findTranslations}: every (non-deleted) locale * variant for any of `translationGroups`, in one `WHERE translation_group IN * (...)` query chunked at `SQL_BATCH_SIZE` for D1's bind-parameter limit. * Lets callers resolve many edge groups without an N+1 per group. The caller * groups the flat result by `translationGroup` itself. * * `translation_group` leads the sort so the ordering follows * `idx_{table}_del_tg_locale` past its `deleted_at` equality; callers group by * `translationGroup`, so the per-group locale order they rely on is preserved. * * `publishedOnly` restricts the result to `status = 'published'` — reference * reads pass this for callers without `content:read_drafts` so draft/scheduled * entries never leak through an edge traversal. * * A reference edge stores only a collection slug (no SQL FK), so the table may * have been dropped since the edge was written. That is a tolerated dangling * state, not an error: a missing table resolves to no rows, mirroring how the * content read handlers treat `isMissingTableError`. */ findTranslationsForGroups(type: string, translationGroups: string[], options?: { publishedOnly?: boolean; }): Promise; /** * Batch variant of {@link findByIdOrSlug}: resolve many identifiers (each an * id OR a slug) within `type` in a constant number of queries — one `WHERE id * IN (...)` and one `WHERE slug IN (...)`, each chunked at `SQL_BATCH_SIZE`. * Returns a map from the input identifier to its resolved item; identifiers * that match nothing are absent. Used on write paths that accept a list of * references, so a single request doesn't fan out to an N+1 of point lookups. * * Resolution mirrors {@link findByIdOrSlug}: a ULID-shaped identifier prefers * the id match and falls back to slug; anything else prefers the slug match * and falls back to id. Slug matches collapse to the lowest-locale variant * (`ORDER BY locale ASC`), matching the slug-without-locale lookup. */ findManyByIdOrSlug(type: string, identifiers: string[]): Promise>; /** * Publish the current draft * * Promotes draft_revision_id to live_revision_id and clears draft pointer. * Syncs the draft revision's data into the content table columns so the * content table always reflects the published version. * If no draft revision exists, creates one from current data and publishes it. * When `promoteRevision` is false, publishes the current content-table data * by changing lifecycle metadata only. * * `publishedAt` (optional) overrides the publication timestamp. If omitted, * the existing `published_at` is preserved (idempotent re-publish keeps the * original date) and falls back to the current time on first publish. Pass * an explicit value to backdate a publish (e.g. when migrating content from * another CMS). * * `requireDue` gates the final update on the row still being due. * `expectedScheduledAt` additionally fences changes made after a sweep * selected the row but before publication preparation began. */ publish(type: string, id: string, publishedAt?: string, requireDue?: boolean, expectedScheduledAt?: string, promoteRevision?: boolean, requireSlug?: boolean): Promise; /** * Unpublish content * * Removes live pointer but preserves draft. If no draft exists, * creates one from the live version so the content isn't lost. */ unpublish(type: string, id: string): Promise; /** * Set the draft revision pointer for a content item. * * Used by seed/import paths that stage a new revision's data before * promoting it to live via `publish()`. * * Validates that the content item exists and is not soft-deleted, that * the revision exists, and that the revision belongs to the same * collection and entry. Without these checks, a caller could leave the * content row pointing at a missing or unrelated revision. */ setDraftRevision(type: string, id: string, revisionId: string): Promise; replaceDraftRevision(type: string, id: string, revisionId: string, expected: Pick): Promise; /** * Discard pending draft changes * * Clears draft_revision_id. The content table columns already hold the * published version, so no data sync is needed. */ discardDraft(type: string, id: string): Promise; /** * Count content items with a pending schedule. * Includes both draft-scheduled (status='scheduled') and published * posts with scheduled draft changes (status='published', scheduled_at set). */ countScheduled(type: string): Promise; /** * Map database row to ContentItem * Extracts system columns and puts content fields in data * Excludes null values from data to match input semantics */ private mapRow; private normalizeFilterScalar; private normalizeFieldFilter; private collectionExists; private resolveFieldFilters; private applyFieldFilters; /** * Map order field names to database columns. * Only allows known fields to prevent column enumeration via crafted orderBy values. */ private mapOrderField; private resolveOrderField; } //#endregion //#region src/database/repositories/media.d.ts type MediaStatus = "pending" | "ready" | "failed"; interface MediaItem { id: string; filename: string; mimeType: string; size: number | null; width: number | null; height: number | null; alt: string | null; caption: string | null; storageKey: string; status: MediaStatus; contentHash: string | null; blurhash: string | null; dominantColor: string | null; createdAt: string; authorId: string | null; } interface CreateMediaInput { filename: string; mimeType: string; size?: number; width?: number; height?: number; alt?: string; caption?: string; storageKey: string; contentHash?: string; blurhash?: string; dominantColor?: string; status?: MediaStatus; authorId?: string; } interface FindManyMediaOptions { limit?: number; cursor?: string; /** Filter by MIME type. Pass a string for a single prefix/exact, or an array to match any. Strings ending with "/" are treated as LIKE prefix matches; others are exact equality. */ mimeType?: string | readonly string[]; status?: MediaStatus | "all"; /** Case-insensitive substring matched against the filename (covers filename and extension). */ q?: string; } /** * Media repository for database operations */ declare class MediaRepository { private db; constructor(db: Kysely); /** * Create a new media item */ create(input: CreateMediaInput): Promise; /** * Create a pending media item (for signed URL upload flow) */ createPending(input: { filename: string; mimeType: string; size?: number; storageKey: string; contentHash?: string; authorId?: string; }): Promise; createUploadAttempt(mediaId: string, storageKey: string): Promise; hasUploadAttempt(storageKey: string): Promise; claimUploadAttemptForCleanup(storageKey: string): Promise; deleteUploadAttempt(storageKey: string): Promise; deleteCompletedUploadAttempts(): Promise; findUploadAttemptsForCleanup(maxAgeMs?: number, limit?: number): Promise; publishPendingStorageKey(id: string, expectedStorageKey: string, storageKey: string, contentHash?: string): Promise; /** * Confirm upload (mark as ready) */ confirmUpload(id: string, metadata?: { width?: number; height?: number; size?: number; blurhash?: string; dominantColor?: string; contentHash?: string | null; }, expectedStorageKey?: string): Promise; /** * Mark upload as failed */ markFailed(id: string, expectedStorageKey?: string): Promise; /** * Find media by ID */ findById(id: string): Promise; /** * Find media by filename * Useful for idempotent imports */ findByFilename(filename: string): Promise; /** * Find media by content hash * Used for deduplication - same content = same hash */ findByContentHash(contentHash: string): Promise; /** * Find many media items with cursor pagination * * Uses keyset pagination (cursor-based) for consistent results. * The cursor encodes the created_at and id of the last item. */ findMany(options?: FindManyMediaOptions): Promise>; /** * Update media metadata */ update(id: string, input: Partial>): Promise; /** * Delete media item */ deleteWithStorageKey(id: string): Promise; delete(id: string): Promise; /** * Count media items */ count(mimeType?: string | readonly string[]): Promise; /** * Delete pending uploads older than the given age. * Pending uploads that were never confirmed indicate abandoned upload flows. * * Returns the storage keys of deleted rows so callers can remove the * corresponding files from object storage. */ cleanupPendingUploads(maxAgeMs?: number): Promise; /** * Convert database row to MediaItem */ private rowToItem; } //#endregion //#region src/database/repositories/user.d.ts /** * Valid role levels matching the database schema. * 10=subscriber, 20=contributor, 30=author, 40=editor, 50=admin */ type UserRole = 10 | 20 | 30 | 40 | 50; /** String role names for convenience APIs */ type UserRoleName = "subscriber" | "contributor" | "author" | "editor" | "admin"; interface User { id: string; email: string; name: string | null; role: UserRole; avatarUrl: string | null; emailVerified: boolean; data: Record | null; createdAt: string; } interface CreateUserInput { email: string; name?: string; role?: UserRole | UserRoleName; avatarUrl?: string; data?: Record; } interface UpdateUserInput { name?: string; role?: UserRole | UserRoleName; avatarUrl?: string | null; data?: Record; } /** * User repository for CRUD operations */ declare class UserRepository { private db; constructor(db: Kysely); /** * Create a new user */ create(input: CreateUserInput): Promise; /** * Find user by ID */ findById(id: string): Promise; /** * Batch-resolve users by ID. Returns only the users that exist; missing * IDs are silently dropped. Chunked at `SQL_BATCH_SIZE` to stay within * D1's bind-parameter limit. */ findByIds(ids: string[]): Promise; /** * Find user by email (case-insensitive) */ findByEmail(email: string): Promise; /** * List all users with cursor-based pagination */ findMany(options?: { role?: UserRole | UserRoleName; limit?: number; cursor?: string; }): Promise>; /** * Update a user */ update(id: string, input: UpdateUserInput): Promise; /** * Delete a user */ delete(id: string): Promise; /** * Count users */ count(role?: UserRole | UserRoleName): Promise; /** * Check if email exists */ emailExists(email: string): Promise; /** * Convert database row to User object */ private rowToUser; /** Map of role name strings to numeric levels */ private static readonly ROLE_NAME_TO_LEVEL; /** Valid numeric role levels */ private static readonly VALID_LEVELS; /** * Resolve a role name or number to a valid numeric UserRole. * Accepts both string names ("admin") and numeric levels (50). */ static resolveRole(role: UserRole | UserRoleName): UserRole; /** * Convert a raw DB integer to a typed UserRole. * Falls back to subscriber (10) for unknown values. */ private static toRole; } //#endregion //#region src/database/repositories/revision.d.ts interface Revision { id: string; collection: string; entryId: string; data: Record; authorId: string | null; createdAt: string; } //#endregion //#region src/database/repositories/comment.d.ts /** Public-facing comment shape — no private fields */ interface PublicComment { id: string; parentId: string | null; authorName: string; isRegisteredUser: boolean; body: string; createdAt: string; replies?: PublicComment[]; /** Aggregate reaction counts (`{ like: 12 }`), attached when requested. */ reactions?: Record; } //#endregion //#region src/database/repositories/plugin-storage.d.ts /** * Plugin Storage Repository * * Implements the StorageCollection interface for a specific plugin and collection. */ declare class PluginStorageRepository implements StorageCollection { private db; private pluginId; private collection; private indexedFields; constructor(db: Kysely, pluginId: string, collection: string, indexes: Array); /** * Get a document by ID */ get(id: string): Promise; /** * Store a document */ put(id: string, data: T): Promise; /** * Delete a document */ delete(id: string): Promise; /** * Check if a document exists */ exists(id: string): Promise; /** * Get multiple documents by ID */ getMany(ids: string[]): Promise>; /** * Store multiple documents */ putMany(items: Array<{ id: string; data: T; }>): Promise; /** * Delete multiple documents */ deleteMany(ids: string[]): Promise; /** * Query documents with filters */ query(options?: QueryOptions): Promise>; /** * Count documents matching a filter */ count(where?: WhereClause): Promise; } //#endregion //#region src/database/dialect-helpers.d.ts /** * Declared by an adapter whose backend caps the number of terms in a compound * SELECT (`UNION ALL`, `INTERSECT`, `EXCEPT`). SQLite's own * SQLITE_LIMIT_COMPOUND_SELECT default is 500 — high enough that no query * EmDash builds approaches it — but Cloudflare D1 sets it to 5 and rejects * anything larger with "too many terms in compound SELECT". */ interface CompoundSelectLimitedAdapter { /** Maximum terms per compound SELECT. Must be a positive integer. */ readonly compoundSelectLimit: number; } //#endregion //#region src/fields/types.d.ts /** * SQLite column types that map from field types */ type ColumnType = "TEXT" | "REAL" | "INTEGER" | "JSON"; /** * Base field definition * * Note: schema uses z.ZodTypeAny to accommodate optional/default wrappers */ interface FieldDefinition<_T = unknown> { type: string; /** * The SQLite column type to use when storing this field */ columnType: ColumnType; schema: z.ZodTypeAny; options?: unknown; ui?: FieldUIHints; validation?: FieldValidation; } /** * UI hints for admin rendering */ interface FieldUIHints { widget?: string; placeholder?: string; helpText?: string; rows?: number; min?: number | string; max?: number | string; [key: string]: unknown; } /** * Portable Text block structure */ interface PortableTextBlock$1 { _type: string; _key: string; [key: string]: unknown; } /** * @deprecated Use MediaValue instead. ImageValue is an alias for backwards compatibility. */ type ImageValue = MediaValue; /** * Persisted file field value. * * File values are references with cached metadata, not implicitly hydrated * media records. Use the media provider API when current metadata is needed. */ interface FileValue { id: string; /** Legacy cached URL. Provider-backed values commonly omit this. */ url?: string; /** Direct URL used by external media providers. */ src?: string; /** Cached original filename, when available. */ filename?: string; /** Cached MIME type, when available. */ mimeType?: string; /** Cached file size in bytes, when persisted with the value. */ size?: number; /** Media provider ID. Defaults to `local` when omitted. */ provider?: string; /** Provider-specific data needed to resolve or render the file. */ meta?: Record; } //#endregion //#region src/fields/image.d.ts interface ImageOptions { required?: boolean; maxSize?: number; allowedTypes?: string[]; } declare function image(options?: ImageOptions): FieldDefinition; //#endregion //#region src/fields/file.d.ts interface FileOptions { required?: boolean; maxSize?: number; allowedTypes?: string[]; helpText?: string; } declare function file(options?: FileOptions): FieldDefinition; //#endregion //#region src/fields/reference.d.ts /** * Reference field * References another content item by ID */ declare function reference(collection: string, options?: { required?: boolean; }): FieldDefinition; //#endregion //#region src/fields/portable-text.d.ts /** * Portable Text field * Stores structured content in Portable Text format */ declare function portableText(options?: { required?: boolean; }): FieldDefinition; //#endregion //#region src/api/handlers/content.d.ts /** * Trashed content item with deletion timestamp */ interface TrashedContentItem { id: string; type: string; slug: string | null; status: string; data: Record; authorId: string | null; createdAt: string; updatedAt: string; publishedAt: string | null; deletedAt: string; } /** * Create content list handler */ declare function handleContentList(db: Kysely, collection: string, params: { cursor?: string; limit?: number; status?: string; orderBy?: string; order?: "asc" | "desc"; locale?: string; q?: string; authorId?: string; dateField?: ContentDateField; dateFrom?: string; dateTo?: string; bylines?: string[]; bylinesNone?: boolean; includeInferredBylines?: boolean; fieldFilters?: ContentFieldFilters; }): Promise>; /** A content author option for the admin author filter. */ interface ContentAuthor { id: string; name: string | null; email: string; avatarUrl: string | null; } /** * List the distinct authors of a collection's live content. * * Backs the admin content-list author filter. Unlike `/admin/users` (ADMIN * only), this is gated on `content:read`, so any editor can filter by author. * Returns only users who have authored at least one non-trashed entry, sorted * by display name then email for a stable dropdown order. */ declare function handleContentAuthors(db: Kysely, collection: string): Promise>; /** * Get single content item */ declare function handleContentGet(db: Kysely, collection: string, id: string, locale?: string): Promise>; /** * Get a content item by id, including trashed items. * Used by restore endpoint for ownership checks on soft-deleted items. */ declare function handleContentGetIncludingTrashed(db: Kysely, collection: string, id: string, locale?: string): Promise>; /** * Create content item. * * Content + SEO writes are wrapped in a transaction so either both succeed * or neither does. If `body.seo` is provided for a non-SEO collection, the * API returns a validation error rather than silently dropping it. */ declare function handleContentCreate(db: Kysely, collection: string, body: { data: Record; slug?: string | null; status?: string; authorId?: string; bylines?: ContentBylineInput[]; locale?: string; translationOf?: string; seo?: ContentSeoInput; taxonomies?: Record; createdAt?: string | null; publishedAt?: string | null; }): Promise>; /** * Update content item. * If `_rev` is provided, validates it against the current version before writing. * No `_rev` = blind write (backwards-compatible for admin UI). * * Content + SEO writes are wrapped in a transaction for atomicity. */ declare function handleContentUpdate(db: Kysely, collection: string, id: string, body: { data?: Record; slug?: string | null; status?: string; authorId?: string | null; bylines?: ContentBylineInput[]; locale?: string; _rev?: string; seo?: ContentSeoInput; taxonomies?: Record; publishedAt?: string | null; }): Promise>; /** * Duplicate content item. * * Only copies SEO data if the collection has SEO enabled. * Always returns consistent `seo` shape for SEO-enabled collections. */ declare function handleContentDuplicate(db: Kysely, collection: string, id: string, authorId?: string): Promise>; /** * Delete content item (soft delete - moves to trash) */ declare function handleContentDelete(db: Kysely, collection: string, id: string): Promise>; /** * Restore content item from trash */ declare function handleContentRestore(db: Kysely, collection: string, id: string): Promise>; /** * Permanently delete content item (cannot be undone). * Also cleans up associated SEO data. */ declare function handleContentPermanentDelete(db: Kysely, collection: string, id: string): Promise>; /** * List trashed content items */ declare function handleContentListTrashed(db: Kysely, collection: string, options?: { limit?: number; cursor?: string; }): Promise>; /** * Count trashed content items */ declare function handleContentCountTrashed(db: Kysely, collection: string): Promise>; /** * Schedule content for future publishing */ declare function handleContentSchedule(db: Kysely, collection: string, id: string, scheduledAt: string): Promise>; /** * Unschedule content (revert to draft) */ declare function handleContentUnschedule(db: Kysely, collection: string, id: string): Promise>; /** * Publish content immediately. * * Publication is one atomic content-row statement. On databases that support * transactions, the existing slug-redirect side write remains grouped with it. */ declare function handleContentPublish(db: Kysely, collection: string, id: string, options?: { publishedAt?: string; requireScheduledDue?: boolean; expectedScheduledAt?: string; }): Promise>; /** * Unpublish content (revert to draft). * * Wrapped in a transaction — unpublish may create a draft revision * from the live version then update the status, which is multi-step. */ declare function handleContentUnpublish(db: Kysely, collection: string, id: string): Promise>; /** * Count scheduled content items */ declare function handleContentCountScheduled(db: Kysely, collection: string): Promise>; /** * Discard draft changes (revert to live version) */ declare function handleContentDiscardDraft(db: Kysely, collection: string, id: string): Promise>; /** * Compare live and draft revisions */ declare function handleContentCompare(db: Kysely, collection: string, id: string): Promise | null; draft: Record | null; }>>; /** * Get all translations for a content item. * Returns the item's translation group members with locale and status info. */ declare function handleContentTranslations(db: Kysely, collection: string, id: string): Promise; }>>; //#endregion //#region src/api/handlers/manifest.d.ts interface CollectionDefinition { schema: { _def?: { shape?: () => Record; }; shape?: Record; }; admin: { label: string; labelSingular?: string; supports?: string[]; routable?: boolean; }; } type CollectionMap = Record; /** * Generate admin manifest from collections */ declare function generateManifest(collections: CollectionMap, plugins?: Record; widgets?: string[]; }>): Promise; //#endregion //#region src/api/handlers/revision.d.ts interface RevisionListResponse { items: Revision[]; total: number; } interface RevisionResponse { item: Revision; } /** * List revisions for a content entry */ declare function handleRevisionList(db: Kysely, collection: string, entryId: string, params?: { limit?: number; }): Promise>; /** * Get a specific revision */ declare function handleRevisionGet(db: Kysely, revisionId: string): Promise>; /** * Restore a revision (updates content to this revision's data and creates new revision) */ declare function handleRevisionRestore(db: Kysely, revisionId: string, callerUserId: string): Promise>; //#endregion //#region src/api/handlers/media.d.ts interface MediaListResponse { items: MediaItem[]; nextCursor?: string; } interface MediaResponse { item: MediaItem; } /** * List media items */ declare function handleMediaList(db: Kysely, params: { cursor?: string; limit?: number; mimeType?: string | readonly string[]; q?: string; }): Promise>; /** * Get single media item */ declare function handleMediaGet(db: Kysely, id: string): Promise>; /** * Create media item (after file upload) */ declare function handleMediaCreate(db: Kysely, input: { filename: string; mimeType: string; size?: number; width?: number; height?: number; alt?: string; storageKey: string; contentHash?: string; blurhash?: string; dominantColor?: string; authorId?: string; }): Promise>; /** * Update media metadata */ declare function handleMediaUpdate(db: Kysely, id: string, input: { alt?: string; caption?: string; width?: number; height?: number; }): Promise>; /** * Delete media item */ declare function handleMediaDelete(db: Kysely, id: string): Promise>; //#endregion //#region src/media/usage/content-refresh.d.ts type ContentMediaUsageRefreshErrorCode = "CONTENT_NOT_FOUND" | "DRAFT_REVISION_NOT_FOUND" | "DRAFT_REVISION_MISMATCH" | "DRAFT_REVISION_INVALID" | "CONTENT_USAGE_REFRESH_ERROR" | "CONTENT_USAGE_DELETE_ERROR" | "CONTENT_USAGE_GENERATION_CONFLICT" | "CONTENT_USAGE_RESOURCE_LIMIT" | "CONTENT_USAGE_STALE"; declare function markContentMediaUsageCollectionStaleSafely(db: Kysely, collectionSlug: string, lastErrorCode: ContentMediaUsageRefreshErrorCode): Promise; //#endregion //#region src/schema/registry.d.ts /** * Error thrown when a schema operation fails */ declare class SchemaError extends Error { code: string; details?: Record | undefined; constructor(message: string, code: string, details?: Record | undefined); } /** * Schema Registry * * Manages collection and field definitions stored in D1. * Handles runtime DDL operations (CREATE TABLE, ALTER TABLE). */ declare class SchemaRegistry { private db; constructor(db: Kysely); /** * List all collections */ listCollections(): Promise; /** * Get a collection by slug */ getCollection(slug: string): Promise; /** * Get a collection with all its fields */ getCollectionWithFields(slug: string): Promise; /** * List every collection together with its fields in O(1) query shapes * — one for collections, then one batched query for the fields of every * returned collection — instead of the N+1 pattern of `listCollections` * + per-collection `listFields`. The fields query is chunked at * `SQL_BATCH_SIZE` to stay under D1's bound-parameter limit, so on * sites with more than `SQL_BATCH_SIZE` collections the field fetch * becomes `ceil(collectionCount / SQL_BATCH_SIZE)` queries — still * a constant factor, not N+1. Typical sites have well under * `SQL_BATCH_SIZE` collections, so this is two queries in practice. * * Used by the manifest build, which previously paid N+1 round-trips on * every admin request. Each round-trip costs ~80–150ms against the D1 * primary on a busy link, so a 10-collection site spent ~1 s rebuilding * a manifest that is now built fresh per admin request (no cache). */ listCollectionsWithFields(): Promise; /** * Validate `titleField`/`dateField` against the collection's fields: * `titleField` must be a text-like field; `dateField` must be a `datetime` field. * Only truthy values are checked (undefined = unchanged, null/"" = cleared). */ private validateTitleDateFields; /** * Create a new collection */ createCollection(input: CreateCollectionInput): Promise; /** * Create a seed-owned collection and all of its fields in bulk. * * Fresh seeds can define dozens of fields. Creating them through * `createField` performs multiple reads, one ALTER TABLE, and one media * usage invalidation per field, which can exhaust D1's per-request query * budget. This path validates the full schema before mutating it, creates * the complete content table in one statement, and inserts field metadata * in parameter-safe batches. */ createSeedCollection(input: Omit, fields: readonly CreateFieldInput[]): Promise; private assertSeedFieldDefinitions; /** * Update a collection */ updateCollection(slug: string, input: UpdateCollectionInput): Promise; /** * Delete a collection */ deleteCollection(slug: string, options?: { force?: boolean; }): Promise; /** * List fields for a collection */ listFields(collectionId: string): Promise; /** * Get a field by slug within a collection */ getField(collectionSlug: string, fieldSlug: string): Promise; /** * Create a new field */ createField(collectionSlug: string, input: CreateFieldInput): Promise; /** * Update a field */ updateField(collectionSlug: string, fieldSlug: string, input: UpdateFieldInput): Promise; /** * Synchronize an existing FTS index with the collection's current state. * * Only rebuilds or disables — never first-time enables. First-time FTS * enablement is handled by the seed's explicit enableSearch call (which * is try-caught) or the admin UI toggle. * * - FTS active + still has search support and searchable fields → rebuild * - FTS active + lost search support or no searchable fields → disable * - FTS not active → no-op * * Pass `db` when calling from within a transaction so FTS operations * participate in the same transaction and are rolled back on failure. */ private syncSearchState; /** * Delete a field */ deleteField(collectionSlug: string, fieldSlug: string): Promise; /** * Reorder collections in the admin sidebar. * * `slugs` is the full desired order: every listed collection gets its * index as `sort_order`, and any collection left out has its explicit * position cleared, dropping it back to the alphabetical tail. Unknown or * duplicate slugs throw before anything is written. */ reorderCollections(slugs: string[]): Promise; /** * Reorder fields */ reorderFields(collectionSlug: string, fieldSlugs: string[]): Promise; /** * Create a content table for a collection */ private createContentTable; private getFieldIndexName; private getLocaleFieldIndexName; private createFieldIndex; private dropFieldIndex; /** * Add a column to a content table */ private addColumn; /** * Drop a column from a content table */ private dropColumn; /** * Check if a collection has any content */ private collectionHasContent; /** * Get table name for a collection */ private getTableName; /** * Get column name for a field */ private getColumnName; /** * Validate a slug */ private validateSlug; /** * Format a default value for SQL. * * SQLite `ALTER TABLE ADD COLUMN ... DEFAULT` requires a literal constant * expression — parameterized values cannot be used here. We manually escape * single quotes and coerce types to ensure the output is safe. * * INTEGER/REAL values are coerced through `Number()` which can only produce * digits, `.`, `-`, `e`, `Infinity`, or `NaN` — all safe in SQL. * TEXT/JSON values have single quotes escaped via SQL standard doubling (`''`). */ private formatDefaultValue; /** * Get empty default for a field type */ private getEmptyDefault; /** * Map a collection row to a Collection object */ private mapCollectionRow; /** * Map a field row to a Field object */ private mapFieldRow; /** * Discover orphaned content tables * * Finds ec_* tables that exist in the database but don't have a * corresponding entry in _emdash_collections. */ discoverOrphanedTables(): Promise>; /** * Register an orphaned table as a collection * * Creates a _emdash_collections entry for an existing ec_* table. */ registerOrphanedTable(slug: string, options?: { label?: string; labelSingular?: string; description?: string; }): Promise; /** * Convert slug to human-readable label */ private slugToLabel; } //#endregion //#region src/schema/query.d.ts /** * Get collection metadata by slug. * * @example * ```ts * import { getCollectionInfo } from "@premium-cms/emdash"; * * const info = await getCollectionInfo("posts"); * if (info?.commentsEnabled) { * // render comment UI * } * ``` */ declare function getCollectionInfo(slug: string): Promise; //#endregion //#region src/database/migrations/policy.d.ts type RuntimeMigrationMode = "auto" | "check" | "manual"; interface RuntimeMigrationConfig { runtime: RuntimeMigrationMode; dev?: RuntimeMigrationMode; } //#endregion //#region src/registry/types.d.ts /** * Public types for the experimental plugin registry. * * Kept in their own module so they don't get re-bundled into the * `astro/integration/runtime.ts` chunk's dist output. tsdown / rolldown * are sensitive to which top-level types live alongside `definePlugin`'s * overloads, and pulling these types into the integration module * affected downstream `definePlugin()` overload resolution for trusted * plugins built against core's dist (see commit history for the * detailed write-up). */ /** * Experimental plugin registry configuration. * * See {@link ExperimentalConfig.registry}. */ interface RegistryConfig { /** * Base URL of the registry aggregator (an atproto AppView that indexes * the firehose for `pm.fair.package.*` and `com.emdashcms.*` records). * * Must be the origin where the aggregator's XRPC endpoints are mounted, * such that `${aggregatorUrl}/xrpc/` resolves to a valid endpoint. * * Must be HTTPS in production; `http://localhost` or `http://127.0.0.1` * are accepted in dev. */ aggregatorUrl: string; /** * Optional comma-separated list of bare labeller DIDs forwarded as the * `atproto-accept-labelers` header on every aggregator request. The * declaration contributes to the client's cache identity. * * The aggregator validates the declaration and rejects unknown sources or * a list that omits a required source. It does not let the declaration * override its configured approval, block, takedown, or withdrawal policy. */ acceptLabelers?: string; /** * Site-level policy applied to the latest-release selection filter. * * These filters operate over the signed records the aggregator returns; * they are not protocol-level constraints. See the RFC's * "Update Discovery and Takedowns" section for the integration point. */ policy?: { /** * Hold back releases newer than this when computing the recommended * install or update version. Mitigates "compromised publisher * account pushes a malicious release of an established plugin" by * giving the takedown labeller a detection window. * * Accepts a duration string (`"24h"`, `"48h"`, `"72h"`, `"7d"`) or a * number of seconds. * * Currently applies uniformly to all releases. A future addition * may exempt brand-new packages (those with no prior release * history) so the holdback doesn't block first-time publishing, * but that exemption is not implemented yet; use * {@link minimumReleaseAgeExclude} to allowlist trusted publishers * whose packages should install immediately. * * Defaults to `undefined` (no holdback). A future trust/moderation * RFC will specify the recommended default. */ minimumReleaseAge?: string | number; /** * Packages exempt from the {@link minimumReleaseAge} holdback. Use * for publishers whose release tempo you've explicitly accepted -- * your own first-party plugins, a trusted partner, etc. * * Each entry is either: * - A bare publisher DID (e.g. `"did:plc:abc123"`) -- every * package from that publisher is exempt. * - A `/` pair (e.g. * `"did:plc:abc123/hotfix-plugin"`) -- only that specific * package is exempt. * * Whole-publisher exemptions are the common case: trust is * naturally a property of the publisher, not of each individual * package. Per-package exemptions exist for cases where a publisher * has one plugin you want fast-track installs for and others you'd * rather hold back. * * Only DIDs are accepted -- not handles. Handles are mutable * aggregator-supplied envelope data, and accepting them as a * trust input would let a compromised aggregator bypass the * holdback by claiming any handle for any package. DIDs are * tied to the AT URI of the package record itself, so even a * compromised aggregator cannot lie about which DID published * a release. * * Mirrors pnpm's `minimumReleaseAgeExclude`. * * @example * ```ts * minimumReleaseAgeExclude: [ * "did:plc:emdashfirstparty", // every package from this publisher * "did:plc:abc123/hotfix-plugin", // just this one package * ] * ``` */ minimumReleaseAgeExclude?: readonly string[]; }; } /** * Shorthand: pass a bare aggregator URL string in place of a full * `RegistryConfig` object when you don't need `acceptLabelers` or * `policy`. The normalizer expands the string into * `{ aggregatorUrl: }` before any downstream code sees it. * * @example * ```ts * experimental: { * registry: "https://registry.emdashcms.com", * } * ``` * * Equivalent to: * ```ts * experimental: { * registry: { aggregatorUrl: "https://registry.emdashcms.com" }, * } * ``` */ type RegistryConfigInput = string | RegistryConfig; /** * Experimental EmDash features. See `EmDashConfig.experimental`. * * Each field is independently opt-in. Fields may be promoted out of * `experimental` (becoming top-level `EmDashConfig` options) or removed * in minor releases; check the changelog when upgrading. */ interface ExperimentalConfig { /** * Decentralized plugin registry. * * When set, replaces the centralized `marketplace` for the admin UI's * browse and install flows. The registry is an atproto-backed * federation: package metadata lives in each publisher's PDS and * an aggregator (the `aggregatorUrl`) indexes the firehose and * exposes read-only XRPC endpoints for discovery. * * See [RFC 0001](https://github.com/emdash-cms/emdash/pull/694) for * the protocol design. * * **Trust model (v1, experimental).** Today EmDash trusts the * configured aggregator with these claims, per package and per * release: * * - The publisher DID associated with a `(did, slug)` pair. * - The artifact `url`, the artifact `checksum`, and any mirror * URLs returned for a release. * - The published handle for a DID (used for display only; * EmDash separately verifies the DID->handle round-trip in the * admin UI before treating a handle as confirmed). * * What EmDash verifies independently before activating an * installed plugin: * * - The artifact bytes hash to the checksum the aggregator * returned (so a malicious mirror or in-transit tamper can't * swap the bundle). * - The bundle's `manifest.id` matches the requested slug, and * its `manifest.version` matches the release version (so an * attacker who controls the aggregator can't trick the * sandbox into addressing the wrong plugin id). * - The bundle's `manifest.capabilities` matches what the admin * acknowledged in the consent dialog (so a publisher can't * ship a bundle that requests more permissions than the * dialog displayed). * * What's NOT yet verified: * * - Full MST proof / publisher signature on the release record. * A compromised aggregator can forge a release for any DID * and slug, and the install will succeed as long as the * bundle matches the (forged) checksum. * - Per-release replay / rollback: the aggregator chooses which * release version is "latest". * * **Recommendation.** Until full signature verification lands, * point `aggregatorUrl` only at an aggregator you operate * yourself or one you trust with the same level of authority as * a centralized plugin source. `policy.minimumReleaseAge` widens * the detection window for takedowns. `acceptLabelers` declares a * request and cache identity; it does not change aggregator policy. * * Requires `sandboxRunner` to be configured -- registry plugins * always run sandboxed. * * Accepts a bare URL string as shorthand for * `{ aggregatorUrl: "..." }`. Use the full object form when you * need `acceptLabelers` or `policy`. */ registry?: RegistryConfigInput; } //#endregion //#region src/astro/storage/types.d.ts /** * Serializable storage configuration descriptor */ interface StorageDescriptor { /** Module path exporting createStorage function */ entrypoint: string; /** Serializable config passed to createStorage at runtime */ config: unknown; } /** * S3-compatible storage configuration */ interface S3StorageConfig { /** S3 endpoint URL */ endpoint: string; /** Bucket name */ bucket: string; /** * Access key ID. * May be resolved from the `S3_ACCESS_KEY_ID` env var at runtime on Node. * Must be provided together with `secretAccessKey`, or both omitted. */ accessKeyId?: string; /** * Secret access key. * May be resolved from the `S3_SECRET_ACCESS_KEY` env var at runtime on Node. * Must be provided together with `accessKeyId`, or both omitted. */ secretAccessKey?: string; /** Optional region (defaults to "auto") */ region?: string; /** Optional public URL prefix for CDN */ publicUrl?: string; } /** * Local filesystem storage configuration */ interface LocalStorageConfig { /** Directory path for storing files */ directory: string; /** Base URL for serving files */ baseUrl: string; } //#endregion //#region src/astro/integration/runtime.d.ts /** * Admin page definition (copied from plugins/types to avoid circular deps) */ interface PluginAdminPage { path: string; label: string; icon?: string; } /** * Dashboard widget definition (copied from plugins/types to avoid circular deps) */ interface PluginDashboardWidget { id: string; size?: "full" | "half" | "third"; title?: string; } /** * Plugin descriptor - returned by plugin factory functions * * Contains all static metadata needed for manifest and admin UI, * plus the entrypoint for runtime instantiation. * * @example * ```ts * export function myPlugin(options?: MyPluginOptions): PluginDescriptor { * return { * id: "my-plugin", * version: "1.0.0", * entrypoint: "@my-org/emdash-plugin-foo", * options: options ?? {}, * adminEntry: "@my-org/emdash-plugin-foo/admin", * adminPages: [{ path: "/settings", label: "Settings" }], * }; * } * ``` */ /** * Storage collection declaration for sandboxed plugins */ interface StorageCollectionDeclaration { indexes?: string[]; uniqueIndexes?: string[]; } interface PluginDescriptor> { /** Unique plugin identifier */ id: string; /** Plugin version (semver) */ version: string; /** Module specifier to import (e.g., "@premium-cms/plugin-api-test") */ entrypoint: string; /** * Options to pass to createPlugin(). Native format only. * Standard-format plugins configure themselves via KV settings * and Block Kit admin pages -- not constructor options. */ options?: TOptions; /** * Plugin format. Determines how the entrypoint is loaded: * - `"standard"` -- exports `definePlugin({ hooks, routes })` as default. * Wrapped with `adaptSandboxEntry` for in-process execution. Can run in both * `plugins: []` (in-process) and `sandboxed: []` (isolate). * - `"native"` -- exports `createPlugin(options)` returning a `ResolvedPlugin`. * Can only run in `plugins: []`. Cannot be sandboxed or published to marketplace. * * Defaults to `"native"` when unset. * */ format?: "standard" | "native"; /** Admin UI module specifier (e.g., "@premium-cms/plugin-audit-log/admin") */ adminEntry?: string; /** Module specifier for site-side Astro rendering components (must export `blockComponents`) */ componentsEntry?: string; /** Admin pages for navigation */ adminPages?: PluginAdminPage[]; /** Dashboard widgets */ adminWidgets?: PluginDashboardWidget[]; /** Settings schema for the auto-generated admin settings form */ settingsSchema?: Record; /** * Portable Text block types this plugin contributes to the editor. * Declarative (Block Kit) — surfaced in the admin slash menu and consumed * from the manifest, so standard/sandboxed plugins can contribute blocks * without a native render component. */ portableTextBlocks?: PortableTextBlockConfig[]; /** Field widget types this plugin contributes for schema-field editing UIs. */ fieldWidgets?: FieldWidgetConfig[]; /** * Capabilities the plugin requests. * For standard-format plugins, capabilities are enforced in both trusted and * sandboxed modes via the PluginContextFactory. */ capabilities?: string[]; /** * Allowed hosts for network:fetch capability * Supports wildcards like "*.example.com" */ allowedHosts?: string[]; /** * Storage collections the plugin declares * Sandboxed plugins can only access declared collections. */ storage?: Record; /** Serialized MCP declarations emitted by the plugin build. */ mcp?: PluginMcpManifestConfig; /** * Route declarations for sandboxed config-declared plugins. Mirrors * definePlugin({ routes }) and drives route auth decisions; omitted routes * default to non-public. */ routes?: Array; /** * Hook declarations for sandboxed config-declared plugins. Mirrors * definePlugin({ hooks }). */ hooks?: Array; } /** * Sandboxed plugin descriptor - same format as PluginDescriptor * * These run in isolated V8 isolates via Worker Loader on Cloudflare. * The `entrypoint` is resolved to a file and bundled at build time. */ type SandboxedPluginDescriptor> = PluginDescriptor; interface EmDashConfig { /** * Database configuration * * Use one of the adapter functions: * - `sqlite({ url: "file:./data.db" })` - Local SQLite * - `libsql({ url: "...", authToken: "..." })` - Turso/libSQL * - `d1({ binding: "DB" })` - Cloudflare D1 * * @example * ```ts * import { sqlite } from "@premium-cms/emdash/db"; * * emdash({ * database: sqlite({ url: "file:./data.db" }), * }) * ``` */ database?: DatabaseDescriptor; /** Core database migration behavior at runtime. Defaults to `auto`. */ migrations?: RuntimeMigrationConfig; /** * Storage configuration (for media) */ storage?: StorageDescriptor; /** * Optional distributed object cache for query results. * * Off by default. When configured, content and chrome (settings, menus, * taxonomies) reads are cached in a fast key/value store and served without * touching the database on repeat requests across isolates. This offloads * read pressure from D1/SQLite, which is especially valuable on Cloudflare * where D1 has far lower request capacity than KV. * * Use a backend adapter: * - `memoryCache()` from `emdash/astro` — in-isolate (Node / local dev) * - `kvCache({ binding: "CACHE" })` from `@premium-cms/cloudflare` — KV * * Preview and visual-edit requests bypass the cache, so editors previewing * see live content. All other reads — including authenticated browsing outside * edit mode — are served from the cache, which only ever stores published * content. After an edit, anonymous visitors may see stale content until other * isolates pick up the bumped epoch: immediate with the memory backend, and on * KV bounded by KV's edge-cache propagation (eventual consistency, up to ~60s) * plus the isolate-local `revalidate` window (default 1s). * * Scheduled content becomes visible at query time (no write event fires when * its publish time passes), so a cached list/entry won't surface a newly-due * scheduled item until the next write to that collection or until the * entry's TTL lapses (`defaultTtl`, default 1h). Sites that rely on precise * scheduled publishing should lower `defaultTtl` accordingly. * * @example * ```ts * import { kvCache } from "@premium-cms/cloudflare"; * * emdash({ * database: d1({ binding: "DB" }), * objectCache: kvCache({ binding: "CACHE" }), * }) * ``` */ objectCache?: ObjectCacheDescriptor; /** * Image optimization. * * By default EmDash wraps Astro's image endpoint so media served from * storage is optimized through the normal `` / `getImage` pipeline, * loading source bytes directly from the storage adapter (works behind * Cloudflare Access). Set to `false` to leave Astro's image endpoint * untouched -- media then renders as a plain `` unless your image * service can fetch it over HTTP. */ images?: boolean; /** * Trusted plugins to load (run in main isolate) * * @example * ```ts * import auditLog from "@premium-cms/plugin-audit-log"; * import webhookNotifier from "@premium-cms/plugin-webhook-notifier"; * * emdash({ * plugins: [auditLog, webhookNotifier], * }) * ``` */ plugins?: PluginDescriptor[]; /** * Sandboxed plugins to load (run in isolated V8 isolates) * * Only works on Cloudflare with Worker Loader enabled. * Uses the same format as `plugins` - the difference is where they run. * * @example * ```ts * import { untrustedPlugin } from "some-third-party-plugin"; * * emdash({ * plugins: [trustedPlugin()], // runs in host * sandboxed: [untrustedPlugin()], // runs in isolate * sandboxRunner: "@premium-cms/sandbox-cloudflare", * }) * ``` */ sandboxed?: SandboxedPluginDescriptor[]; /** * Module that exports the sandbox runner factory. * Required if using sandboxed plugins. * * @example * ```ts * emdash({ * sandboxRunner: "@premium-cms/sandbox-cloudflare", * }) * ``` */ sandboxRunner?: string; /** * Explicitly disable plugin sandboxing, even if a sandbox runner is configured. * Use this as a debugging escape hatch to determine whether a bug is in your * plugin code or in the sandbox runtime. * * When set to `false`, all plugins run in-process without isolation. * * @default true (sandboxing enabled if sandboxRunner is configured) */ sandbox?: boolean; /** * Authentication configuration * * Use an auth adapter function from a platform package: * - `access({ teamDomain: "..." })` from `@premium-cms/cloudflare` * * When an external auth provider is configured, passkey auth is disabled. * * @example * ```ts * import { access } from "@premium-cms/cloudflare"; * * emdash({ * auth: access({ * teamDomain: "myteam.cloudflareaccess.com", * audience: "abc123...", * roleMapping: { * "Admins": 50, * "Editors": 30, * }, * }), * }) * ``` */ auth?: AuthDescriptor; /** * Pluggable auth providers (login methods on the login page). * * Auth providers appear as options alongside passkey on the login page * and setup wizard. Any provider can be used to create the initial * admin account. Passkey is built-in; providers listed here are additive. * * @example * ```ts * import { atproto } from "@premium-cms/auth-atproto"; * * emdash({ * authProviders: [atproto()], * }) * ``` */ authProviders?: AuthProviderDescriptor[]; /** * MCP (Model Context Protocol) server endpoint. * * Exposes an MCP Streamable HTTP server at `/_emdash/api/mcp` * that allows AI agents and tools to interact with the CMS using * the standardized MCP protocol. * * Enabled by default. The endpoint requires bearer token auth, so * it has no effect unless the user creates an API token and * configures a client. Set to `false` to disable. * * @default true */ mcp?: boolean; /** * Plugin marketplace URL * * When set, enables the marketplace features: browse, install, update, * and uninstall plugins from a remote marketplace. * * Must be an HTTPS URL in production, or localhost/127.0.0.1 in dev. * Requires `sandboxRunner` to be configured (marketplace plugins run sandboxed). * * When `registry` is also configured, the registry replaces the marketplace * for the admin UI's browse and install flows. Existing marketplace-installed * plugins continue to work; new installs and updates come from the registry. * * @example * ```ts * emdash({ * marketplace: "https://marketplace.emdashcms.com", * sandboxRunner: "@premium-cms/sandbox-cloudflare", * }) * ``` */ marketplace?: string; /** * Experimental features. * * These options are not yet stable. Shape, defaults, and behavior may * change between minor versions. Use only if you're comfortable * tracking the release notes and updating your config when an * experimental feature graduates or changes. * * @example * ```ts * emdash({ * experimental: { * registry: { * aggregatorUrl: "https://registry.emdashcms.com", * }, * }, * sandboxRunner: "@premium-cms/sandbox-cloudflare", * }) * ``` */ experimental?: ExperimentalConfig; /** * Maximum allowed media file upload size in bytes. * * Applies to both direct multipart uploads and signed-URL uploads. * When unset, defaults to 52_428_800 (50 MB). * * @example * ```ts * emdash({ maxUploadSize: 100 * 1024 * 1024 }) // 100 MB * ``` */ maxUploadSize?: number; /** * Public browser-facing origin for the site. * * Use when `Astro.url` / `request.url` do not match what users open — common with a * **TLS-terminating reverse proxy**: the app often sees `http://` on the internal hop * while the browser uses `https://`, which breaks WebAuthn, CSRF, OAuth, and redirect URLs. * * Set to the full origin users type in the address bar (no path), e.g. * `https://mysite.example.com`. When not set, falls back to environment variables * `EMDASH_SITE_URL` > `SITE_URL`, then to the request URL's origin. * * Replaces `passkeyPublicOrigin` (which only fixed passkeys). */ siteUrl?: string; /** * Additional origins accepted by passkey verification. * * When the same EmDash deployment is reachable under several hostnames sharing * a registrable parent (e.g. `https://example.com` plus * `https://preview.example.com`), the canonical `siteUrl` defines the `rpId` * and the entries here are the *additional* origins from which assertions * are accepted. Each entry must be the same hostname as `siteUrl` or a * subdomain of it — WebAuthn requires `rpId` to be a registrable suffix of * every origin. * * Merged at runtime with the `EMDASH_ALLOWED_ORIGINS` env var (comma-separated). * Validation: * - Config-declared entries are shape-checked at Astro startup. * - Subdomain relationship to `siteUrl` is checked at startup when * `siteUrl` is also config-declared, otherwise at first passkey * verification (since `siteUrl` may come from `EMDASH_SITE_URL`). * * Mismatches throw with a source-attributed message naming * `config.allowedOrigins` or `EMDASH_ALLOWED_ORIGINS`. * * @example * ```ts * emdash({ * siteUrl: "https://example.com", * allowedOrigins: ["https://preview.example.com"], * }) * ``` */ allowedOrigins?: string[]; trustedProxyHeaders?: string[]; /** * User middleware that wraps the complete EmDash request pipeline. * * Before `next()` it runs before EmDash initializes its runtime or database, * so `locals.emdash`, the authenticated user, and request-scoped EmDash state * are unavailable. This allows cached responses and request gates to return * without paying initialization cost. When it calls `next()`, the resolved * response includes EmDash HTML injection and all other response mutations, * allowing the middleware to finalize caching and response headers safely. */ middleware?: { /** Astro middleware module entrypoint. */outer: string | URL; }; /** * Enable playground mode for ephemeral "try EmDash" sites. * * When set, the integration injects a playground middleware (order: "pre") * that runs BEFORE the normal EmDash middleware chain. It creates an * isolated Durable Object database per session, runs migrations, applies * the seed, creates an anonymous admin user, and sets the DB in ALS. * By the time the runtime middleware runs, the database is fully ready. * * Setup and auth middleware are skipped (the playground handles both). * * Requires `@premium-cms/cloudflare` as a dependency and a DO binding * in wrangler.jsonc. * * @example * ```ts * emdash({ * database: playgroundDatabase({ binding: "PLAYGROUND_DB" }), * playground: { * middlewareEntrypoint: "@premium-cms/cloudflare/db/playground-middleware", * }, * }) * ``` */ playground?: { /** Module path for the playground middleware. */middlewareEntrypoint: string; }; /** * Media providers for browsing and uploading media * * The local media provider (using storage adapter) is available by default. * Additional providers can be added for external services like Unsplash, * Cloudinary, Mux, Cloudflare Images, etc. * * @example * ```ts * import { cloudflareImages, cloudflareStream } from "@premium-cms/cloudflare"; * import { unsplash } from "@premium-cms/provider-unsplash"; * * emdash({ * mediaProviders: [ * cloudflareImages({ accountId: "..." }), * cloudflareStream({ accountId: "..." }), * unsplash({ accessKey: "..." }), * ], * }) * ``` */ mediaProviders?: MediaProviderDescriptor[]; /** * Admin UI font configuration. * * By default, EmDash loads Noto Sans via the Astro Font API, covering * Latin, Latin Extended, Cyrillic, Cyrillic Extended, Greek, Greek * Extended, Devanagari, and Vietnamese. Fonts are downloaded from * Google at build time and self-hosted, so there are no runtime CDN * requests. * * To add support for additional writing systems (Arabic, CJK, etc.), * pass script names. EmDash resolves the matching Noto Sans variant * from Google Fonts and merges all script faces under a single * font-family, so the browser downloads only the glyphs it needs * via unicode-range. * * Set to `false` to disable font injection entirely and use system fonts. * * @example * ```ts * // Add Arabic and Japanese support * emdash({ * fonts: { * scripts: ["arabic", "japanese"], * }, * }) * ``` * * @example * ```ts * // Disable web fonts entirely (use system fonts) * emdash({ * fonts: false, * }) * ``` */ fonts?: false | { /** * Additional Noto Sans script families to include. * * Available scripts: arabic, armenian, bengali, chinese-simplified, * chinese-traditional, chinese-hongkong, devanagari, ethiopic, farsi, * georgian, gujarati, gurmukhi, hebrew, japanese, kannada, khmer, * korean, lao, malayalam, myanmar, oriya, sinhala, tamil, telugu, * thai, tibetan. */ scripts?: string[]; }; /** * Admin UI branding (white-labeling). * * Overrides the default EmDash logo and name in the admin panel. * Use this to white-label the CMS for agency or enterprise deployments. * These settings are separate from the public site settings (title, logo, * favicon) which remain available for SEO and front-end use. * * @example * ```ts * emdash({ * admin: { * logo: "/images/agency-logo.webp", * siteName: "AgencyX CMS", * favicon: "/favicon.ico", * }, * }) * ``` */ admin?: { /** URL or path to a custom logo image for the admin UI (login page, sidebar). */logo?: string; /** Custom name displayed in the admin sidebar and browser tab. */ siteName?: string; /** URL or path to a custom favicon for the admin panel. */ favicon?: string; }; /** * Editor toolbar delivery on public pages. * * - `"server"` (default): the toolbar is injected server-side into every * HTML response rendered for an authenticated editor. Simple and * zero-config, but behind a shared cache (Cloudflare Cache Everything / * Workers Cache, Fastly, Varnish, …) editors often receive the cached * anonymous variant — without the toolbar — whenever an anonymous visitor * primed the cache first, so the toolbar appears and disappears with * cache state. * - `"client"`: public HTML is identical for everyone (nothing * session-specific is injected server-side, so shared caches stay fully * effective). A tiny bootstrap script shows an "Edit" pill for browsers * that have logged into the admin (non-secret localStorage flag). Clicking * it verifies the session and reloads the page with an `_edit` query * param, which is always rendered fresh (never cached) with the full * toolbar. Logged-out visitors opening an `_edit` URL are redirected to * the canonical URL. * - `false`: never render the toolbar or bootstrap script. * * See the visual-editing docs for the cache-behavior details. */ toolbar?: "server" | "client" | false; /** * Version of Astro the host project is building with. Populated by the * integration's `astro:config:setup` hook (not authored by the user) and * surfaced to the admin and the registry install gate so a plugin's * `env:astro` requirement can be evaluated against the real host version. */ astroVersion?: string; } /** * Get stored config from global * This is set by the virtual module at build time */ declare function getStoredConfig(): EmDashConfig | null; //#endregion //#region src/plugins/sandbox/types.d.ts /** * Resource limits for sandboxed plugins. * Enforced by the sandbox runtime (e.g., Worker Loader). */ interface ResourceLimits { /** CPU time per invocation in milliseconds (default: 200ms) */ cpuMs?: number; /** Memory limit in MB (default: 128MB) */ memoryMb?: number; /** Maximum subrequests per invocation — every bridge call counts (default: 64) */ subrequests?: number; /** Wall-clock time limit in milliseconds (default: 60000ms) */ wallTimeMs?: number; } /** * Storage interface for loading plugin code. * Could be R2, local filesystem, or any other storage backend. */ interface PluginCodeStorage { /** Get plugin bundle code by path */ get(path: string): Promise; /** Check if a bundle exists */ exists(path: string): Promise; } /** * Serialized email message for sandbox RPC transport. * Matches the core EmailMessage type but uses only serializable fields. */ interface SandboxEmailMessage { to: string; subject: string; text: string; html?: string; } /** * Callback for sending email from a sandboxed plugin. * The sandbox runner wires this up from the EmailPipeline. * * @param message - The email message to send * @param pluginId - The sending plugin's ID (used as source) */ type SandboxEmailSendCallback = (message: SandboxEmailMessage, pluginId: string) => Promise; /** * Options for creating a sandbox runner */ interface SandboxOptions { /** Storage interface for loading plugin code */ storage?: PluginCodeStorage; /** Database for bridge operations */ db: Kysely; /** Called immediately before a sandboxed plugin content mutation. */ beforeContentWrite?: () => Promise; /** Default resource limits */ limits?: ResourceLimits; /** Site info for plugin context (injected into wrapper at generation time) */ siteInfo?: { name: string; url: string; locale: string; trailingSlash?: "always" | "never" | "ignore"; }; /** Email send callback, wired from the EmailPipeline by the runtime */ emailSend?: SandboxEmailSendCallback; /** * Media storage adapter for sandboxed plugin uploads and deletes. * When provided, plugins with write:media can upload and delete files * via ctx.media.upload() and ctx.media.delete(). */ mediaStorage?: { upload(options: { key: string; body: Uint8Array; contentType: string; }): Promise; delete(key: string): Promise; }; } /** * Handle to a sandboxed plugin running inside an isolate. Returned * by `SandboxRunner.load` and held by the runtime's cache so hooks / * routes can be invoked across the isolate boundary. Distinct from * the author-facing `SandboxedPlugin` type in `emdash/plugin`, which * describes the source-level shape of a plugin's default export. */ interface SandboxedPluginInstance { /** Unique identifier: `${manifest.id}:${manifest.version}` */ readonly id: string; /** * Invoke a hook in the sandboxed plugin. * * @param hookName - Name of the hook (e.g., "content:beforeSave") * @param event - Event data to pass to the hook * @returns Hook result (transformed content, void, etc.) */ invokeHook(hookName: string, event: unknown): Promise; /** * Invoke an API route in the sandboxed plugin. * * @param routeName - Name of the route * @param input - Validated input data * @param request - Serialized request info for context * @returns Route response data */ invokeRoute(routeName: string, input: unknown, request: SerializedRequest): Promise; /** * Terminate the sandboxed plugin. * Releases resources and prevents further invocations. */ terminate(): Promise; } /** * Serialized request for RPC transport. * Worker Loader can't pass Request objects directly. */ interface SerializedRequest { url: string; method: string; headers: Record; /** Normalized request metadata extracted before RPC serialization */ meta: RequestMeta; /** * Authenticated caller for private routes, resolved by the host before * dispatch. Undefined for public routes and unbound machine tokens. */ user?: UserInfo; } declare const SANDBOX_ROUTE_ERROR_DEFINITIONS: { readonly MEDIA_USAGE_ACTIVATION_IN_PROGRESS: { readonly message: "Media usage activation is in progress"; readonly status: 503; }; readonly MEDIA_USAGE_ACTIVATION_CHECK_FAILED: { readonly message: "Unable to verify media usage activation state"; readonly status: 503; }; }; type SandboxRouteErrorCode = keyof typeof SANDBOX_ROUTE_ERROR_DEFINITIONS; interface SandboxRouteErrorDetails { code: SandboxRouteErrorCode; message: string; status: 503; } interface SandboxRouteErrorEnvelope { __emdashSandboxRouteError: true; error: SandboxRouteErrorDetails; } declare function getSandboxRouteErrorDetails(error: unknown): SandboxRouteErrorDetails | null; declare function createSandboxRouteError(code: SandboxRouteErrorCode): Error & SandboxRouteErrorDetails; declare function createSandboxRouteErrorEnvelope(error: unknown): SandboxRouteErrorEnvelope | null; declare function getSandboxRouteErrorEnvelope(value: unknown): SandboxRouteErrorEnvelope | null; /** * Sandbox runner interface. * Platform adapters implement this to provide plugin isolation. */ interface SandboxRunner { /** * Check if sandboxing is available on this platform. * Returns false for platforms that don't support isolation. */ isAvailable(): boolean; /** * Check if the sandbox runtime is currently healthy. * For in-process runners this always returns true. * For sidecar-based runners (workerd), returns false if the * child process has crashed and hasn't been restarted yet. */ isHealthy(): boolean; /** * Load a sandboxed plugin from code. * * @param manifest - Plugin manifest with metadata and capabilities * @param code - The bundled plugin JavaScript code * @returns A sandboxed plugin instance * @throws If sandboxing is not available or plugin can't be loaded */ load(manifest: PluginManifest, code: string): Promise; /** * Set the email send callback for sandboxed plugins. * Called after the EmailPipeline is created, since the pipeline * doesn't exist when the sandbox runner is constructed. */ setEmailSend(callback: SandboxEmailSendCallback | null): void; /** * Terminate all loaded sandboxed plugins. * Called during shutdown or when reconfiguring. */ terminateAll(): Promise; } /** * Error thrown when the sandbox runtime is unavailable. * This happens when the sidecar process has crashed or hasn't started. */ declare class SandboxUnavailableError extends Error { constructor(pluginId: string, reason: string); } /** * Factory function type for creating sandbox runners. * Exported by platform adapters (e.g., @premium-cms/adapter-cloudflare/sandbox). * * @example * ```typescript * // In @premium-cms/adapter-cloudflare/sandbox.ts * export const createSandboxRunner: SandboxRunnerFactory = (options) => { * return new CloudflareSandboxRunner(options); * }; * ``` */ type SandboxRunnerFactory = (options: SandboxOptions) => SandboxRunner; //#endregion //#region src/plugins/cron.d.ts /** * Callback to invoke a plugin's cron hook. * Provided by PluginManager so CronExecutor stays decoupled from the hook pipeline. */ type InvokeCronHookFn = (pluginId: string, event: CronEvent) => Promise; /** * Callback to notify the scheduler that the next due time may have changed. */ type RescheduleFn = () => void; /** * Executes overdue cron tasks. * * Called by the platform driver: the NodeCronScheduler timer on Node, or the * Worker's `scheduled()` handler (via runScheduledTasks) on Cloudflare. * Stateless — all state lives in the database. */ declare class CronExecutor { private invokeCronHook; /** * Resolves the database connection to use for this tick. A resolver (not a * captured instance) so connection-backed adapters work across events: on * Cloudflare the `scheduled()` handler installs an event-scoped connection * in ALS, and this resolves to it instead of the per-isolate singleton * whose socket belongs to an earlier request. Accepts a plain `Kysely` too * (wrapped in a constant resolver) for callers/tests that don't need ALS. */ private readonly resolveDb; constructor(db: Kysely | (() => Kysely), invokeCronHook: InvokeCronHookFn); private get db(); /** * Process all overdue tasks. * * 1. Atomically claim tasks whose next_run_at <= now, status = idle, enabled = 1. * 2. For each claimed task, invoke the plugin's cron hook. * 3. On success: compute next_run_at and reset to idle, or delete one-shots. * 4. On failure: reset to idle (retry on next tick). */ tick(): Promise; /** * Recover tasks stuck in 'running' for more than STALE_LOCK_MINUTES. * These likely crashed mid-execution. */ recoverStaleLocks(): Promise; /** * Get the next due time across all enabled tasks. * Returns null if no tasks are scheduled. */ getNextDueTime(): Promise; } /** * Per-plugin cron API implementation. * Scoped to a single plugin ID — plugins cannot see or modify other plugins' tasks. */ declare class CronAccessImpl implements CronAccess { private db; private pluginId; private reschedule; constructor(db: Kysely, pluginId: string, reschedule: RescheduleFn); schedule(name: string, opts: { schedule: string; data?: Record; }): Promise; cancel(name: string): Promise; list(): Promise; } //#endregion //#region src/plugins/context.d.ts /** * Create HTTP access with host validation. * * Uses redirect: "manual" to re-validate each redirect target against * the allowedHosts list, preventing redirects to unauthorized hosts. */ declare function createHttpAccess(pluginId: string, allowedHosts: string[]): HttpAccess; /** * Create unrestricted HTTP access (for plugins with network:fetch:any capability). * No host validation, but applies SSRF protection on redirect targets to * prevent plugins from being tricked into reaching internal services. */ declare function createUnrestrictedHttpAccess(pluginId: string): HttpAccess; /** * Options for creating site info */ interface SiteInfoOptions { /** Site name from options table */ siteName?: string; /** Site URL from options table or Astro config */ siteUrl?: string; /** The site's platform origin (`custom_domain:default_url`), when hosted by a control plane. */ platformUrl?: string; /** Site locale from options table */ locale?: string; /** Astro's `trailingSlash` config (from `virtual:emdash/config`). */ trailingSlash?: "always" | "never" | "ignore"; } interface PluginContextFactoryOptions { db: Kysely; beforeContentWrite?: () => Promise; /** * Resolver for the database connection, preferred over `db` when present. * Called per `createContext()` so connection-backed adapters (e.g. Postgres * over Hyperdrive) get the current request/event-scoped connection from ALS * rather than a snapshot of the per-isolate singleton — reusing the * singleton's socket from a later event trips workerd's cross-request I/O * guard. When omitted, `db` is used directly (correct for stateless * adapters like D1 and Node SQLite). `db` remains required as the fallback. */ getDb?: () => Kysely; /** * Storage backend for direct media uploads. * If not provided, upload() will throw. */ storage?: Storage; /** * Explicit provider for `ctx.media.getUploadUrl()`. Optional: when omitted * but `storage` is configured, the factory derives a working `getUploadUrl()` * (and `upload()`) from storage. Only when neither `getUploadUrl` nor * `storage` is present do media write operations become unavailable. */ getUploadUrl?: (filename: string, contentType: string) => Promise<{ uploadUrl: string; mediaId: string; }>; /** * Site information for ctx.site and ctx.url(). * If not provided, site info will have empty defaults. */ siteInfo?: SiteInfoOptions; /** * Callback to notify the cron scheduler that the next due time may have changed. * If not provided, ctx.cron will not be available. */ cronReschedule?: () => void; /** * Email pipeline instance for ctx.email. * If not provided (or no provider configured), ctx.email will be undefined. */ emailPipeline?: EmailPipeline; /** * Pre-resolved list of trusted proxy header names (from the runtime * `EmDashConfig.trustedProxyHeaders` or the env var). Plugin route * handlers pass this to `extractRequestMeta` so plugins see the same * client IP the core auth path does. */ trustedProxyHeaders?: string[]; } //#endregion //#region src/plugins/hooks.d.ts type HookNameV2 = "plugin:install" | "plugin:activate" | "plugin:deactivate" | "plugin:uninstall" | "content:beforeSave" | "content:afterSave" | "content:beforeDelete" | "content:afterDelete" | "content:afterPublish" | "content:afterUnpublish" | "content:afterRestore" | "content:afterSchedule" | "content:afterUnschedule" | "media:beforeUpload" | "media:afterUpload" | "cron" | "email:beforeSend" | "email:deliver" | "email:afterSend" | "comment:beforeCreate" | "comment:moderate" | "comment:afterCreate" | "comment:afterModerate" | "page:metadata" | "page:fragments"; /** * Hook execution result */ interface HookResult { success: boolean; value?: T; error?: Error; pluginId: string; duration: number; } /** * Hook pipeline for executing hooks in order */ declare class HookPipeline { private hooks; private pluginMap; private contextFactory; /** Stored so setContextFactory can merge incrementally. */ private contextFactoryOptions; /** Hook names where at least one handler declared exclusive: true */ private exclusiveHookNames; /** * Selected provider plugin ID for each exclusive hook. * Set by the PluginManager after resolution. */ private exclusiveSelections; constructor(plugins: ResolvedPlugin[], factoryOptions?: PluginContextFactoryOptions); /** * Set or update the context factory options. * * When called on a pipeline that already has a factory, the new options * are merged on top of the existing ones so that callers don't need to * repeat every field (e.g. adding `cronReschedule` without losing * `storage` / `getUploadUrl`). */ setContextFactory(options: Partial): void; /** * Get context for a plugin */ private getContext; /** * Get typed hooks for a specific hook name. * The internal map stores ResolvedHook, but we know each name * maps to a specific handler type via HookHandlerMap. * * Exclusive hooks that have a selected provider are filtered out — they * should only run via invokeExclusiveHook(), not in the regular pipeline. */ private getTypedHooks; /** * Register all hooks from plugins. * * Registers each hook name individually to preserve type safety. The * internal map stores ResolvedHook since it's keyed by string, * but getTypedHooks() restores the correct handler type on retrieval. */ private registerPlugins; /** * Maps hook names to the capability required to register them. * * Hooks not listed here have no capability requirement (e.g. lifecycle * hooks, cron). Any plugin declaring a listed hook without the required * capability will have that hook silently skipped at registration time. */ private static readonly HOOK_REQUIRED_CAPABILITY; /** * Register a single plugin's hook by name */ private registerPluginHook; /** * Register a single hook */ private registerHook; /** * Sort hooks by priority and dependencies */ private sortHooks; /** * Execute a hook with timeout */ private executeWithTimeout; /** * Run plugin:install hooks */ runPluginInstall(pluginId: string): Promise[]>; /** * Run plugin:activate hooks */ runPluginActivate(pluginId: string): Promise[]>; /** * Run plugin:deactivate hooks */ runPluginDeactivate(pluginId: string): Promise[]>; /** * Run plugin:uninstall hooks */ runPluginUninstall(pluginId: string, deleteData: boolean): Promise[]>; private runLifecycleHook; /** * Run content:beforeSave hooks * Returns modified content from the pipeline */ runContentBeforeSave(content: Record, collection: string, isNew: boolean): Promise<{ content: Record; results: HookResult>[]; }>; /** * Run content:afterSave hooks */ runContentAfterSave(content: Record, collection: string, isNew: boolean): Promise[]>; /** * Run content:beforeDelete hooks * Returns whether deletion is allowed */ runContentBeforeDelete(id: string, collection: string): Promise<{ allowed: boolean; results: HookResult[]; }>; /** * Run content:afterDelete hooks */ runContentAfterDelete(id: string, collection: string, permanent: boolean): Promise[]>; /** * Run content state-change hooks that all share the same event shape. */ private runContentStateChangeHook; /** * Run content:afterPublish hooks (fire-and-forget). */ runContentAfterPublish(content: Record, collection: string): Promise[]>; /** * Run content:afterUnpublish hooks (fire-and-forget). */ runContentAfterUnpublish(content: Record, collection: string): Promise[]>; /** * Run content:afterRestore hooks (fire-and-forget). */ runContentAfterRestore(content: Record, collection: string): Promise[]>; /** * Run content:afterSchedule hooks (fire-and-forget). */ runContentAfterSchedule(content: Record, collection: string): Promise[]>; /** * Run content:afterUnschedule hooks (fire-and-forget). */ runContentAfterUnschedule(content: Record, collection: string): Promise[]>; /** * Run media:beforeUpload hooks */ runMediaBeforeUpload(file: { name: string; type: string; size: number; }): Promise<{ file: { name: string; type: string; size: number; }; results: HookResult<{ name: string; type: string; size: number; }>[]; }>; /** * Run media:afterUpload hooks */ runMediaAfterUpload(media: { id: string; filename: string; mimeType: string; size: number | null; url: string; createdAt: string; }): Promise[]>; /** * Invoke the cron hook for a specific plugin. * * Unlike other hooks which broadcast to all plugins, the cron hook is * dispatched only to the target plugin — the one that owns the task. */ invokeCronHook(pluginId: string, event: CronEvent): Promise>; /** * Run email:beforeSend hooks (middleware pipeline). * * Each handler receives the message and returns a modified message or * `false` to cancel delivery. The pipeline chains message transformations — * each handler receives the output of the previous one. */ runEmailBeforeSend(message: EmailMessage, source: string): Promise<{ message: EmailMessage | false; results: HookResult[]; }>; /** * Run email:afterSend hooks (fire-and-forget). * * Errors are logged but don't propagate — they don't affect the caller. */ runEmailAfterSend(message: EmailMessage, source: string): Promise[]>; /** * Run comment:beforeCreate hooks (middleware pipeline). * * Each handler receives the event and returns a modified event or * `false` to reject the comment. The pipeline chains transformations — * each handler receives the output of the previous one. */ runCommentBeforeCreate(event: CommentBeforeCreateEvent): Promise; /** * Run comment:afterCreate hooks (fire-and-forget). * * Errors are logged but don't propagate — they don't affect the caller. */ runCommentAfterCreate(event: CommentAfterCreateEvent): Promise; /** * Run comment:afterModerate hooks (fire-and-forget). * * Errors are logged but don't propagate — they don't affect the caller. */ runCommentAfterModerate(event: CommentAfterModerateEvent): Promise; /** * Run page:metadata hooks. Each handler returns contributions that are * merged by the metadata collector. Errors are logged but don't propagate. */ runPageMetadata(event: PageMetadataEvent): Promise>; /** * Run page:fragments hooks. Only trusted plugins should be registered * for this hook. Errors are logged but don't propagate. */ runPageFragments(event: PageFragmentEvent): Promise>; /** * Check if any hooks are registered for a given name */ hasHooks(name: HookNameV2): boolean; /** * Get hook count for debugging */ getHookCount(name: HookNameV2): number; /** * Get all registered hook names */ getRegisteredHooks(): HookNameV2[]; /** * Returns hook names where at least one handler declared exclusive: true */ getRegisteredExclusiveHooks(): string[]; /** * Check if a hook is exclusive */ isExclusiveHook(name: string): boolean; /** * Set the selected provider for an exclusive hook. * Called by PluginManager after resolution. */ setExclusiveSelection(hookName: string, pluginId: string): void; /** * Clear the selected provider for an exclusive hook. */ clearExclusiveSelection(hookName: string): void; /** * Get the selected provider for an exclusive hook (if any). */ getExclusiveSelection(hookName: string): string | undefined; /** * Get all plugins that registered a handler for a given exclusive hook. */ getExclusiveHookProviders(hookName: string): Array<{ pluginId: string; }>; /** * Get all plugins that registered a non-exclusive handler for a given * hook (e.g. `email:beforeSend`, `email:afterSend`), preserving priority * order. Partitions with `getExclusiveHookProviders()`, which returns * plugins whose registration is marked `exclusive: true`. */ getHookProviders(hookName: string): Array<{ pluginId: string; }>; /** * Invoke an exclusive hook — dispatch only to the selected provider. * Returns null if no provider is selected or if the selected hook * is not found in the pipeline. * * This is a generic dispatch used by the email pipeline and other * exclusive hook consumers. The handler type is unknown — callers * must know the expected signature. * * Errors are isolated: a failing handler returns an error result * instead of propagating the exception to the caller. */ invokeExclusiveHook(hookName: string, event: unknown): Promise<{ result: unknown; pluginId: string; error?: Error; duration: number; } | null>; } /** * Create a hook pipeline from plugins */ declare function createHookPipeline(plugins: ResolvedPlugin[], factoryOptions?: PluginContextFactoryOptions): HookPipeline; //#endregion //#region src/plugins/email.d.ts /** * EmailPipeline orchestrates email delivery through the plugin hook system. * * The pipeline runs in three stages: * 1. email:beforeSend — middleware hooks that can transform or cancel messages * 2. email:deliver — exclusive hook dispatching to the selected provider * 3. email:afterSend — fire-and-forget hooks for logging/analytics */ declare class EmailPipeline { private pipeline; constructor(pipeline: HookPipeline); /** * Replace the underlying hook pipeline. * * Called by the runtime when rebuilding the hook pipeline after a * plugin is enabled or disabled, so the email pipeline dispatches * to the current set of active hooks. */ setPipeline(pipeline: HookPipeline): void; /** * Send an email through the full pipeline. * * @param message - The email to send * @param source - Where the email originated ("system" for auth, plugin ID for plugins) * @throws EmailNotConfiguredError if no provider is selected * @throws EmailRecursionError if called re-entrantly from within a hook * @throws Error if the provider handler throws */ send(message: EmailMessage, source: string): Promise; /** * Inner send implementation, separated from the recursion guard. */ private sendInner; /** * Check if an email provider is configured and available. * * Returns true if an email:deliver provider is selected in the exclusive * hook system. Plugins and auth code use this to decide whether to show * "send invite" vs "copy invite link" UI. */ isAvailable(): boolean; } //#endregion //#region src/plugins/routes.d.ts /** * Route metadata (public flag) without the handler. * Used by the catch-all route to decide auth before dispatch. */ interface RouteMeta { public: boolean; permission?: string; /** * Cache-Control value for successful GET responses. Only ever set for * public routes — authenticated responses must stay `private, no-store`. */ cacheControl?: string; } /** * Result from a route invocation */ interface RouteResult { success: boolean; data?: T; error?: { code: string; message: string; details?: unknown; }; status: number; } /** * Host-side user shape accepted when dispatching a plugin route. Structurally * matches `User` from `@premium-cms/auth` so hosts can pass `locals.user` * directly without the plugin layer depending on the auth package. */ interface RouteCallerInput { id: string; email: string; name: string | null; role: number; roleId?: string | null; createdAt: Date | string; tokenAuth?: boolean; } /** * Route invocation options */ interface InvokeRouteOptions { /** The original request */ request: Request; /** Request body (already parsed) */ body?: unknown; /** * Authenticated caller resolved by the host, exposed to the handler as * `ctx.user`. Undefined for public routes and unbound machine tokens. */ user?: UserInfo; } /** * Error class for plugin routes * Allows plugins to return structured errors with specific HTTP status codes */ declare class PluginRouteError extends Error { code: string; status: number; details?: unknown | undefined; constructor(code: string, message: string, status?: number, details?: unknown | undefined); /** * Create a bad request error (400) */ static badRequest(message: string, details?: unknown): PluginRouteError; /** * Create an unauthorized error (401) */ static unauthorized(message?: string): PluginRouteError; /** * Create a forbidden error (403) */ static forbidden(message?: string): PluginRouteError; /** * Create a not found error (404) */ static notFound(message?: string): PluginRouteError; /** * Create a conflict error (409) */ static conflict(message: string, details?: unknown): PluginRouteError; /** * Create an internal error (500) */ static internal(message?: string): PluginRouteError; } //#endregion //#region src/plugins/scheduler/types.d.ts /** * Platform-specific cron scheduler interface. * * Schedulers are responsible for calling CronExecutor.tick() at the right * time. The executor handles all business logic; the scheduler only manages * timing. * * Implementations receive the CronExecutor via constructor. * */ interface CronScheduler { /** Start the scheduler. */ start(): void | Promise; /** Stop the scheduler and clean up timers/alarms. */ stop(): void | Promise; /** Signal that the next due time may have changed (task added/cancelled). */ reschedule(): void; /** Register a system cleanup function to run alongside each tick. */ setSystemCleanup(fn: SystemCleanupFn): void; /** Register bounded Media Usage maintenance to run after the general tick settles. */ setMediaUsageMaintenance?(fn: SystemCleanupFn): void; } /** * System cleanup callback invoked alongside each scheduler tick. * Fire-and-forget -- failures are logged internally and never propagate. */ type SystemCleanupFn = () => Promise; //#endregion //#region src/scheduled-publish.d.ts /** A content item that was promoted to published by a sweep. */ interface PublishedRef { collection: string; id: string; } //#endregion //#region src/emdash-runtime.d.ts /** Combined result from a single-pass page contribution collection */ interface PageContributions { metadata: PageMetadataContribution[]; fragments: PageFragmentContribution[]; } /** * Sandboxed plugin entry from virtual module */ interface SandboxedPluginEntry { id: string; version: string; options: Record; code: string; /** Capabilities the plugin requests */ capabilities: PluginCapability[]; /** Allowed hosts for network:fetch */ allowedHosts: string[]; /** Declared storage collections */ storage: PluginStorageConfig; /** Serialized MCP declarations emitted at plugin build time. */ mcp?: PluginMcpManifestConfig; /** Route declarations (name + public/permission/cacheControl), used for route auth decisions */ routes?: PluginManifest["routes"]; /** Hook declarations this plugin implements */ hooks?: PluginManifest["hooks"]; /** Admin pages */ adminPages?: Array<{ path: string; label?: string; icon?: string; }>; /** Dashboard widgets */ adminWidgets?: Array<{ id: string; title?: string; size?: string; }>; /** Settings schema for the auto-generated admin settings form */ settingsSchema?: Record; /** Portable Text block types contributed to the editor (declarative Block Kit) */ portableTextBlocks?: PortableTextBlockConfig[]; /** Field widget types contributed for schema-field editing UIs */ fieldWidgets?: FieldWidgetConfig[]; /** Admin entry module */ adminEntry?: string; /** * Exclusive hooks this plugin should be auto-selected for. * Weaker than an existing admin DB selection — config order wins when no selection exists. */ preferred?: string[]; } /** * Media provider entry from virtual module */ interface MediaProviderEntry { id: string; name: string; icon?: string; capabilities: MediaProviderCapabilities; /** Factory function to create the provider instance */ createProvider: (ctx: MediaProviderContext) => MediaProvider; } /** * Context passed to media provider factory functions */ interface MediaProviderContext { db: Kysely; /** * Resolver for the live connection, preferred over `db` by providers that * query EmDash's database. Resolves the current request/event-scoped * connection from ALS so connection-backed adapters (Postgres over * Hyperdrive) don't reuse the per-isolate singleton's socket across events. * Providers should resolve per operation rather than capturing `db` once. * Omitted-safe: falls back to `db` for stateless adapters (D1, Node SQLite). */ getDb?: () => Kysely; storage: Storage | null; } /** * Builds the timer-based scheduler that drives cron ticks and maintenance. * Injected via `virtual:emdash/scheduler` so the platform — not core — decides * whether a long-lived heartbeat exists. */ type CreateSchedulerFn = (executor: CronExecutor) => CronScheduler; /** * Dependencies injected from virtual modules (middleware reads these) */ interface RuntimeDependencies { config: EmDashConfig; /** Effective migration mode, resolved once by the runtime entrypoint. */ migrationMode?: RuntimeMigrationMode; plugins: ResolvedPlugin[]; createDialect: (config: any) => Dialect; /** * Factory for a dialect that batches same-turn reads into one round trip * ({@link EmDashRuntime.create} uses it for the cold-start read phase). * Present only on batching backends (D1, DO); absent backends fall back to * the singleton. Returns a fresh connection each call — it must never be the * long-lived singleton, whose coalescing buffer would be shared across * requests. */ createCoalescingDialect?: (config: any) => Dialect | null; createStorage: ((config: any) => Storage) | null; sandboxEnabled: boolean; /** sandbox: false escape hatch - load sandboxed plugins in-process */ sandboxBypassed?: boolean; /** * Factory for the timer-based cron/maintenance heartbeat. Supplied by the * generated `virtual:emdash/scheduler` module: a `NodeCronScheduler` factory * on long-lived runtimes (Node/Bun), or `null` on serverless adapters where * an external driver (e.g. the Cloudflare Worker's `scheduled()` Cron * Trigger) calls `runScheduledTasks()` instead. When absent or null, the * runtime starts no scheduler. Keeping the platform decision in the * integration means core has no adapter-specific runtime checks. */ createScheduler?: CreateSchedulerFn | null; /** Media provider entries from virtual module */ mediaProviderEntries?: MediaProviderEntry[]; sandboxedPluginEntries: SandboxedPluginEntry[]; /** Factory function supplied by the active platform adapter. */ createSandboxRunner: SandboxRunnerFactory | null; } /** * Constructor parameters for `EmDashRuntime`. * * Production code should use `EmDashRuntime.create()` which discovers and * loads all parts (database, plugins, hooks, cron, etc.) and then calls the * constructor. Direct construction is supported for callers that already * have all the dependencies in hand — for example, integration tests that * supply a pre-migrated database and an empty plugin set. * * Every field corresponds 1:1 to internal state set on the runtime — none of * these are derived. If you don't have a value for one, see what `create()` * passes for that field as the canonical default. */ interface EmDashRuntimeParts { db: Kysely; storage: Storage | null; configuredPlugins: ResolvedPlugin[]; sandboxedPlugins: Map; sandboxedPluginEntries: SandboxedPluginEntry[]; hooks: HookPipeline; enabledPlugins: Set; pluginStates: Map; config: EmDashConfig; mediaProviders: Map; mediaProviderEntries: MediaProviderEntry[]; cronExecutor: CronExecutor | null; cronScheduler: CronScheduler | null; emailPipeline: EmailPipeline | null; allPipelinePlugins: ResolvedPlugin[]; pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; siteUrl?: string; platformUrl?: string; locale?: string; trailingSlash?: "always" | "never" | "ignore"; }; }; runtimeDeps: RuntimeDependencies; pipelineRef: { current: HookPipeline; }; } type MediaUsageMaintenanceTaskClass = "entry_work" | "collection_deletion" | "reconciliation"; type MediaUsageMaintenanceResult = { outcome: "inactive" | "admission_closed"; taskClass: null; turn: null; } | { outcome: "processed"; taskClass: MediaUsageMaintenanceTaskClass; turn: number; }; /** * EmDashRuntime - singleton per worker */ declare class EmDashRuntime { /** * The singleton database instance (worker-lifetime cached). * Use the `db` getter instead — it checks the request context first * for per-request overrides (D1 read replica sessions, DO multi-site). */ private readonly _db; readonly storage: Storage | null; readonly configuredPlugins: ResolvedPlugin[]; readonly sandboxedPlugins: Map; readonly sandboxedPluginEntries: SandboxedPluginEntry[]; /** * Schema registry bound to the current request/event-scoped connection. * Built per access (SchemaRegistry just wraps a db) against `this.db`, the * ALS-aware getter — never a captured snapshot of the singleton. On a * connection-backed adapter (Postgres over Hyperdrive) a captured singleton * would query a socket opened by an earlier event and trip workerd's * cross-request I/O guard; the catch in handlers like handleContentUpdate * would then silently treat a revision-enabled collection as non-revisioned * and write draft edits to live columns. Same reasoning as the per-call * registry in _buildManifest(). */ get schemaRegistry(): SchemaRegistry; private _hooks; readonly config: EmDashConfig; readonly mediaProviders: Map; readonly mediaProviderEntries: MediaProviderEntry[]; readonly cronExecutor: CronExecutor | null; readonly email: EmailPipeline | null; private cronScheduler; private enabledPlugins; private pluginStates; /** * Isolate-lifetime guard so FTS indexes are verified at most once per * worker rather than on every admin request. See ensureSearchHealthy(). * Uses the poison-immune single-flight cache (never a shared awaitable * promise) so a cancelled first caller can't wedge later ones. */ private readonly _searchHealthCache; /** Current hook pipeline. Use the `hooks` getter for external access. */ get hooks(): HookPipeline; /** All plugins eligible for the hook pipeline (includes built-in plugins). * Stored so we can rebuild the pipeline when plugins are enabled/disabled. */ private allPipelinePlugins; /** Guards the once-per-process plugin storage-index sync. */ private storageIndexesSynced; /** Factory options for the hook pipeline context factory */ private pipelineFactoryOptions; /** Dependencies needed for exclusive hook resolution */ private runtimeDeps; /** Mutable ref for the cron invokeCronHook closure to read the current pipeline */ private pipelineRef; /** * Get the database instance for the current request. * * Checks the ALS-based request context first — middleware sets a * per-request Kysely instance there for D1 read replica sessions * or DO preview databases. Falls back to the singleton instance. */ get db(): Kysely; constructor(parts: EmDashRuntimeParts); /** * Get the sandbox runner instance (for marketplace install/update) */ getSandboxRunner(): SandboxRunner | null; /** * Whether the sandbox bypass mode (sandbox: false) is active. * Marketplace install/update handlers use this to skip the * SANDBOX_NOT_AVAILABLE gate, since the bypass path loads * marketplace plugins in-process via syncMarketplacePlugins(). */ isSandboxBypassed(): boolean; /** * Publish any content whose scheduled time has passed. * Returns the items promoted so callers can invalidate their cache tags. */ publishScheduled(): Promise; private publishScheduledWithFence; /** * Run the full scheduled-maintenance batch: cron tasks, scheduled * publishing, and system cleanup. For request-less drivers — the * Cloudflare `scheduled()` handler invokes this from a Cron Trigger. * (On Node the timer-based scheduler drives the same work itself.) * * Each step is independent and non-fatal. Returns the content promoted * by the publishing sweep so the caller can purge edge-cache tags. * * `onPublished` (optional) is awaited after each collection's batch so a * request-less driver can invalidate edge-cache tags incrementally rather * than only after the whole sweep — bounding stale-cache exposure if the * runtime is killed mid-sweep. */ runScheduledTasks(options?: { onPublished?: (refs: PublishedRef[]) => Promise; }): Promise<{ published: PublishedRef[]; }>; runScheduledMediaUsageTasks(): Promise; /** * Materialize plugin-declared storage indexes, once per process. * * Called from the scheduler path, not from request handlers — configured * plugins have no install handler, so the tick is their only sync moment. */ syncPluginStorageIndexesOnce(): Promise; /** * Stop the cron scheduler gracefully. * Call during worker shutdown or hot-reload. */ stopCron(): Promise; /** * Update in-memory plugin status and rebuild the hook pipeline. * * Rebuilding the pipeline ensures disabled plugins' hooks stop firing * and re-enabled plugins' hooks start firing again without a restart. * Exclusive hook selections are re-resolved after each rebuild. */ setPluginStatus(pluginId: string, status: "active" | "inactive"): Promise; /** * Rebuild the hook pipeline from the current set of enabled plugins. * * Filters `allPipelinePlugins` to only those in `enabledPlugins`, * creates a fresh HookPipeline, re-resolves exclusive hook selections, * and re-wires the context factory so existing references (cron * callbacks, email pipeline) use the new pipeline. */ private rebuildHookPipeline; /** * Synchronize marketplace plugin runtime state with DB + storage. * * Ensures install/update/uninstall changes take effect immediately in the * current worker: loads newly active plugins and removes uninstalled ones. */ /** When this isolate last reconciled its loaded plugins with `plugin_state` (see `resyncPluginsIfStale`). */ private pluginSyncAt; /** * Plugins change in ONE isolate: the install / update / enable / uninstall * route calls `syncMarketplacePlugins()` there, and every other isolate of the * worker (other colos, other instances) keeps serving the build it loaded at * start until it is recycled — which can take hours on a quiet site. Request * handling calls this instead: at most once per `maxAgeMs` per isolate it * re-reads `plugin_state` (one small query) and loads what changed, so a * plugin update reaches every isolate within the window. */ resyncPluginsIfStale(maxAgeMs?: number): Promise; syncMarketplacePlugins(): Promise; /** * Synchronize registry plugin runtime state with DB + storage. * * Mirrors {@link syncMarketplacePlugins} for plugins installed via the * experimental decentralized plugin registry. Called after install, * update, and uninstall handlers complete. */ syncRegistryPlugins(): Promise; /** * Internal: reconcile in-memory sandboxed-plugin state with the * `_plugin_state` table for the given source tier. Shared * implementation behind {@link syncMarketplacePlugins} and * {@link syncRegistryPlugins}. * * Each source tier has its own key set in `${source}PluginKeys` so a * sync for one tier doesn't invalidate the other. */ private syncSandboxedSourcePlugins; /** * Remove a plugin from the in-memory pipeline lists by ID. * Mutates allPipelinePlugins and configuredPlugins in place. */ private removePluginFromLists; /** * Sync marketplace plugin metadata in sandbox: false bypass mode. * * In bypass mode the noop runner can't load plugins, but admin pages, * widgets, and route metadata still need to refresh in-process when an * admin installs/updates/uninstalls a marketplace plugin. Otherwise the * admin UI shows stale data until the server restarts. * * Hooks and routes still won't execute under bypass (matches the * cold-start bypass behavior in loadMarketplacePluginsBypassed). * * Known limitation: bypass plugins are loaded via `import(dataUrl)`, * which Node's ESM cache keys on the full URL. Updates create fresh * module objects, but old ones remain cached for the worker's lifetime. * In practice this is a few KB per update — only matters for sites with * very frequent marketplace updates running long-lived processes. The * fix would be vm.SourceTextModule for explicit lifecycle management. */ private syncMarketplacePluginsBypassed; /** * Create and initialize the runtime */ static create(deps: RuntimeDependencies, timings?: Array<{ name: string; dur: number; desc?: string; }>): Promise; /** * Get a media provider by ID */ getMediaProvider(providerId: string): MediaProvider | undefined; /** * Get all media provider entries (for admin UI) */ getMediaProviderList(): Array<{ id: string; name: string; icon?: string; capabilities: MediaProviderCapabilities; }>; /** * Get or create database instance */ private static getDatabase; /** * Get or create storage instance */ private static getStorage; /** * Load sandboxed plugin entries as trusted in-process plugins. * Used by the sandbox: false debugging escape hatch. * * Imports each plugin's bundled ESM code via a data URL, adapts it * with adaptSandboxEntry, and returns ResolvedPlugin objects ready * to be merged into the pipeline plugin list. */ private static loadBypassedPlugins; /** * Load sandboxed plugins using SandboxRunner */ private static loadSandboxedPlugins; /** * Cold-start: load marketplace-installed plugins from site-local R2 storage * * Queries _plugin_state for source='marketplace' rows, fetches each bundle * from R2, and loads via SandboxRunner. */ /** * Cold-start load of all active sandboxed plugins for one install * tier (marketplace or registry) from site-local R2. * * Mirrors {@link syncSandboxedSourcePlugins} but runs once at runtime * creation, before request traffic arrives; the sync method runs on * demand after install / update / uninstall handlers. */ private static loadInstalledSandboxedPlugins; /** * Cold-start: load marketplace plugins in bypass mode (sandbox: false). * * Each active marketplace bundle is read, evaluated via data URL, adapted * with adaptSandboxEntry, and returned as a ResolvedPlugin. The caller is * responsible for merging these into allPipelinePlugins / configuredPlugins * BEFORE the hook pipeline is created, so hooks and routes register in * the trusted pipeline. * * Also caches manifest and route metadata so admin UI / getManifest() work. * * Returns ResolvedPlugins to be merged into the pipeline. */ private static loadMarketplacePluginsBypassed; /** * Resolve exclusive hook selections on startup. * * Delegates to the shared resolveExclusiveHooks() in hooks.ts. * The runtime version considers all pipeline providers as "active" since * the pipeline was already built from only active/enabled plugins. */ private static resolveExclusiveHooks; /** * Build the admin manifest from the live database. * * Used by the admin UI (sidebar collections, content editor field * dispatch, manifest endpoint) and by WordPress import — it's never * read on a public request, so this isn't on any anonymous hot path. * * No cross-request cache. The previous worker-isolate cache produced * a class of cross-isolate staleness bugs (#776, #873, #876, #877) * because Cloudflare Workers keeps multiple warm isolates per region * and there's no fan-out primitive to invalidate them in step. The * cache existed to amortize an N+1 schema query pattern; now that * `listCollectionsWithFields()` does the same work in two queries, * the rebuild is fast enough to pay on every admin request. * * Within a single request, `requestCached` deduplicates concurrent * callers (the manifest endpoint and an admin SSR template, say). */ getManifest(): Promise; /** * Build the manifest from the database. * * Constant query shapes via `listCollectionsWithFields()` — one query * for collections, one batched query for fields (chunked at * `SQL_BATCH_SIZE` collection IDs to stay under D1's bound-parameter * limit). Typical sites stay well under the chunk threshold, so this * is two queries in practice; never N+1. */ private _buildManifest; /** * Verify and repair FTS indexes on demand. Runs at most once per worker * lifetime. * * Originally called from `EmDashRuntime.create()`, but on a busy D1 link * (e.g. SIN replica ~80-150ms per query) it added ~1.5s to every cold * start for a modest-sized site — more than every other init phase * combined. Anonymous public reads never touch the search write path, * so the cost isn't paid back for the vast majority of requests. * * Instead, search endpoints call this lazily: the first request that * actually needs the index pays the verify cost (usually fast — no * rebuild needed), everyone else runs cold-free. * * Uses the runtime's singleton database (`this._db`) rather than the * request-scoped DB. Verify reads only, but `rebuildIndex` writes, and * a GET search request on D1 carries a `first-unconstrained` session * that's free to route at a read replica — unsafe for writes. The * singleton always goes through the default binding, which the D1 * adapter will promote to `first-primary` for write statements. * * Safe to call concurrently: repeated callers share the same in-flight * promise. Errors are swallowed internally so callers don't need to * defend against FTS not existing yet (pre-setup). */ ensureSearchHealthy(): Promise; /** * The git-backed store for a collection whose entries live in the site's * repo (`storage: "git"`), or null for database collections. Throws when * the collection is git-backed but GitHub isn't connected yet. */ private gitStoreFor; private gitError; handleContentList(collection: string, params: { cursor?: string; limit?: number; status?: string; orderBy?: string; order?: "asc" | "desc"; locale?: string; q?: string; authorId?: string; dateField?: ContentDateField; dateFrom?: string; dateTo?: string; bylines?: string[]; bylinesNone?: boolean; includeInferredBylines?: boolean; fieldFilters?: ContentFieldFilters; }): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: ContentListResponse; } | { success: true; data: { items: ContentItem$1[]; nextCursor: undefined; total: number; }; }>; handleContentAuthors(collection: string): Promise>; handleContentGet(collection: string, id: string, locale?: string): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: ContentResponse; } | { success: true; data: { item: ContentItem$1; _rev: string; }; }>; handleContentGetIncludingTrashed(collection: string, id: string, locale?: string): Promise>; /** * If the response item has a `draftRevisionId`, replace `item.data` with * the draft revision's data and expose the original published values as * `liveData`. This makes the content_get / content_update round-trip * intuitive — read returns the latest content the caller has saved * (their pending draft), with the previously-published values still * accessible for compare-style flows. * * No-op when no draft exists or the response is an error. */ private hydrateDraftData; handleContentCreate(collection: string, body: { data: Record; slug?: string | null; status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null; }>; locale?: string; translationOf?: string; taxonomies?: Record; }): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: ContentResponse; } | { success: true; data: { item: ContentItem$1; _rev: string; }; }>; handleContentUpdate(collection: string, id: string, body: { data?: Record; slug?: string | null; status?: string; authorId?: string | null; bylines?: Array<{ bylineId: string; roleLabel?: string | null; }>; seo?: { title?: string | null; description?: string | null; image?: string | null; canonical?: string | null; noIndex?: boolean; }; taxonomies?: Record; publishedAt?: string | null; locale?: string; /** Replace the previous autosave revision after staging this save. */ skipRevision?: boolean; _rev?: string; }): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: { item: ContentItem$1; _rev: string; }; } | { liveContentChanged: boolean; success: true; data: ContentResponse; }>; handleContentDelete(collection: string, id: string): Promise<{ success: true; data: { deleted: true; id: string; }; } | { success: true; data: { deleted: boolean; }; error?: undefined; } | { success: boolean; error: { code: string; message: string; }; data?: undefined; }>; handleContentListTrashed(collection: string, params?: { cursor?: string; limit?: number; }): Promise>; handleContentRestore(collection: string, id: string): Promise>; handleContentPermanentDelete(collection: string, id: string): Promise>; handleContentCountTrashed(collection: string): Promise>; handleContentDuplicate(collection: string, id: string, authorId?: string): Promise>; handleContentPublish(collection: string, id: string, options?: { publishedAt?: string; requireScheduledDue?: boolean; expectedScheduledAt?: string; }): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: ContentResponse; } | { success: true; data: { item: ContentItem$1; }; }>; handleContentUnpublish(collection: string, id: string): Promise<{ success: false; error: { code: string; message: string; }; } | { success: true; data: ContentResponse; } | { success: true; data: { item: ContentItem$1; }; }>; handleContentSchedule(collection: string, id: string, scheduledAt: string): Promise>; handleContentUnschedule(collection: string, id: string): Promise>; handleContentCountScheduled(collection: string): Promise>; handleContentDiscardDraft(collection: string, id: string): Promise>; handleContentCompare(collection: string, id: string): Promise | null; draft: Record | null; }>>; handleContentTranslations(collection: string, id: string): Promise; }>>; handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string | readonly string[]; q?: string; }): Promise>; handleMediaGet(id: string): Promise>; handleMediaCreate(input: { filename: string; mimeType: string; size?: number; width?: number; height?: number; storageKey: string; contentHash?: string; blurhash?: string; dominantColor?: string; authorId?: string; }): Promise>; handleMediaUpdate(id: string, input: { alt?: string; caption?: string; width?: number; height?: number; }): Promise>; handleMediaDelete(id: string): Promise>; handleRevisionList(collection: string, entryId: string, params?: { limit?: number; }): Promise>; handleRevisionGet(revisionId: string): Promise>; handleRevisionRestore(revisionId: string, callerUserId: string): Promise>; private refreshContentUsageAfterSuccessfulWrite; private deleteContentUsageAfterSuccessfulPermanentDelete; /** * Get route metadata for a plugin route without invoking the handler. * Used by the catch-all route to decide auth before dispatch. * Returns null if the plugin or route doesn't exist. */ /** * Every route enabled plugins expose under /_emdash/api/plugins//…, * with the access each one requires — the plugin half of the route * catalogue policies are written against. */ listPluginRoutes(): Array<{ pluginId: string; route: string; public: boolean; permission: string; }>; getPluginRouteMeta(pluginId: string, path: string): RouteMeta | null; /** * Resolve the settings schema for a runtime-installed (marketplace or * registry) plugin from its cached manifest. Returns `{}` for a known * plugin without a schema and `null` for unknown plugins, matching the * contract of `getPluginSettingsSchema` for build-time plugins. */ getRuntimePluginSettingsSchema(pluginId: string): Record | null; handlePluginApiRoute(pluginId: string, _method: string, path: string, request: Request, user?: RouteCallerInput | null): Promise<{ success: boolean; data?: unknown; error?: { code: string; message: string; }; status?: number; }>; getPluginMcpTools(pluginId?: string): Promise<{ pluginId: string; name: string; description: string; route: string; permission: string; destructive: boolean; inputSchema: z$1.ZodType; outputSchema?: z$1.ZodType; }[]>; getEnabledPluginMcpTools(): Promise<{ pluginId: string; name: string; description: string; route: string; permission: string; destructive: boolean; inputSchema: z$1.ZodType; outputSchema?: z$1.ZodType; }[]>; serializePluginMcpConsent(tools: Awaited>, pluginId: string): string; handlePluginMcpTool(pluginId: string, toolName: string, route: string, input: unknown, actorId: string, request: Request, caller?: RouteCallerInput | null): Promise<{ success: boolean; data?: unknown; error?: { code: string; message: string; }; status?: number; }>; handlePluginMcpDenied(pluginId: string, toolName: string, route: string, actorId: string, request: Request, reason: string): Promise; private findSandboxedPlugin; /** * Normalize image/file fields in content data. * Fills missing dimensions, storageKey, mimeType, and filename from providers. */ private normalizeMediaFields; private runSandboxedBeforeSave; private runSandboxedBeforeDelete; private runAfterSaveHooks; private runAfterDeleteHooks; private runDeferredContentHook; private runAfterPublishHooks; private runAfterUnpublishHooks; private runAfterRestoreHooks; private runAfterScheduleHooks; private runAfterUnscheduleHooks; private handleSandboxedRoute; /** * Cache for page contributions. Uses a WeakMap keyed on the PublicPageContext * object so results are collected once per page context per request, even when * multiple render components (EmDashHead, EmDashBodyStart, EmDashBodyEnd) * request contributions from the same page. */ private pageContributionCache; /** * Collect all page contributions (metadata + fragments) in a single pass. * Results are cached by page context object identity. */ collectPageContributions(page: PublicPageContext): Promise; private doCollectPageContributions; /** * Collect page metadata contributions from trusted and sandboxed plugins. * Delegates to the single-pass collector and returns the metadata portion. */ collectPageMetadata(page: PublicPageContext): Promise; /** * Collect page fragment contributions from trusted plugins only. * Delegates to the single-pass collector and returns the fragments portion. */ collectPageFragments(page: PublicPageContext): Promise; private isPluginEnabled; } //#endregion //#region src/sections/types.d.ts /** * Section source types */ type SectionSource = "theme" | "user" | "import"; /** * Section as returned to templates/admin */ interface Section { id: string; slug: string; title: string; description?: string; keywords: string[]; content: PortableTextBlock$1[]; previewUrl?: string; source: SectionSource; themeId?: string; createdAt: string; updatedAt: string; } /** * Input for creating a section */ interface CreateSectionInput { slug: string; title: string; description?: string; keywords?: string[]; content: PortableTextBlock$1[]; previewMediaId?: string; source?: SectionSource; themeId?: string; } /** * Input for updating a section */ interface UpdateSectionInput { slug?: string; title?: string; description?: string; keywords?: string[]; content?: PortableTextBlock$1[]; previewMediaId?: string | null; } /** * Options for querying sections */ interface GetSectionsOptions { /** Filter by source */ source?: SectionSource; /** Search title, description, keywords */ search?: string; /** Limit results */ limit?: number; /** Cursor for pagination */ cursor?: string; } //#endregion //#region src/sections/index.d.ts /** * Get a section by slug * * @example * ```ts * import { getSection } from "@premium-cms/emdash"; * * const section = await getSection("hero-centered"); * if (section) { * console.log(section.content); // Portable Text array * } * ``` */ declare function getSection(slug: string): Promise
; /** * Get all sections with optional filtering * * @example * ```ts * import { getSections } from "@premium-cms/emdash"; * * // Get all theme-provided sections * const themeSections = await getSections({ source: "theme" }); * * // Search sections * const results = await getSections({ search: "pricing" }); * ``` */ declare function getSections(options?: GetSectionsOptions): Promise>; //#endregion //#region src/content/converters/types.d.ts /** * Portable Text Types * * Defines the structure of Portable Text blocks used in EmDash. */ /** * Base span (inline text) */ interface PortableTextSpan { _type: "span"; _key: string; text: string; marks?: string[]; } /** * Mark definition (bold, italic, link, etc.) */ interface PortableTextMarkDef { _type: string; _key: string; [key: string]: unknown; } /** * Link mark definition */ interface PortableTextLinkMark extends PortableTextMarkDef { _type: "link"; href: string; blank?: boolean; } /** * Text block (paragraph, heading, etc.) */ interface PortableTextTextBlock { _type: "block"; _key: string; style?: "normal" | "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "blockquote"; listItem?: "bullet" | "number"; level?: number; listId?: string; listStart?: number; children: PortableTextSpan[]; markDefs?: PortableTextMarkDef[]; textAlign?: "left" | "center" | "right" | "justify"; } /** * Image block */ interface PortableTextImageBlock { _type: "image"; _key: string; asset: { _ref: string; url?: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; }; alt?: string; caption?: string; /** Original image width */ width?: number; /** Original image height */ height?: number; /** Display width for this instance (overrides original) */ displayWidth?: number; /** Display height for this instance (overrides original) */ displayHeight?: number; } /** * A single image inside a gallery block. Mirrors the shape produced by * gutenberg-to-portable-text and consumed by Gallery.astro. */ interface PortableTextGalleryImage { _type: "image"; _key: string; asset: { /** Present on WordPress-imported galleries; always emitted on round-trip */_type?: "reference"; _ref: string; url?: string; /** Provider ID for external media (e.g., "cloudflare-images") */ provider?: string; }; alt?: string; caption?: string; width?: number; height?: number; /** LQIP blurhash placeholder (images only) */ blurhash?: string; /** LQIP dominant-color placeholder, as a CSS color (images only) */ dominantColor?: string; } /** * Gallery block (grid of images with optional per-image captions) */ interface PortableTextGalleryBlock { _type: "gallery"; _key: string; images: PortableTextGalleryImage[]; columns?: number; } /** * Code block */ interface PortableTextCodeBlock { _type: "code"; _key: string; code: string; language?: string; filename?: string; } /** * HTML block (raw HTML content) */ interface PortableTextHtmlBlock { _type: "htmlBlock"; _key: string; html: string; } /** * Unknown/custom block (preserved for plugin compatibility) */ interface PortableTextUnknownBlock { _type: string; _key: string; [key: string]: unknown; } /** * Any Portable Text block */ type PortableTextBlock = PortableTextTextBlock | PortableTextImageBlock | PortableTextGalleryBlock | PortableTextCodeBlock | PortableTextHtmlBlock | PortableTextUnknownBlock; /** * ProseMirror JSON types (simplified for TipTap) */ interface ProseMirrorMark { type: string; attrs?: Record; } interface ProseMirrorNode { type: string; attrs?: Record; content?: ProseMirrorNode[]; marks?: ProseMirrorMark[]; text?: string; } interface ProseMirrorDocument { type: "doc"; content: ProseMirrorNode[]; } //#endregion //#region src/content/converters/prosemirror-to-portable-text.d.ts /** * Convert ProseMirror document to Portable Text */ declare function prosemirrorToPortableText(doc: ProseMirrorDocument): PortableTextBlock[]; //#endregion //#region src/content/converters/portable-text-to-prosemirror.d.ts /** * Convert Portable Text to ProseMirror document */ declare function portableTextToProsemirror(blocks: PortableTextBlock[]): ProseMirrorDocument; //#endregion //#region src/utils/hash.d.ts /** * SHA-256 hash of a string, truncated to 16 hex chars (64 bits). * For cache invalidation / ETags — not for security. */ declare function hashString(content: string): Promise; /** * Compute content hash using Web Crypto API * * Uses SHA-1 which is the fastest option in SubtleCrypto. * SHA-1 is cryptographically weak but fine for content deduplication * where we only need to detect identical files, not resist attacks. * * Returns hex string prefixed with "sha1:" for future-proofing */ declare function computeContentHash(content: Uint8Array | ArrayBuffer): Promise; //#endregion //#region src/utils/url.d.ts /** * URL scheme validation utilities * * Prevents XSS via dangerous URL schemes (javascript:, data:, vbscript:, etc.) * by allowlisting known-safe schemes before rendering into href attributes. */ /** * Returns the URL unchanged if it uses a safe scheme, otherwise returns "#". * * Use this at the render layer as the primary defense against XSS via * dangerous URL schemes like `javascript:`, `data:`, or `vbscript:`. * * @example * ```ts * sanitizeHref("https://example.com") // "https://example.com" * sanitizeHref("/about") // "/about" * sanitizeHref("#section") // "#section" * sanitizeHref("mailto:a@b.com") // "mailto:a@b.com" * sanitizeHref("javascript:alert(1)") // "#" * sanitizeHref("data:text/html,