/** * EmDashRuntime - Core runtime for EmDash CMS * * Manages database, storage, plugins (trusted + sandboxed), hooks, and * provides handlers for content/media operations. * * Created once per worker lifetime, cached and reused across requests. */ import { ensureFrontendServiceAccount } from "./auth/frontend-account.js"; import { Permissions } from "@premium-cms/auth"; import type { Element } from "@premium-cms/blocks"; import { Kysely, sql, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; import { z } from "zod"; import { GitContentStore, GitStoreError, gitConnection } from "./content/git-store.js"; import { assertMediaUsageActivationWriteAllowed } from "./api/media-usage-write-fence.js"; import { validateRev } from "./api/rev.js"; import type { EmDashConfig, PluginAdminPage, PluginDashboardWidget, } from "./astro/integration/runtime.js"; import type { EmDashManifest, ManifestCollection } from "./astro/types.js"; import { getAuthMode } from "./auth/mode.js"; import { getTrustedProxyHeaders } from "./auth/trusted-proxy.js"; import type { ContentFieldFilters } from "./content-list-query.js"; import { isSqlite } from "./database/dialect-helpers.js"; import { kyselyLogOption } from "./database/instrumentation.js"; import { enforceRuntimeMigrationPolicy, PendingMigrationsError, type RuntimeMigrationMode, } from "./database/migrations/policy.js"; import { ConcurrentMigrationTimeoutError, MIGRATION_RACE_WAIT_MS, } from "./database/migrations/runner.js"; import { AuditRepository } from "./database/repositories/audit.js"; import { ContentRepository } from "./database/repositories/content.js"; import { RevisionRepository } from "./database/repositories/revision.js"; import { ContentMutationConflictError } from "./database/repositories/types.js"; import type { ContentItem as ContentItemInternal, ContentDateField, } from "./database/repositories/types.js"; import { getI18nConfig } from "./i18n/config.js"; import { repairLocaleCasing } from "./i18n/repair-locale-casing.js"; import { warnAboutUnconfiguredTaxonomyLocales } from "./i18n/taxonomy-locale-diagnostic.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; import { MEDIA_USAGE_COLLECTION_DELETION_LIMITS, processDueMediaUsageCollectionDeletions, } from "./media/usage/collection-deletion-processor.js"; import { deleteContentMediaUsage, findNonTranslatableSiblingContentIds, markContentMediaUsageCollectionStale, refreshContentMediaUsageAfterWrite, } from "./media/usage/content-refresh.js"; import { MEDIA_USAGE_RECONCILIATION_LIMITS, processDueMediaUsageReconciliation, } from "./media/usage/reconciliation-processor.js"; import { MEDIA_USAGE_WORK_PROCESSING_LIMITS, processDueMediaUsageWork, processMediaUsageWorkAfterWrite, } from "./media/usage/work-processor.js"; import { createSandboxRunnerOptions } from "./plugins/sandbox/runner-options.js"; import { getSandboxRouteErrorDetails } from "./plugins/sandbox/types.js"; import type { SandboxedPluginInstance, SandboxRunner, SandboxRunnerFactory, } from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, MediaItem, PluginManifest, PluginCapability, PluginStorageConfig, PluginMcpManifestConfig, PublicPageContext, PageMetadataContribution, PageFragmentContribution, PortableTextBlockConfig, FieldWidgetConfig, SettingField, UserInfo, } from "./plugins/types.js"; import { recordSchedulerHeartbeatSafely } from "./scheduler-health.js"; import { MAX_COLLECTION_LIST_COLUMNS, type FieldType } from "./schema/types.js"; import { isMissingTableError } from "./utils/db-errors.js"; import { hashString } from "./utils/hash.js"; import { createInitLock, type InitLock, initWithLock } from "./utils/init-lock.js"; import { createSingleFlightCache, singleFlightCached } from "./utils/single-flight-cache.js"; import { COMMIT, VERSION } from "./version.js"; const LEADING_SLASH_PATTERN = /^\//; const LOCALE_CASING_REPAIR_OPTION = "emdash:repair_locale_casing"; function getLocaleCasingRepairVersion(locales: readonly string[]): string | null { if (locales.length === 0) return null; return `1:${locales.toSorted().join(",")}`; } /** * Parse a JSON column expected to contain an array of strings. * * Throws on malformed JSON rather than returning []; callers are responsible * for deciding how to handle/log the error. Empty string / null inputs return * [] (they represent "no value"). Non-string array entries are filtered out. */ function parseStringArray(raw: string | null | undefined): string[] { if (!raw) return []; const parsed: unknown = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((v): v is string => typeof v === "string"); } /** Combined result from a single-pass page contribution collection */ interface PageContributions { metadata: PageMetadataContribution[]; fragments: PageFragmentContribution[]; } const VALID_METADATA_KINDS = new Set(["meta", "property", "link", "jsonld"]); /** Security-critical allowlist for link rel values from sandboxed plugins */ const VALID_LINK_REL = new Set([ "canonical", "alternate", "author", "license", "nlweb", "site.standard.document", ]); /** * Runtime validation for sandboxed plugin metadata contributions. * Sandboxed plugins return `unknown` across the RPC boundary — we must * verify the shape before passing to the metadata collector. */ function isValidMetadataContribution(c: unknown): c is PageMetadataContribution { if (!c || typeof c !== "object" || !("kind" in c)) return false; const obj = c as Record; if (typeof obj.kind !== "string" || !VALID_METADATA_KINDS.has(obj.kind)) return false; switch (obj.kind) { case "meta": return typeof obj.name === "string" && typeof obj.content === "string"; case "property": return typeof obj.property === "string" && typeof obj.content === "string"; case "link": return ( typeof obj.href === "string" && typeof obj.rel === "string" && VALID_LINK_REL.has(obj.rel) ); case "jsonld": return obj.graph != null && typeof obj.graph === "object"; default: return false; } } import { after } from "./after.js"; import { maybeRunScheduledBackup } from "./api/handlers/backup.js"; import { loadBundleFromR2 } from "./api/handlers/marketplace.js"; import { runSystemCleanup } from "./cleanup.js"; import { DEFAULT_COMMENT_MODERATOR_PLUGIN_ID, defaultCommentModerate, } from "./comments/moderator.js"; import { validateEncryptionKeyAtStartup } from "./config/secrets.js"; import { OptionsRepository } from "./database/repositories/options.js"; import { handleContentList, handleContentAuthors, handleContentGet, handleContentGetIncludingTrashed, handleContentCreate, handleContentUpdate, handleContentDelete, handleContentDuplicate, handleContentRestore, handleContentPermanentDelete, handleContentListTrashed, handleContentCountTrashed, handleContentPublish, handleContentUnpublish, handleContentSchedule, handleContentUnschedule, handleContentCountScheduled, handleContentDiscardDraft, handleContentCompare, handleContentTranslations, handleMediaList, handleMediaGet, handleMediaCreate, handleMediaUpdate, handleMediaDelete, handleRevisionList, handleRevisionGet, handleRevisionRestore, SchemaRegistry, type Database, type Storage, } from "./index.js"; import { getDb } from "./loader.js"; import { isRecord } from "./plugin-utils.js"; import { CronExecutor, type InvokeCronHookFn } from "./plugins/cron.js"; import { definePlugin } from "./plugins/define-plugin.js"; import { DEV_CONSOLE_EMAIL_PLUGIN_ID, devConsoleEmailDeliver } from "./plugins/email-console.js"; import { EmailPipeline } from "./plugins/email.js"; import { createHookPipeline, resolveExclusiveHooks as resolveExclusiveHooksShared, type HookPipeline, } from "./plugins/hooks.js"; import { normalizeManifestRoute } from "./plugins/manifest-schema.js"; import { extractRequestMeta, sanitizeHeadersForSandbox } from "./plugins/request-meta.js"; import { buildRouteMeta, parseRouteInput, PluginRouteRegistry, toRouteCallerInfo, type RouteCallerInput, type RouteMeta, } from "./plugins/routes.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.js"; import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js"; import { normalizeRegistryConfig } from "./registry/config.js"; import { requestCached } from "./request-cache.js"; import { getRequestContext } from "./request-context.js"; import { publishDueContent, type PublishedRef } from "./scheduled-publish.js"; import { FTSManager } from "./search/fts-manager.js"; import { invalidateSiteSettingsCache } from "./settings/index.js"; /** * Map schema field types to editor field kinds */ const FIELD_TYPE_TO_KIND: Record = { string: "string", slug: "string", url: "url", text: "richText", number: "number", integer: "number", boolean: "boolean", datetime: "datetime", select: "select", multiSelect: "multiSelect", portableText: "portableText", image: "image", file: "file", reference: "reference", json: "json", repeater: "repeater", }; const DRAFT_ONLY_UPDATE_KEYS = new Set(["data", "slug", "locale", "skipRevision"]); const MAX_DRAFT_STAGE_ATTEMPTS = 32; const LIST_COLUMN_FIELD_TYPES: ReadonlySet = new Set([ "string", "number", "integer", "boolean", "datetime", "select", "multiSelect", ]); /** * Sandboxed plugin entry from virtual module */ export 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 */ export 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 */ export 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. */ export type CreateSchedulerFn = (executor: CronExecutor) => CronScheduler; /** * Dependencies injected from virtual modules (middleware reads these) */ export interface RuntimeDependencies { config: EmDashConfig; /** Effective migration mode, resolved once by the runtime entrypoint. */ migrationMode?: RuntimeMigrationMode; plugins: ResolvedPlugin[]; // eslint-disable-next-line @typescript-eslint/no-explicit-any 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. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any createCoalescingDialect?: (config: any) => Dialect | null; // eslint-disable-next-line @typescript-eslint/no-explicit-any 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. */ export 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 }; } /** * Convert a ContentItem to Record for hook consumption. * Hooks receive the full item as a flat record. */ function contentItemToRecord(item: ContentItemInternal): Record { return { ...item }; } /** * Db init lock reclaim deadline. Derived from the migration race wait so * they can't drift apart: a healthy init can legitimately block for the * full MIGRATION_RACE_WAIT_MS inside waitForConcurrentMigrator, plus cold * connect and migrator work, before it should be presumed dead. The outer * runtime init lock (middleware.ts) must use a strictly larger deadline — * it wraps create() → getDatabase() → this lock, and equal deadlines would * let the outer reclaim while the inner is legitimately still working. */ export const DB_INIT_DEADLINE_MS = MIGRATION_RACE_WAIT_MS + 20_000; /** * Db cache + its init lock live on globalThis behind a Symbol: the bundler * can duplicate this module across SSR chunks (same reasoning as * request-cache.ts), and a duplicated cache/lock would mean concurrent * independent db inits — and duplicate migrators — per isolate. */ const DB_HOLDER_KEY = Symbol.for("emdash:db-cache"); interface DbHolder { cache: Map>; lock: InitLock; /** * Recent migration failures, keyed like `cache`. A failed migration is * near-certain to fail again immediately (schema conflict, broken * migration), and without this every request in a warm isolate would * re-attempt it — on Workers with Postgres that stampedes the database * through the migration advisory lock (#1744). Entries expire after * DB_INIT_FAILURE_BACKOFF_MS; a successful init clears them. */ failures: Map; } const globalSymbolStore = globalThis as Record; function getDbHolder(): DbHolder { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis symbol slot, written only below let holder = globalSymbolStore[DB_HOLDER_KEY] as DbHolder | undefined; if (!holder) { holder = { cache: new Map>(), lock: createInitLock(), failures: new Map(), }; globalSymbolStore[DB_HOLDER_KEY] = holder; } // A holder created by an older copy of this module (dev-server HMR keeps // globalThis across reloads) may predate the failures map. holder.failures ??= new Map(); return holder; } /** * After a database init fails (migrations threw), skip re-attempting for * this long. Cold isolates still get one attempt each, so a transient * failure heals on its own; a persistently failing migration is retried at * most once per backoff window per isolate instead of on every request. */ const DB_INIT_FAILURE_BACKOFF_MS = 30_000; /** * Auto-seed runs at most once per isolate per database. Its lock + "done" set * live on globalThis (same bundler-duplication reasoning as the db cache) so a * reclaimed-and-rerun `create()` can't seed a second time concurrently. The * lock polls rather than sharing a promise, so it is safe to await across a * cancelled owner in workerd. */ const SEED_HOLDER_KEY = Symbol.for("emdash:seed-state"); interface SeedHolder { done: Set; lock: InitLock; } function getSeedHolder(): SeedHolder { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- globalThis symbol slot, written only below let holder = globalSymbolStore[SEED_HOLDER_KEY] as SeedHolder | undefined; if (!holder) { holder = { done: new Set(), lock: createInitLock() }; globalSymbolStore[SEED_HOLDER_KEY] = holder; } return holder; } const storageCache = new Map(); const sandboxedPluginCache = new Map(); /** * Per-tier sets of `${pluginId}:${version}` keys present in * `sandboxedPluginCache`. Used during sync to know which entries belong * to which install source so we can invalidate only what belongs to the * tier currently being synced. */ const marketplacePluginKeys = new Set(); const registryPluginKeys = new Set(); /** * Manifest metadata for runtime-installed sandboxed plugins (marketplace * and registry both). Keyed by `pluginId`; readers don't care which * source the plugin came from. Named `marketplace*` for legacy reasons. */ const marketplaceManifestCache = new Map< string, { id: string; version: string; admin?: { pages?: PluginAdminPage[]; widgets?: PluginDashboardWidget[]; settingsSchema?: Record; }; mcp?: PluginMcpManifestConfig; storage?: PluginManifest["storage"]; } >(); /** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */ const sandboxedRouteMetaCache = new Map>(); let sandboxRunner: SandboxRunner | null = null; export const MEDIA_USAGE_MAINTENANCE_QUERY_RESERVATIONS = Object.freeze({ entryWork: MEDIA_USAGE_WORK_PROCESSING_LIMITS.ordinaryStatementsPerJob, collectionDeletion: MEDIA_USAGE_COLLECTION_DELETION_LIMITS.maxQueriesPerTick, reconciliation: MEDIA_USAGE_RECONCILIATION_LIMITS.maxQueriesPerTick, maxClassQueries: Math.max( MEDIA_USAGE_WORK_PROCESSING_LIMITS.ordinaryStatementsPerJob, MEDIA_USAGE_COLLECTION_DELETION_LIMITS.maxQueriesPerTick, MEDIA_USAGE_RECONCILIATION_LIMITS.maxQueriesPerTick, ), eventCeiling: 40, }); export type MediaUsageMaintenanceTaskClass = | "entry_work" | "collection_deletion" | "reconciliation"; export type MediaUsageMaintenanceResult = | { outcome: "inactive" | "admission_closed"; taskClass: null; turn: null } | { outcome: "processed"; taskClass: MediaUsageMaintenanceTaskClass; turn: number }; async function runScheduledMediaUsageLane( db: Kysely, ): Promise { const queriesAlreadySpent = getRequestContext()?.metrics?.dbCount ?? 0; if ( queriesAlreadySpent + 1 + MEDIA_USAGE_MAINTENANCE_QUERY_RESERVATIONS.maxClassQueries > MEDIA_USAGE_MAINTENANCE_QUERY_RESERVATIONS.eventCeiling ) { return { outcome: "admission_closed", taskClass: null, turn: null }; } const activation = await db .updateTable("_emdash_media_usage_activation") .set({ media_usage_maintenance_turn: sql`(media_usage_maintenance_turn + 1) % 3`, }) .where("task_key", "=", "incremental_capture") .where("state", "=", "active") .returning("media_usage_maintenance_turn") .executeTakeFirst(); if (!activation) return { outcome: "inactive", taskClass: null, turn: null }; const turn = activation.media_usage_maintenance_turn; if (turn === 0) { await processDueMediaUsageWork(db); return { outcome: "processed", taskClass: "entry_work", turn }; } if (turn === 1) { await processDueMediaUsageCollectionDeletions(db); return { outcome: "processed", taskClass: "collection_deletion", turn }; } await processDueMediaUsageReconciliation(db); return { outcome: "processed", taskClass: "reconciliation", turn }; } /** * EmDashRuntime - singleton per worker */ export 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: Kysely; 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 { return new SchemaRegistry(this.db); } private _hooks!: HookPipeline; readonly config: EmDashConfig; readonly mediaProviders: Map; readonly mediaProviderEntries: MediaProviderEntry[]; readonly cronExecutor: CronExecutor | null; readonly email: EmailPipeline | null; private cronScheduler: CronScheduler | null; private enabledPlugins: Set; private pluginStates: Map; /** * 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 = createSingleFlightCache(); /** Current hook pipeline. Use the `hooks` getter for external access. */ get hooks(): HookPipeline { return this._hooks; } /** 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: ResolvedPlugin[]; /** Guards the once-per-process plugin storage-index sync. */ private storageIndexesSynced = false; /** Factory options for the hook pipeline context factory */ private pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; beforeContentWrite?: () => Promise; storage?: Storage; siteInfo?: { siteName?: string; siteUrl?: string; platformUrl?: string; locale?: string; trailingSlash?: "always" | "never" | "ignore"; }; }; /** Dependencies needed for exclusive hook resolution */ private runtimeDeps: RuntimeDependencies; /** Mutable ref for the cron invokeCronHook closure to read the current pipeline */ private pipelineRef!: { current: HookPipeline }; /** * 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 { const ctx = getRequestContext(); if (ctx?.db) { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- db in context is set by middleware with correct type return ctx.db as Kysely; } return this._db; } constructor(parts: EmDashRuntimeParts) { this._db = parts.db; this.storage = parts.storage; this.configuredPlugins = parts.configuredPlugins; this.sandboxedPlugins = parts.sandboxedPlugins; this.sandboxedPluginEntries = parts.sandboxedPluginEntries; this._hooks = parts.hooks; this.enabledPlugins = parts.enabledPlugins; this.pluginStates = parts.pluginStates; this.config = parts.config; this.mediaProviders = parts.mediaProviders; this.mediaProviderEntries = parts.mediaProviderEntries; this.cronExecutor = parts.cronExecutor; this.cronScheduler = parts.cronScheduler; this.email = parts.emailPipeline; this.allPipelinePlugins = parts.allPipelinePlugins; this.pipelineFactoryOptions = parts.pipelineFactoryOptions; this.runtimeDeps = parts.runtimeDeps; this.pipelineRef = parts.pipelineRef; } /** * Get the sandbox runner instance (for marketplace install/update) */ getSandboxRunner(): SandboxRunner | null { return sandboxRunner; } /** * 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 { return this.runtimeDeps.sandboxBypassed === true; } /** * Publish any content whose scheduled time has passed. * Returns the items promoted so callers can invalidate their cache tags. */ async publishScheduled(): Promise { return this.publishScheduledWithFence(); } private async publishScheduledWithFence( onPublished?: (refs: PublishedRef[]) => Promise, ): Promise { await assertMediaUsageActivationWriteAllowed(this.db); return publishDueContent(this.db, { publish: (collection, id, options) => this.handleContentPublish(collection, id, options), onPublished, }); } /** * 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. */ async runScheduledTasks( options: { onPublished?: (refs: PublishedRef[]) => Promise; } = {}, ): Promise<{ published: PublishedRef[] }> { if (this.cronExecutor) { try { await this.cronExecutor.tick(); } catch (error) { console.error("[cron] Tick failed:", error); } try { await this.cronExecutor.recoverStaleLocks(); } catch (error) { console.error("[cron] Stale lock recovery failed:", error); } } let published: PublishedRef[] = []; try { published = await this.publishScheduledWithFence(options.onPublished); } catch (error) { console.error("[scheduled-publish] Sweep failed:", error); } try { await runSystemCleanup(this.db, this.storage ?? undefined); } catch (error) { console.error("[cleanup] System cleanup failed:", error); } try { await this.syncPluginStorageIndexesOnce(); } catch (error) { console.error("[plugins] Storage index sync failed:", error); } // Never throws; no-op unless scheduled backups are enabled and due. await maybeRunScheduledBackup(this.db, this.storage ?? undefined); await recordSchedulerHeartbeatSafely(this.db); return { published }; } async runScheduledMediaUsageTasks(): Promise { return runScheduledMediaUsageLane(this.db); } /** * 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. */ async syncPluginStorageIndexesOnce(): Promise { if (this.storageIndexesSynced) return; this.storageIndexesSynced = true; // Sandboxed marketplace/registry plugins never join allPipelinePlugins; // their manifests are cached at bundle load. Without them, plugins // installed before this feature shipped would never get their indexes. await syncDeclaredStorageIndexes(this.db, [ ...this.allPipelinePlugins, ...marketplaceManifestCache.values(), ]); } /** * Stop the cron scheduler gracefully. * Call during worker shutdown or hot-reload. */ async stopCron(): Promise { if (this.cronScheduler) { await this.cronScheduler.stop(); } } /** * 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. */ async setPluginStatus(pluginId: string, status: "active" | "inactive"): Promise { this.pluginStates.set(pluginId, status); if (status === "active") { this.enabledPlugins.add(pluginId); await this.rebuildHookPipeline(); await this._hooks.runPluginActivate(pluginId); } else { // Fire deactivate on the current pipeline while the plugin is still in it await this._hooks.runPluginDeactivate(pluginId); this.enabledPlugins.delete(pluginId); await this.rebuildHookPipeline(); } } /** * 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 async rebuildHookPipeline(): Promise { const enabledList = this.allPipelinePlugins.filter((p) => this.enabledPlugins.has(p.id)); const newPipeline = createHookPipeline(enabledList, this.pipelineFactoryOptions); // Re-resolve exclusive hooks against the new pipeline await EmDashRuntime.resolveExclusiveHooks(newPipeline, this.db, this.runtimeDeps); // Carry over context factory options from the old pipeline so that // email, cron reschedule, and other wired-in options are preserved. // The old pipeline's contextFactoryOptions were built up incrementally // via setContextFactory calls during create(). We replay them here. if (this.email) { // db/getDb are already wired by createHookPipeline above (they live in // pipelineFactoryOptions), so the merge only adds emailPipeline. newPipeline.setContextFactory({ emailPipeline: this.email }); } newPipeline.setContextFactory({ // Plugin schedules remain database-backed when no in-process scheduler // exists; an external trigger is responsible for invoking due tasks. cronReschedule: () => this.cronScheduler?.reschedule(), }); // Update the email pipeline to use the new hook pipeline if (this.email) { this.email.setPipeline(newPipeline); } // Update the mutable ref so the cron closure dispatches through // the new pipeline without needing to reconstruct the CronExecutor. this.pipelineRef.current = newPipeline; this._hooks = newPipeline; } /** * 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 = Date.now(); /** * 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. */ async resyncPluginsIfStale(maxAgeMs = 30_000): Promise { const now = Date.now(); if (now - this.pluginSyncAt < maxAgeMs) return; this.pluginSyncAt = now; try { await this.syncMarketplacePlugins(); } catch (error) { console.error("[emdash] periodic plugin sync failed:", error); } } async syncMarketplacePlugins(): Promise { this.pluginSyncAt = Date.now(); if (!this.config.marketplace) return; // In sandbox bypass mode (sandbox: false), the noop runner reports // unavailable but we still want admin metadata for newly installed // marketplace plugins to refresh in-process. Hooks/routes still won't // execute (matches the cold-start bypass behavior), but Configure // links and admin pages appear immediately. if (this.runtimeDeps.sandboxBypassed) { await this.syncMarketplacePluginsBypassed(); return; } await this.syncSandboxedSourcePlugins("marketplace"); } /** * 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. */ async syncRegistryPlugins(): Promise { if (!this.config.experimental?.registry) return; await this.syncSandboxedSourcePlugins("registry"); } /** * 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 async syncSandboxedSourcePlugins(source: "marketplace" | "registry"): Promise { if (!this.storage) return; if (!sandboxRunner || !sandboxRunner.isAvailable()) return; const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys; try { const stateRepo = new PluginStateRepository(this.db); const states = source === "marketplace" ? await stateRepo.getMarketplacePlugins() : await stateRepo.getRegistryPlugins(); const desired = new Map(); for (const state of states) { this.pluginStates.set(state.pluginId, state.status); if (state.status === "active") { this.enabledPlugins.add(state.pluginId); } else { this.enabledPlugins.delete(state.pluginId); } if (state.status !== "active") continue; // Marketplace plugins use `marketplaceVersion` when present; // registry plugins always use `version`. const desiredVersion = source === "marketplace" ? (state.marketplaceVersion ?? state.version) : state.version; desired.set(state.pluginId, desiredVersion); } // Remove uninstalled or no-longer-active plugins from memory. const keysToRemove: string[] = []; for (const key of keySet) { const [pluginId] = key.split(":"); if (!pluginId) continue; const desiredVersion = desired.get(pluginId); if (desiredVersion && key === `${pluginId}:${desiredVersion}`) continue; keysToRemove.push(key); } for (const key of keysToRemove) { const [pluginId] = key.split(":"); if (!pluginId) continue; const desiredVersion = desired.get(pluginId); if (!desiredVersion) { this.pluginStates.delete(pluginId); this.enabledPlugins.delete(pluginId); } const existing = sandboxedPluginCache.get(key); if (existing) { try { await existing.terminate(); } catch (error) { console.warn(`EmDash: Failed to terminate sandboxed plugin ${key}:`, error); } } sandboxedPluginCache.delete(key); this.sandboxedPlugins.delete(key); keySet.delete(key); if (pluginId) { sandboxedRouteMetaCache.delete(pluginId); marketplaceManifestCache.delete(pluginId); } } // Load newly active plugins. for (const [pluginId, version] of desired) { const key = `${pluginId}:${version}`; if (sandboxedPluginCache.has(key)) { keySet.add(key); continue; } const bundle = await loadBundleFromR2(this.storage, pluginId, version, source); if (!bundle) { console.warn(`EmDash: ${source} plugin ${pluginId}@${version} not found in R2`); continue; } const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode); sandboxedPluginCache.set(key, loaded); this.sandboxedPlugins.set(key, loaded); keySet.add(key); // Cache manifest admin config for getManifest() marketplaceManifestCache.set(pluginId, { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, mcp: bundle.manifest.mcp, storage: bundle.manifest.storage, }); // Cache route metadata from manifest for auth decisions if (bundle.manifest.routes.length > 0) { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); routeMetaMap.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { sandboxedRouteMetaCache.delete(pluginId); } } } catch (error) { console.error(`EmDash: Failed to sync ${source} plugins:`, error); } } /** * Remove a plugin from the in-memory pipeline lists by ID. * Mutates allPipelinePlugins and configuredPlugins in place. */ private removePluginFromLists(pluginId: string): void { const allIdx = this.allPipelinePlugins.findIndex((p) => p.id === pluginId); if (allIdx !== -1) this.allPipelinePlugins.splice(allIdx, 1); const configIdx = this.configuredPlugins.findIndex((p) => p.id === pluginId); if (configIdx !== -1) this.configuredPlugins.splice(configIdx, 1); } /** * 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 async syncMarketplacePluginsBypassed(): Promise { if (!this.storage) return; try { const stateRepo = new PluginStateRepository(this.db); const marketplaceStates = await stateRepo.getMarketplacePlugins(); const desired = new Map(); for (const state of marketplaceStates) { this.pluginStates.set(state.pluginId, state.status); if (state.status === "active") { this.enabledPlugins.add(state.pluginId); } else { this.enabledPlugins.delete(state.pluginId); } if (state.status !== "active") continue; desired.set(state.pluginId, state.marketplaceVersion ?? state.version); } // Drop metadata for plugins no longer active. const toRemove: string[] = []; for (const pluginId of marketplaceManifestCache.keys()) { if (!desired.has(pluginId)) toRemove.push(pluginId); } for (const pluginId of toRemove) { // Fire plugin:deactivate hook before removal const resolved = this.allPipelinePlugins.find((p) => p.id === pluginId); if (resolved) { try { const deactivateHook = resolved.hooks?.["plugin:deactivate"]; if (deactivateHook) { const handler = typeof deactivateHook === "function" ? deactivateHook : deactivateHook.handler; if (typeof handler === "function") { // Sandbox-bypass cleanup: the plugin context isn't constructable // here (no DB binding, no media, etc.), but well-behaved // deactivate hooks should be no-op safe. If a hook does require // ctx, it throws and the surrounding catch logs it. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- best-effort cleanup; see comment above await handler({ pluginId }, {} as never); } } } catch (err) { console.warn(`[emdash] plugin:deactivate hook failed for ${pluginId}:`, err); } } marketplaceManifestCache.delete(pluginId); sandboxedRouteMetaCache.delete(pluginId); // Remove from pipeline lists too (mutate in place since the // arrays are readonly references but mutable contents) this.removePluginFromLists(pluginId); this.enabledPlugins.delete(pluginId); } // Load plugin code, adapt as trusted plugins, and add to pipeline lists const { adaptSandboxEntry } = await import("./plugins/adapt-sandbox-entry.js"); const newPlugins: ResolvedPlugin[] = []; for (const [pluginId, version] of desired) { const bundle = await loadBundleFromR2(this.storage, pluginId, version); if (!bundle) { console.warn(`EmDash: Marketplace plugin ${pluginId}@${version} not found in R2`); continue; } marketplaceManifestCache.set(pluginId, { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, mcp: bundle.manifest.mcp, storage: bundle.manifest.storage, }); if (bundle.manifest.routes.length > 0) { const routeMetaMap = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); routeMetaMap.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(pluginId, routeMetaMap); } else { sandboxedRouteMetaCache.delete(pluginId); } // Skip if already in the pipeline at this version const existing = this.allPipelinePlugins.find((p) => p.id === pluginId); if (existing && existing.version === bundle.manifest.version) continue; // Remove any older version if (existing) { this.removePluginFromLists(pluginId); } try { const dataUrl = `data:text/javascript;base64,${Buffer.from(bundle.backendCode).toString("base64")}`; // Dynamic data: import returns `any` from a base64-encoded module. // We trust the bundle to be shaped like a plugin (built by plugin-cli); // adaptSandboxEntry then validates fields it cares about. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- dynamic module from trusted bundle const pluginModule = (await import(/* @vite-ignore */ dataUrl)) as Record< string, unknown >; const pluginDef = (pluginModule.default ?? pluginModule) as Parameters< typeof adaptSandboxEntry >[0]; const adapted = adaptSandboxEntry(pluginDef, { id: bundle.manifest.id, version: bundle.manifest.version, entrypoint: "", capabilities: bundle.manifest.capabilities ?? [], allowedHosts: bundle.manifest.allowedHosts ?? [], // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- adaptSandboxEntry copies storage through storage: (bundle.manifest.storage ?? {}) as never, adminPages: bundle.manifest.admin?.pages, adminWidgets: bundle.manifest.admin?.widgets?.map((w) => ({ id: w.id, title: w.title, size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), settingsSchema: bundle.manifest.admin?.settingsSchema, }); newPlugins.push(adapted); this.allPipelinePlugins.push(adapted); this.configuredPlugins.push(adapted); this.enabledPlugins.add(adapted.id); } catch (error) { console.error( `EmDash: Failed to load marketplace plugin ${pluginId}@${version} in-process:`, error, ); } } // If anything changed, rebuild the hook pipeline so new/removed // plugins take effect immediately without a server restart. if (toRemove.length > 0 || newPlugins.length > 0) { await this.rebuildHookPipeline(); } } catch (error) { console.error("EmDash: Failed to sync marketplace plugins (bypass):", error); } } /** * Create and initialize the runtime */ static async create( deps: RuntimeDependencies, timings?: Array<{ name: string; dur: number; desc?: string }>, ): Promise { // Helper: time a phase and push into the shared timings array when // provided. Uses performance.now() — monotonic across async boundaries. // No-op when `timings` wasn't passed (preserves backwards compatibility // with callers that don't care about per-phase breakdown). const phase = async (name: string, desc: string, fn: () => Promise): Promise => { if (!timings) return fn(); const t0 = performance.now(); try { return await fn(); } finally { timings.push({ name, dur: performance.now() - t0, desc }); } }; // Initialize the database and enforce its configured migration policy. const db = await phase("rt.db", "DB init + migration policy", () => EmDashRuntime.getDatabase(deps), ); // Resolver for the live connection, mirroring the `get db()` getter // below (which can't be used here — the runtime instance doesn't exist // yet). Long-lived subsystems built during create() (cron executor, // plugin context factory, media providers) capture this resolver rather // than the `db` snapshot, so a connection-backed adapter (Postgres over // Hyperdrive) serves their queries from the current request/event-scoped // connection in ALS instead of the per-isolate singleton — whose socket // belongs to an earlier request and would trip workerd's cross-request // I/O guard. Stateless adapters (D1, Node SQLite) set no ALS db on most // paths, so this falls back to the singleton: unchanged behavior. const resolveDb = (): Kysely => { const ctx = getRequestContext(); // eslint-disable-next-line typescript/no-unsafe-type-assertion -- ALS db is typed unknown to avoid a circular import; middleware always sets a Kysely return (ctx?.db as Kysely | undefined) ?? db; }; // Validate EMDASH_ENCRYPTION_KEY once here so a malformed value // surfaces in startup logs instead of as request-time 500s. The key // itself is not yet consumed (a follow-up PR adds plugin-secret // encryption); validating early just guards against silent // misconfiguration. await phase("rt.secrets", "Validate encryption key", () => validateEncryptionKeyAtStartup()); // FTS verify/repair is deferred off the cold-start hot path. // See EmDashRuntime.ensureSearchHealthy(). // Initialize storage (sync) const storage = EmDashRuntime.getStorage(deps); let pluginStates: Map = new Map(); const configuredLocales: string[] = virtualConfig?.i18n?.locales ?? getI18nConfig()?.locales ?? []; const localeCasingRepairVersion = getLocaleCasingRepairVersion(configuredLocales); let storedLocaleCasingRepairVersion: string | undefined; let siteInfo: | { siteName?: string; siteUrl?: string; locale?: string; trailingSlash?: "always" | "never" | "ignore"; } | undefined; // "Already set up" by default so a read failure (e.g. tables absent on a // pre-migration db) skips seeding rather than seeding a half-built db. let seedGate = { collectionCount: 1, setupDone: true }; // Seeding must only touch the configured singleton, never a borrowed // per-request db (playground / DO preview) or the loader-fallback db. const reqCtx = getRequestContext(); const ownsConfiguredDb = !!deps.config.database && !(reqCtx?.dbIsIsolated && reqCtx.db); // Run the init reads on a coalescing connection so the concurrent // same-turn reads flush as one batch() round trip. The singleton can't: // its plain SqliteAdapter reports supportsMultipleConnections=false, so // Kysely's connection mutex serializes concurrent reads into N round // trips. The connection is per-init and discarded — the long-lived // singleton must never coalesce or one request's reads could land in // another's batch. Backends without a coalescing dialect (Node/SQLite) // fall back to the singleton, where serialization costs nothing. let readDb = db; let readDbDisposable: Kysely | undefined; const disposeReadDb = async () => { const disposable = readDbDisposable; readDbDisposable = undefined; if (!disposable) return; try { await disposable.destroy(); } catch { // Non-fatal — the underlying binding is shared and needs no teardown. } }; if (ownsConfiguredDb && deps.createCoalescingDialect && deps.config.database) { try { const dialect = deps.createCoalescingDialect(deps.config.database.config); if (dialect) { readDb = new Kysely({ dialect, log: kyselyLogOption() }); readDbDisposable = readDb; } } catch { readDb = db; } } const optionsRepo = new OptionsRepository(readDb); let missingManualSchemaError: unknown; const captureMissingManualSchema = (error: unknown) => { if ((deps.migrationMode ?? "auto") === "manual" && isMissingTableError(error)) { missingManualSchemaError ??= error; } }; const readSiteInfo = async () => { const siteOpts = await optionsRepo.getMany([ "emdash:site_title", "emdash:site_url", "emdash:locale", "custom_domain:default_url", LOCALE_CASING_REPAIR_OPTION, ]); storedLocaleCasingRepairVersion = siteOpts.get(LOCALE_CASING_REPAIR_OPTION); return { siteName: siteOpts.get("emdash:site_title") ?? undefined, siteUrl: siteOpts.get("emdash:site_url") ?? undefined, // The platform origin a control plane gave this site; plugins use it // for platform-hosted URLs (previews) that must not follow a custom domain. platformUrl: siteOpts.get("custom_domain:default_url") ?? undefined, locale: siteOpts.get("emdash:locale") ?? undefined, // trailingSlash is a build-time Astro routing decision, not a // user-editable setting, so it comes from the Astro config // (virtual:emdash/config), not the options table. trailingSlash: virtualConfig?.trailingSlash, }; }; const coldStartReads: Array> = [ phase("rt.plugins", "Plugin states", async () => { try { const states = await readDb .selectFrom("_plugin_state") .select(["plugin_id", "status"]) .execute(); pluginStates = new Map(states.map((s) => [s.plugin_id, s.status])); } catch (error) { captureMissingManualSchema(error); // _plugin_state may not exist yet on a pre-migration db. } }), phase("rt.site", "Site info options", async () => { try { siteInfo = await readSiteInfo(); } catch (error) { captureMissingManualSchema(error); // options may not exist yet on a pre-migration db. } }), ]; if (ownsConfiguredDb) { coldStartReads.push( phase("rt.seedcheck", "Auto-seed gate", async () => { try { const [collectionCount, setupOption] = await Promise.all([ readDb .selectFrom("_emdash_collections") .select((eb) => eb.fn.countAll().as("count")) .executeTakeFirstOrThrow(), readDb .selectFrom("options") .select("value") .where("name", "=", "emdash:setup_complete") .executeTakeFirst(), ]); const setupDone = (() => { try { return !!setupOption && JSON.parse(setupOption.value) === true; } catch { return false; } })(); seedGate = { collectionCount: collectionCount.count, setupDone }; } catch (error) { captureMissingManualSchema(error); // Leave the "already set up" default so a read failure never // triggers a seed onto a half-built db. } }), ); } await Promise.all(coldStartReads); if (ownsConfiguredDb) { // The public frontend's service account + API token (Settings → frontend token). await phase("rt.frontend", "Frontend service account", async () => { try { await ensureFrontendServiceAccount(db); } catch (error) { captureMissingManualSchema(error); // authz tables may not exist yet on a pre-migration db; retried next boot. } }); } if (missingManualSchemaError) { await disposeReadDb(); throw missingManualSchemaError; } if ( localeCasingRepairVersion && (configuredLocales.some( (locale) => locale.includes("-") || locale !== locale.toLowerCase(), ) || storedLocaleCasingRepairVersion !== undefined) && storedLocaleCasingRepairVersion !== localeCasingRepairVersion ) { await phase("rt.locale", "Repair locale casing", async () => { await repairLocaleCasing(db, configuredLocales); await new OptionsRepository(db).set(LOCALE_CASING_REPAIR_OPTION, localeCasingRepairVersion); }); } // Auto-seed the default schema for a first load that skipped the setup // wizard (the wizard and dev-bypass apply seeds explicitly). Run under a // per-isolate lock keyed by the configured db so a reclaimed-and-rerun // create() can't apply the seed a second time concurrently. if (seedGate.collectionCount === 0 && !seedGate.setupDone) { const seedKey = deps.config.database?.entrypoint ?? "default"; const seedHolder = getSeedHolder(); try { await initWithLock( seedHolder.lock, () => (seedHolder.done.has(seedKey) ? true : undefined), async () => { const { applySeed } = await import("./seed/apply.js"); const { loadSeed } = await import("./seed/load.js"); const { validateSeed } = await import("./seed/validate.js"); const seed = await loadSeed(); const validation = validateSeed(seed); if (validation.valid) { await applySeed(db, seed, { onConflict: "skip" }); console.log("Auto-seeded default collections"); } seedHolder.done.add(seedKey); return true; }, { deadlineMs: DB_INIT_DEADLINE_MS, anchor: (promise) => after(() => promise) }, ); // The site-info read ran before the seed wrote its defaults, so // refresh the snapshot the plugin context sees. try { siteInfo = await readSiteInfo(); } catch { // Non-fatal — plugin context falls back to undefined fields. } } catch { // Non-fatal — a failed seed (e.g. missing seed module) leaves the // site un-seeded; a later request retries. } } // The read connection is single-use; everything below uses the singleton. await disposeReadDb(); const enabledPlugins = new Set(); for (const plugin of deps.plugins) { const status = pluginStates.get(plugin.id); if (status === undefined || status === "active") { enabledPlugins.add(plugin.id); } } // Build the full list of pipeline-eligible plugins: all configured // plugins (regardless of current enabled status) plus built-in plugins. // rebuildHookPipeline() filters this to only enabled plugins. const allPipelinePlugins: ResolvedPlugin[] = [...deps.plugins]; // Collected bypassed plugins (sandbox: false escape hatch). // These need to be added to BOTH the pipeline (for hooks) AND the // configuredPlugins list (for route dispatch). const bypassedPluginsList: ResolvedPlugin[] = []; // In dev mode, register a built-in console email provider. // It participates in exclusive hook resolution like any other plugin — // auto-selected when it's the sole provider, overridden when a real one is configured. // Gated by import.meta.env.DEV to prevent silent email loss in production. if (import.meta.env.DEV) { try { const devConsolePlugin = definePlugin({ id: DEV_CONSOLE_EMAIL_PLUGIN_ID, version: "0.0.0", capabilities: ["hooks.email-transport:register"], hooks: { "email:deliver": { exclusive: true, handler: devConsoleEmailDeliver, }, }, }); allPipelinePlugins.push(devConsolePlugin); // Built-in plugins are always enabled enabledPlugins.add(devConsolePlugin.id); } catch (error) { console.warn("[email] Failed to register dev console email provider:", error); } } // Register built-in default comment moderator. // Always present — auto-selected as the sole comment:moderate provider // unless a plugin (e.g. AI moderation) provides its own. try { const defaultModeratorPlugin = definePlugin({ id: DEFAULT_COMMENT_MODERATOR_PLUGIN_ID, version: "0.0.0", capabilities: ["users:read"], hooks: { "comment:moderate": { exclusive: true, handler: defaultCommentModerate, }, }, }); allPipelinePlugins.push(defaultModeratorPlugin); // Built-in plugins are always enabled enabledPlugins.add(defaultModeratorPlugin.id); } catch (error) { console.warn("[comments] Failed to register default moderator:", error); } // sandbox: false escape hatch - load sandboxed plugin entries in-process // as trusted plugins (no isolation) so they participate in the hook pipeline. // Block this on Cloudflare Workers where dynamic import(dataUrl) is not // available and running untrusted code in-process is a security risk. if (deps.sandboxBypassed && deps.sandboxedPluginEntries.length > 0) { const isCfWorkers = typeof navigator !== "undefined" && typeof navigator.userAgent === "string" && navigator.userAgent.includes("Cloudflare-Workers"); if (isCfWorkers) { throw new Error( "sandbox: false is not supported in Cloudflare Workers. " + "Remove the sandbox: false option or use the Cloudflare sandbox runner.", ); } console.info( "EmDash: Sandbox disabled (sandbox: false). " + "Sandboxed plugins will run in-process without isolation.", ); const bypassedPlugins = await EmDashRuntime.loadBypassedPlugins(deps.sandboxedPluginEntries); for (const plugin of bypassedPlugins) { allPipelinePlugins.push(plugin); bypassedPluginsList.push(plugin); // Respect plugin state: only enable if active or no record exists. // Plugins an admin previously disabled should stay disabled. const status = pluginStates.get(plugin.id); if (status === undefined || status === "active") { enabledPlugins.add(plugin.id); } } } // In bypass mode, also load marketplace plugins from R2 as trusted // in-process plugins BEFORE pipeline creation. They need to be in the // pipeline to participate in hook dispatch. if (deps.sandboxBypassed && deps.config.marketplace && storage) { const marketplaceBypassed = await EmDashRuntime.loadMarketplacePluginsBypassed(db, storage); for (const plugin of marketplaceBypassed) { allPipelinePlugins.push(plugin); bypassedPluginsList.push(plugin); const status = pluginStates.get(plugin.id); if (status === undefined || status === "active") { enabledPlugins.add(plugin.id); } } } // Filter to currently enabled plugins for the initial pipeline const enabledPluginList = allPipelinePlugins.filter((p) => enabledPlugins.has(p.id)); // Create hook pipeline. getDb travels here (not just via the email // setContextFactory call below) so it survives rebuildHookPipeline(), // which reconstructs the factory from pipelineFactoryOptions. Without it, // toggling a plugin on an email-less deployment would silently revert // plugin contexts to the singleton db — re-breaking connection-backed // adapters. See #1622. const pipelineFactoryOptions = { db, getDb: resolveDb, beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(resolveDb()), storage: storage ?? undefined, siteInfo, }; const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions); // Load sandboxed plugins (build-time, sandbox runner path) const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () => EmDashRuntime.loadSandboxedPlugins(deps, db, storage, siteInfo), ); // Cold-start: load marketplace- and registry-installed plugins from // site R2 via the sandbox runner. The two tiers only depend on the // sandbox phase above, not on each other, so when both are enabled // they run concurrently instead of paying two sequential loads. // In bypass mode marketplace plugins were already handled above. const installedTierPhases: Promise[] = []; if (deps.config.marketplace && storage && !deps.sandboxBypassed) { installedTierPhases.push( phase("rt.market", "Marketplace plugins", () => EmDashRuntime.loadInstalledSandboxedPlugins( "marketplace", db, storage, deps, sandboxedPlugins, siteInfo, ), ), ); } // Cold-start: load registry-installed plugins from site R2 if (deps.config.experimental?.registry && storage) { installedTierPhases.push( phase("rt.registry", "Registry plugins", () => EmDashRuntime.loadInstalledSandboxedPlugins( "registry", db, storage, deps, sandboxedPlugins, siteInfo, ), ), ); } if (installedTierPhases.length > 0) { await Promise.all(installedTierPhases); } // Initialize media providers const mediaProviders = new Map(); const mediaProviderEntries = deps.mediaProviderEntries ?? []; const providerContext: MediaProviderContext = { db, storage, getDb: resolveDb }; for (const entry of mediaProviderEntries) { try { const provider = entry.createProvider(providerContext); mediaProviders.set(entry.id, provider); } catch (error) { console.warn(`Failed to initialize media provider "${entry.id}":`, error); } } // Resolve exclusive hooks — auto-select providers and sync with DB await phase("rt.hooks", "Exclusive hook resolution", () => EmDashRuntime.resolveExclusiveHooks(pipeline, db, deps), ); // ── Email pipeline ─────────────────────────────────────────────── // The email pipeline orchestrates beforeSend → deliver → afterSend. // The dev console provider was registered above and will be auto-selected // by resolveExclusiveHooks if it's the sole email:deliver provider. const emailPipeline = new EmailPipeline(pipeline); // Wire email send into sandbox runner (created earlier but without // email pipeline since it didn't exist yet) if (sandboxRunner) { sandboxRunner.setEmailSend((message, pluginId) => emailPipeline.send(message, pluginId)); } // ── Cron system ────────────────────────────────────────────────── // Create executor with a hook dispatch function that uses the pipeline. // The callback reads from a mutable ref so that rebuildHookPipeline() // can swap the pipeline without reconstructing the CronExecutor. const pipelineRef = { current: pipeline }; const invokeCronHook: InvokeCronHookFn = async (pluginId, event) => { const result = await pipelineRef.current.invokeCronHook(pluginId, event); if (!result.success && result.error) { throw result.error; } }; // Wire email pipeline into context factory (independent of cron — // must not be inside the cron try/catch or ctx.email breaks when cron fails). // db/getDb were already set via pipelineFactoryOptions above; merge only // adds emailPipeline. pipeline.setContextFactory({ emailPipeline }); let cronExecutor: CronExecutor | null = null; let cronScheduler: CronScheduler | null = null; // Populated with the constructed runtime just before this method returns, // so the timer scheduler's cleanup can route scheduled publishing through // the runtime wrapper (firing content:afterPublish hooks). The first tick // is ≥1s out, well after the synchronous assignment below. const runtimeRef: { current: EmDashRuntime | null } = { current: null }; await phase("rt.cron", "Cron init (recovery deferred post-response)", async () => { try { cronExecutor = new CronExecutor(resolveDb, invokeCronHook); // Plugin schedules are always database-backed. On long-lived runtimes this // callback also wakes the timer; on Cloudflare the external Cron Trigger // drives execution, so rescheduling is intentionally a no-op. pipeline.setContextFactory({ cronReschedule: () => cronScheduler?.reschedule(), }); // Recover stale locks from previous crashes. Pure bookkeeping // against the _emdash_cron_tasks table — no request needs the // result — so we defer it past the response via after(). On // Cloudflare this goes into waitUntil (extending the worker // lifetime); on Node it's fire-and-forget (the process stays // up anyway). Saves one cold-start write per D1 isolate. const executorForRecovery = cronExecutor; after(async () => { try { const recovered = await executorForRecovery.recoverStaleLocks(); if (recovered > 0) { console.log(`[cron] Recovered ${recovered} stale task lock(s)`); } } catch (error) { // Keep the `[cron]` prefix so a failure is easy to trace back // rather than surfacing as a generic deferred-task error. console.error("[cron] Failed to recover stale task locks:", error); } }); // The platform decides whether a long-lived timer heartbeat exists. // `createScheduler` is injected by the generated virtual:emdash/scheduler // module: a NodeCronScheduler factory on Node/Bun, or null on serverless // adapters (e.g. Cloudflare) where the Worker's `scheduled()` handler // drives runScheduledTasks() instead. No adapter check lives here. if (deps.createScheduler) { const scheduler = deps.createScheduler(cronExecutor); cronScheduler = scheduler; const runMediaUsageMaintenance = async () => { const runtime = runtimeRef.current; if (runtime) { await runtime.runScheduledMediaUsageTasks(); } else { await runScheduledMediaUsageLane(db); } }; // Run scheduled publishing and system cleanup alongside each tick. // Pass storage so cleanupPendingUploads can delete orphaned files. scheduler.setSystemCleanup(async () => { try { // Route through the runtime so content:afterPublish hooks fire. // Falls back to the raw handler if (improbably) the tick beats // the post-construction ref assignment. const runtime = runtimeRef.current; if (runtime) { await runtime.publishScheduled(); } else { await assertMediaUsageActivationWriteAllowed(db); await publishDueContent(db); } } catch (error) { console.error("[scheduled-publish] Sweep failed:", error); } try { await runSystemCleanup(db, storage ?? undefined); } catch (error) { // Non-fatal -- individual cleanup failures are already logged // by runSystemCleanup. This catches unexpected errors. console.error("[cleanup] System cleanup failed:", error); } try { await runtimeRef.current?.syncPluginStorageIndexesOnce(); } catch (error) { console.error("[plugins] Storage index sync failed:", error); } // Never throws; no-op unless scheduled backups are enabled and due. await maybeRunScheduledBackup(db, storage ?? undefined); await recordSchedulerHeartbeatSafely(db); if (!scheduler.setMediaUsageMaintenance) { try { await runMediaUsageMaintenance(); } catch (error) { console.error("[media-usage] Scheduled maintenance failed:", error); } } }); scheduler.setMediaUsageMaintenance?.(runMediaUsageMaintenance); // start() is void on the timer scheduler but the interface // allows a promise (alarm-backed schedulers); we don't block on it. void scheduler.start(); } } catch (error) { console.warn("[cron] Failed to initialize cron system:", error); // Non-fatal — CMS works without cron } }); const runtime = new EmDashRuntime({ db, storage, // Include bypassed sandboxed plugins in configuredPlugins so route // dispatch can find them under sandbox: false (they're treated as // trusted plugins for the duration of the bypass). configuredPlugins: [...deps.plugins, ...bypassedPluginsList], sandboxedPlugins, sandboxedPluginEntries: deps.sandboxedPluginEntries, hooks: pipeline, enabledPlugins, pluginStates, config: deps.config, mediaProviders, mediaProviderEntries, cronExecutor, cronScheduler, emailPipeline, allPipelinePlugins, pipelineFactoryOptions, runtimeDeps: deps, pipelineRef, }); // Hand the constructed instance to the scheduler-cleanup closure so the // timer-driven sweep can fire publish hooks (see runtimeRef above). runtimeRef.current = runtime; return runtime; } /** * Get a media provider by ID */ getMediaProvider(providerId: string): MediaProvider | undefined { return this.mediaProviders.get(providerId); } /** * Get all media provider entries (for admin UI) */ getMediaProviderList(): Array<{ id: string; name: string; icon?: string; capabilities: MediaProviderCapabilities; }> { return this.mediaProviderEntries.map((e) => ({ id: e.id, name: e.name, icon: e.icon, capabilities: e.capabilities, })); } /** * Get or create database instance */ private static async getDatabase(deps: RuntimeDependencies): Promise> { // Only use the per-request `ctx.db` when it's an isolated instance // (playground / DO preview). Plain D1 Sessions set `ctx.db` on every // anonymous request — if we captured one of those session-bound // Kyselys into the cached runtime, every request would accidentally // share one request's session. The configured `deps.createDialect` // path gives us a fresh singleton instead. const ctx = getRequestContext(); if (ctx?.dbIsIsolated && ctx.db) { // eslint-disable-next-line typescript/no-unsafe-type-assertion -- db in context is typed as unknown to avoid circular deps return ctx.db as Kysely; } const dbConfig = deps.config.database; // If no database configured in integration, try to get from loader if (!dbConfig) { try { return await getDb(); } catch { throw new Error( "EmDash database not configured. Either configure database in astro.config.mjs or use emdashLoader in live.config.ts", ); } } const cacheKey = dbConfig.entrypoint; // Waiters poll the cache rather than sharing the initializing request's // promise: if the request that owns the init is cancelled mid-await // (e.g. client disconnect during cold migrations), a shared promise // never settles — and the owner's `finally` that would clear it never // runs — deadlocking every later request in the isolate. Prevention: // the in-flight init is anchored via after()/waitUntil so a cancelled // owner's init still completes and populates the cache. Net: a stale // lock is reclaimed after a deadline. const holder = getDbHolder(); const throwIfBackingOff = () => { const failure = holder.failures.get(cacheKey); if (!failure) return; if (Date.now() - failure.at < DB_INIT_FAILURE_BACKOFF_MS) { throw new Error( `Database initialization is backing off after a recent migration failure: ${failure.message}`, ); } // Expired — drop it so the map only ever holds active backoffs. holder.failures.delete(cacheKey); }; throwIfBackingOff(); return initWithLock( holder.lock, () => holder.cache.get(cacheKey), async (isCurrentClaim) => { // Re-check under the lock: a request that entered the wait loop // before the failure was recorded must not immediately re-run // the failing migration when the lock frees up. throwIfBackingOff(); const dialect = deps.createDialect(dbConfig.config); const db = new Kysely({ dialect, log: kyselyLogOption() }); try { await enforceRuntimeMigrationPolicy(db, deps.migrationMode ?? "auto"); } catch (error) { // Timing out behind another instance's in-flight migrations // is not a failure of OUR migration — the holder may just be // slow. Don't back off for it: the next request waits again // and init recovers the moment the holder finishes. if ( !(error instanceof ConcurrentMigrationTimeoutError) && !(error instanceof PendingMigrationsError) ) { holder.failures.set(cacheKey, { at: Date.now(), message: error instanceof Error ? error.message : String(error), }); } // Every attempt builds a fresh dialect/pool; close it or each // failed init leaks its connection(s) (#1744 observed these // piling up as idle Postgres connections). await db.destroy().catch(() => {}); throw error; } holder.failures.delete(cacheKey); // Note: legacy installs may carry a stray `emdash:manifest_cache` // row in the options table from versions that persisted a JSON // manifest. The runtime no longer reads or writes it. We do not // proactively delete it: the row is a few hundred bytes of dead // weight and is never on the read path, whereas a one-shot // cleanup-flag check costs an extra `options.get()` on every // isolate cold boot forever. Cheaper to leave it. // This returns a migrated but possibly unseeded db; create() runs // the seed gate and applies the seed, batched with its other init // reads. // Publish only while still the current owner: a reclaimed slow // init must not flip the cached Kysely identity back after the // reclaimer has published its own. The unpublished instance is // still returned and fully valid for the request that built it. if (isCurrentClaim()) { holder.cache.set(cacheKey, db); } return db; }, { deadlineMs: DB_INIT_DEADLINE_MS, anchor: (promise) => after(() => promise), }, ); } /** * Get or create storage instance */ private static getStorage(deps: RuntimeDependencies): Storage | null { const storageConfig = deps.config.storage; if (!storageConfig || !deps.createStorage) { return null; } const cacheKey = storageConfig.entrypoint; const cached = storageCache.get(cacheKey); if (cached) { return cached; } const storage = deps.createStorage(storageConfig.config); storageCache.set(cacheKey, storage); return storage; } /** * 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 async loadBypassedPlugins( entries: SandboxedPluginEntry[], ): Promise { const { adaptSandboxEntry } = await import("./plugins/adapt-sandbox-entry.js"); const plugins: ResolvedPlugin[] = []; for (const entry of entries) { try { const dataUrl = `data:text/javascript;base64,${Buffer.from(entry.code).toString("base64")}`; // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- dynamic module from trusted bundle (built by plugin-cli); adaptSandboxEntry validates required fields. const pluginModule = (await import(/* @vite-ignore */ dataUrl)) as Record; const pluginDef = (pluginModule.default ?? pluginModule) as Parameters< typeof adaptSandboxEntry >[0]; // PluginDescriptor.storage's TypeScript type is narrower than what // adaptSandboxEntry actually accepts at runtime — it copies indexes // through to PluginStorageConfig which supports composite indexes // (string[][]). Pass the raw entry.storage with a structural cast // to preserve composite index declarations. // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- adaptSandboxEntry copies storage through to PluginStorageConfig which supports composite indexes // Preserve admin metadata so plugin-management APIs can derive // hasAdminPages / hasDashboardWidgets correctly. Without this, // the admin UI hides Configure links and dashboard widgets for // bypassed plugins even though they declared them. // SandboxedPluginEntry uses looser types than PluginDescriptor // (label?, size: string), so coerce to the descriptor shape. const adminPages = entry.adminPages?.map((p) => ({ path: p.path, label: p.label ?? p.path, icon: p.icon, })); const adminWidgets: | Array<{ id: string; title?: string; size?: "full" | "half" | "third"; }> | undefined = entry.adminWidgets?.map((w) => { const size: "full" | "half" | "third" | undefined = w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined; return { id: w.id, title: w.title, size }; }); const resolved = adaptSandboxEntry(pluginDef, { id: entry.id, version: entry.version, entrypoint: "", capabilities: entry.capabilities, allowedHosts: entry.allowedHosts, // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- adaptSandboxEntry copies storage through storage: entry.storage as never, adminPages, adminWidgets, settingsSchema: entry.settingsSchema, portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, }); plugins.push(resolved); console.log( `EmDash: Loaded plugin ${entry.id}:${entry.version} in-process (sandbox bypassed)`, ); } catch (error) { console.error(`EmDash: Failed to load sandboxed plugin ${entry.id} in-process:`, error); } } return plugins; } /** * Load sandboxed plugins using SandboxRunner */ private static async loadSandboxedPlugins( deps: RuntimeDependencies, db: Kysely, mediaStorage?: Storage | null, siteInfo?: { siteName?: string; siteUrl?: string; platformUrl?: string; locale?: string; trailingSlash?: "always" | "never" | "ignore"; }, ): Promise> { // Return cached plugins if already loaded if (sandboxedPluginCache.size > 0) { return sandboxedPluginCache; } // Check if sandboxing is enabled if (!deps.sandboxEnabled) { return sandboxedPluginCache; } // Create sandbox runner if not exists if (!sandboxRunner && deps.createSandboxRunner) { sandboxRunner = deps.createSandboxRunner( createSandboxRunnerOptions( { db, beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: mediaStorage ? { upload: (opts) => mediaStorage.upload({ key: opts.key, body: opts.body, contentType: opts.contentType, }), delete: (key) => mediaStorage.delete(key), } : undefined, }, siteInfo, ), ); } if (!sandboxRunner) { return sandboxedPluginCache; } // Check if the runner is actually available (has required bindings). // Warn regardless of whether there are plugins to load, so operators // see the issue even if no marketplace plugins are installed yet. if (!sandboxRunner.isAvailable()) { console.warn( "EmDash: Plugin sandbox is configured but not available on this platform. " + "Sandboxed plugins will not be loaded. " + "If using @premium-cms/sandbox-workerd/sandbox, ensure workerd is installed.", ); return sandboxedPluginCache; } if (deps.sandboxedPluginEntries.length === 0) { return sandboxedPluginCache; } // sandbox: false escape hatch is handled separately (before pipeline // creation) via loadBypassedPlugins. If we somehow reach here with the // flag set, just return — the plugins are already in the trusted pipeline. if (deps.sandboxBypassed) { return sandboxedPluginCache; } // Load each sandboxed plugin via sandbox runner for (const entry of deps.sandboxedPluginEntries) { const pluginKey = `${entry.id}:${entry.version}`; if (sandboxedPluginCache.has(pluginKey)) { continue; } try { // Build manifest from entry's declared config const manifest: PluginManifest = { id: entry.id, version: entry.version, capabilities: entry.capabilities ?? [], allowedHosts: entry.allowedHosts ?? [], storage: entry.storage ?? {}, hooks: entry.hooks ?? [], routes: entry.routes ?? [], admin: {}, mcp: entry.mcp, }; const plugin = await sandboxRunner.load(manifest, entry.code); sandboxedPluginCache.set(pluginKey, plugin); console.log( `EmDash: Loaded sandboxed plugin ${pluginKey} with capabilities: [${manifest.capabilities.join(", ")}]`, ); if (manifest.routes.length > 0) { const routeMetaMap = new Map(); for (const routeEntry of manifest.routes) { const normalized = normalizeManifestRoute(routeEntry); routeMetaMap.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(entry.id, routeMetaMap); } else { sandboxedRouteMetaCache.delete(entry.id); } } catch (error) { console.error(`EmDash: Failed to load sandboxed plugin ${entry.id}:`, error); } } return sandboxedPluginCache; } /** * 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 async loadInstalledSandboxedPlugins( source: "marketplace" | "registry", db: Kysely, storage: Storage, deps: RuntimeDependencies, cache: Map, siteInfo?: { siteName?: string; siteUrl?: string; platformUrl?: string; locale?: string; trailingSlash?: "always" | "never" | "ignore"; }, ): Promise { // Ensure sandbox runner exists with media storage wired up. // (storage here is the media Storage adapter from the runtime.) if (!sandboxRunner && deps.createSandboxRunner) { sandboxRunner = deps.createSandboxRunner( createSandboxRunnerOptions( { db, beforeContentWrite: () => assertMediaUsageActivationWriteAllowed(db), mediaStorage: { upload: (opts) => storage.upload({ key: opts.key, body: opts.body, contentType: opts.contentType, }), delete: (key) => storage.delete(key), }, }, siteInfo, ), ); } // In sandbox bypass mode, marketplace plugins are loaded in-process // BEFORE pipeline creation by EmDashRuntime.create(). Skip here. if (deps.sandboxBypassed) return; if (!sandboxRunner || !sandboxRunner.isAvailable()) { return; } const keySet = source === "marketplace" ? marketplacePluginKeys : registryPluginKeys; try { const stateRepo = new PluginStateRepository(db); const plugins = source === "marketplace" ? await stateRepo.getMarketplacePlugins() : await stateRepo.getRegistryPlugins(); for (const plugin of plugins) { if (plugin.status !== "active") continue; // Marketplace plugins record the live version in // `marketplaceVersion`; registry plugins use `version` directly. const version = source === "marketplace" ? (plugin.marketplaceVersion ?? plugin.version) : plugin.version; const pluginKey = `${plugin.pluginId}:${version}`; // Skip if already loaded (shouldn't happen, but guard) if (cache.has(pluginKey)) continue; try { const bundle = await loadBundleFromR2(storage, plugin.pluginId, version, source); if (!bundle) { console.warn(`EmDash: ${source} plugin ${plugin.pluginId}@${version} not found in R2`); continue; } const loaded = await sandboxRunner.load(bundle.manifest, bundle.backendCode); cache.set(pluginKey, loaded); keySet.add(pluginKey); // Cache manifest admin config for getManifest() marketplaceManifestCache.set(plugin.pluginId, { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, mcp: bundle.manifest.mcp, storage: bundle.manifest.storage, }); // Cache route metadata from manifest for auth decisions if (bundle.manifest.routes.length > 0) { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); routeMeta.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } console.log( `EmDash: Loaded ${source} plugin ${pluginKey} with capabilities: [${bundle.manifest.capabilities.join(", ")}]`, ); } catch (error) { console.error(`EmDash: Failed to load ${source} plugin ${plugin.pluginId}:`, error); } } } catch { // _plugin_state table may not exist yet (pre-migration) } } /** * 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 async loadMarketplacePluginsBypassed( db: Kysely, storage: Storage, ): Promise { const resolved: ResolvedPlugin[] = []; try { const stateRepo = new PluginStateRepository(db); const marketplacePlugins = await stateRepo.getMarketplacePlugins(); if (marketplacePlugins.length === 0) return resolved; console.info( "EmDash: Sandbox disabled (sandbox: false). " + "Marketplace plugins will run in-process without isolation.", ); const { adaptSandboxEntry } = await import("./plugins/adapt-sandbox-entry.js"); for (const plugin of marketplacePlugins) { if (plugin.status !== "active") continue; const version = plugin.marketplaceVersion ?? plugin.version; try { const bundle = await loadBundleFromR2(storage, plugin.pluginId, version); if (!bundle) { console.warn( `EmDash: Marketplace plugin ${plugin.pluginId}@${version} not found in R2`, ); continue; } // Cache manifest and route metadata for admin UI and route auth marketplaceManifestCache.set(plugin.pluginId, { id: bundle.manifest.id, version: bundle.manifest.version, admin: bundle.manifest.admin, mcp: bundle.manifest.mcp, storage: bundle.manifest.storage, }); if (bundle.manifest.routes.length > 0) { const routeMeta = new Map(); for (const entry of bundle.manifest.routes) { const normalized = normalizeManifestRoute(entry); routeMeta.set(normalized.name, buildRouteMeta(normalized)); } sandboxedRouteMetaCache.set(plugin.pluginId, routeMeta); } // Evaluate the bundled ESM and adapt it as a trusted plugin const dataUrl = `data:text/javascript;base64,${Buffer.from(bundle.backendCode).toString("base64")}`; // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- dynamic module from trusted bundle (built by plugin-cli); adaptSandboxEntry validates required fields. const pluginModule = (await import(/* @vite-ignore */ dataUrl)) as Record< string, unknown >; const pluginDef = (pluginModule.default ?? pluginModule) as Parameters< typeof adaptSandboxEntry >[0]; const adapted = adaptSandboxEntry(pluginDef, { id: bundle.manifest.id, version: bundle.manifest.version, entrypoint: "", capabilities: bundle.manifest.capabilities ?? [], allowedHosts: bundle.manifest.allowedHosts ?? [], // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- adaptSandboxEntry copies storage through storage: (bundle.manifest.storage ?? {}) as never, adminPages: bundle.manifest.admin?.pages, adminWidgets: bundle.manifest.admin?.widgets?.map((w) => ({ id: w.id, title: w.title, size: w.size === "full" || w.size === "half" || w.size === "third" ? w.size : undefined, })), settingsSchema: bundle.manifest.admin?.settingsSchema, }); resolved.push(adapted); console.log( `EmDash: Loaded marketplace plugin ${plugin.pluginId}@${version} in-process (sandbox bypassed)`, ); } catch (error) { console.error( `EmDash: Failed to load marketplace plugin ${plugin.pluginId} in-process:`, error, ); } } } catch { // _plugin_state table may not exist yet } return resolved; } /** * 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 async resolveExclusiveHooks( pipeline: HookPipeline, db: Kysely, deps: RuntimeDependencies, ): Promise { const exclusiveHookNames = pipeline.getRegisteredExclusiveHooks(); if (exclusiveHookNames.length === 0) return; let optionsRepo: OptionsRepository; try { optionsRepo = new OptionsRepository(db); } catch { return; // Options table may not exist yet } // Build preferred hints from sandboxed plugin entries const preferredHints = new Map(); for (const entry of deps.sandboxedPluginEntries) { if (entry.preferred && entry.preferred.length > 0) { preferredHints.set(entry.id, entry.preferred); } } // The pipeline was created from only enabled plugins, so all providers // in it are active. The isActive check always returns true. await resolveExclusiveHooksShared({ pipeline, isActive: () => true, getOption: (key) => optionsRepo.get(key), getOptions: (keys) => optionsRepo.getMany(keys), setOption: (key, value) => optionsRepo.set(key, value), deleteOption: async (key) => { await optionsRepo.delete(key); }, preferredHints, }); } // ========================================================================= // Manifest // ========================================================================= /** * 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 { return requestCached("emdash:manifest", () => this._buildManifest()); } /** * 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 async _buildManifest(): Promise { // Build collections from database. // Use this.db (ALS-aware getter) so playground mode picks up the // per-session DO database instead of the hardcoded singleton. const manifestCollections: Record = {}; try { const registry = new SchemaRegistry(this.db); const dbCollections = await registry.listCollectionsWithFields(); for (const collection of dbCollections) { const fields: Record< string, { kind: string; label?: string; required?: boolean; widget?: string; // Two shapes: legacy enum-style `[{ value, label }]` for select widgets, // or arbitrary `Record` for plugin field widgets that // need per-field config (e.g. a checkbox grid receiving its column defs). options?: Array<{ value: string; label: string }> | Record; id?: string; validation?: Record; readOnly?: boolean; visibility?: "create" | "edit"; optionsUrl?: string; } > = {}; for (const field of collection.fields) { const entry: (typeof fields)[string] = { kind: FIELD_TYPE_TO_KIND[field.type] ?? "string", label: field.label, required: field.required, }; // Generic editor-UX flags carried in the field's options bag: // `readOnly` renders the input disabled; `visibility` restricts the // field to the create or edit form. Promoted to top-level manifest // props so they survive the select-enum options overwrite below. const fieldOpts = field.options && !Array.isArray(field.options) ? (field.options as Record) : undefined; if (fieldOpts?.readOnly === true) entry.readOnly = true; if (fieldOpts?.visibility === "create" || fieldOpts?.visibility === "edit") entry.visibility = fieldOpts.visibility; // `optionsUrl`: a string field rendered as a select whose choices the // admin fetches from this (same-origin) URL — `{ options: [{value,label}] }`. if (typeof fieldOpts?.optionsUrl === "string" && fieldOpts.optionsUrl.startsWith("/")) entry.optionsUrl = fieldOpts.optionsUrl; // Always include the field's database ID so the admin can forward it // to upload/media-list API calls for MIME allowlist widening. entry.id = field.id; if (field.widget) entry.widget = field.widget; // Plugin field widgets read their per-field config from `field.options`, // which the seed schema types as `Record`. Pass it // through to the manifest so plugin widgets in the admin SPA receive it. if (field.options) { entry.options = field.options; } // Legacy: select/multiSelect enum options live on `field.validation.options`. // Wins over `field.options` to preserve existing behavior for enum widgets. if (field.validation?.options) { entry.options = field.validation.options.map((v) => ({ value: v, label: v.charAt(0).toUpperCase() + v.slice(1), })); } // Include full validation for repeater fields (subFields, minItems, maxItems) // and for file/image fields (allowedMimeTypes). if ( (field.type === "repeater" || field.type === "file" || field.type === "image") && field.validation ) { entry.validation = { ...field.validation }; } fields[field.slug] = entry; } const configuredListColumns = collection.admin?.listColumns ?? []; const fieldTypes = new Map(collection.fields.map((field) => [field.slug, field.type])); const listColumns: string[] = []; for (const slug of configuredListColumns) { if (listColumns.includes(slug)) continue; const fieldType = fieldTypes.get(slug); if (!fieldType || !LIST_COLUMN_FIELD_TYPES.has(fieldType)) { console.warn( `EmDash: Ignoring unsupported or unknown list column "${slug}" in collection "${collection.slug}".`, ); continue; } if (listColumns.length >= MAX_COLLECTION_LIST_COLUMNS) { console.warn( `EmDash: Collection "${collection.slug}" declares more than ${MAX_COLLECTION_LIST_COLUMNS} list columns; extra columns are ignored.`, ); break; } listColumns.push(slug); } manifestCollections[collection.slug] = { label: collection.label, labelSingular: collection.labelSingular || collection.label, // Git-backed entries have no drafts/revisions/scheduling: the repo // history is the history, and every save is a commit. supports: collection.storage === "git" ? (collection.supports || []).filter((s) => s === "seo" || s === "search" || s === "preview") : collection.supports || [], ...(collection.storage === "git" ? { storage: "git" as const } : {}), hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, routable: collection.routable !== false, titleField: collection.titleField, dateField: collection.dateField, ...(collection.hidden ? { hidden: true } : {}), listColumns: listColumns.length > 0 ? listColumns : undefined, fields, }; } } catch (error) { console.debug("EmDash: Could not load database collections:", error); } // Build plugins manifest const manifestPlugins: Record< string, { version?: string; enabled?: boolean; sandboxed?: boolean; adminMode?: "react" | "blocks" | "none"; adminPages?: Array<{ path: string; label?: string; icon?: string }>; dashboardWidgets?: Array<{ id: string; title?: string; size?: string; }>; portableTextBlocks?: Array<{ type: string; label: string; icon?: string; description?: string; placeholder?: string; fields?: Element[]; category?: string; }>; fieldWidgets?: Array<{ name: string; label: string; fieldTypes: string[]; elements?: Element[]; }>; } > = {}; for (const plugin of this.configuredPlugins) { const status = this.pluginStates.get(plugin.id); const enabled = status === undefined || status === "active"; // Determine admin mode: has admin entry → react, has pages/widgets → blocks, else none const hasAdminEntry = !!plugin.admin?.entry; const hasAdminPages = (plugin.admin?.pages?.length ?? 0) > 0; const hasWidgets = (plugin.admin?.widgets?.length ?? 0) > 0; let adminMode: "react" | "blocks" | "none" = "none"; if (hasAdminEntry) { adminMode = "react"; } else if (hasAdminPages || hasWidgets) { adminMode = "blocks"; } manifestPlugins[plugin.id] = { version: plugin.version, enabled, adminMode, adminPages: plugin.admin?.pages ?? [], dashboardWidgets: plugin.admin?.widgets ?? [], portableTextBlocks: plugin.admin?.portableTextBlocks, fieldWidgets: plugin.admin?.fieldWidgets, }; } // Add sandboxed plugins (use entries for admin config) for (const entry of this.sandboxedPluginEntries) { const status = this.pluginStates.get(entry.id); const enabled = status === undefined || status === "active"; const hasAdminPages = (entry.adminPages?.length ?? 0) > 0; const hasWidgets = (entry.adminWidgets?.length ?? 0) > 0; manifestPlugins[entry.id] = { version: entry.version, enabled, sandboxed: true, // `adminMode` reflects only admin pages/widgets. A plugin can // contribute portableTextBlocks/fieldWidgets with adminMode "none" — // the admin reads those from the manifest regardless, so don't gate // admin contributions on `adminMode`. adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", adminPages: entry.adminPages ?? [], dashboardWidgets: entry.adminWidgets ?? [], portableTextBlocks: entry.portableTextBlocks, fieldWidgets: entry.fieldWidgets, }; } // Add marketplace-installed plugins (dynamically loaded from R2) for (const [pluginId, meta] of marketplaceManifestCache) { // Skip if already included from build-time config if (manifestPlugins[pluginId]) continue; const status = this.pluginStates.get(pluginId); const enabled = status === "active"; const pages = meta.admin?.pages; const widgets = meta.admin?.widgets; const hasAdminPages = (pages?.length ?? 0) > 0; const hasWidgets = (widgets?.length ?? 0) > 0; manifestPlugins[pluginId] = { version: meta.version, enabled, sandboxed: true, adminMode: hasAdminPages || hasWidgets ? "blocks" : "none", adminPages: pages ?? [], dashboardWidgets: widgets ?? [], }; } // Build taxonomies from database let manifestTaxonomies: Array<{ id: string; name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; locale: string; translationGroup: string; }> = []; let taxonomyDefinitionLocales: string[] = []; try { const rows = await this.db .selectFrom("_emdash_taxonomy_defs") .selectAll() .orderBy("name") .execute(); taxonomyDefinitionLocales = rows.map((row) => row.locale); manifestTaxonomies = rows.map((row) => ({ id: row.id, name: row.name, label: row.label, labelSingular: row.label_singular ?? undefined, hierarchical: row.hierarchical === 1, collections: parseStringArray(row.collections).toSorted(), locale: row.locale, translationGroup: row.translation_group ?? row.id, })); } catch (error) { console.debug("EmDash: Could not load taxonomy definitions:", error); } try { const configuredLocales = virtualConfig?.i18n?.locales ?? getI18nConfig()?.locales ?? []; await warnAboutUnconfiguredTaxonomyLocales( this.db, configuredLocales, taxonomyDefinitionLocales, ); } catch (error) { console.warn("[i18n] taxonomy locale diagnostic failed:", error); } // Build manifest hash const manifestHash = await hashString( JSON.stringify(manifestCollections) + JSON.stringify(manifestPlugins) + JSON.stringify(manifestTaxonomies), ); // Determine auth mode const authMode = getAuthMode(this.config); const authModeValue = authMode.type === "external" ? authMode.providerType : "passkey"; // Include i18n config if enabled (read from virtual module to avoid SSR module singleton mismatch) const i18nConfig = virtualConfig?.i18n ?? getI18nConfig(); const i18n = i18nConfig && i18nConfig.locales && i18nConfig.locales.length > 1 ? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales } : undefined; // Normalize the experimental registry config for browser consumption. // Validation errors here surface as 500s from the manifest endpoint // rather than being silently dropped -- a misconfigured registry // should be loud, not invisible. const registry = normalizeRegistryConfig(this.config.experimental?.registry) ?? undefined; return { version: VERSION, commit: COMMIT, astroVersion: this.config.astroVersion, hash: manifestHash, collections: manifestCollections, plugins: manifestPlugins, taxonomies: manifestTaxonomies, authMode: authModeValue, i18n, contentLocale: { defaultLocale: i18nConfig?.defaultLocale ?? "en", implicit: i18nConfig === null, }, marketplace: !!this.config.marketplace, registry, }; } /** * 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). */ async ensureSearchHealthy(): Promise { // Non-SQLite has no FTS to verify; the check is a cheap synchronous // branch, no need to cache it. if (!isSqlite(this._db)) return; try { await singleFlightCached( this._searchHealthCache, async () => { try { const ftsManager = new FTSManager(this._db); const repaired = await ftsManager.verifyAndRepairAll(); if (repaired > 0) { console.log(`Repaired ${repaired} corrupted FTS index(es)`); } } catch { // FTS tables may not exist yet (pre-setup). Non-fatal — cache // the "checked" state regardless so we don't re-scan. } }, { anchor: (promise) => after(() => promise), ownerTimeoutMs: 30_000 }, ); } catch { // This check is best-effort and must never fail the calling request. // The inner body already swallows verify errors; this guards the // outer failure modes (owner timeout, waiter give-up) so a slow FTS // scan degrades to "unverified", not a 500 on admin/search routes. } } // ========================================================================= // Content Handlers // ========================================================================= /** * 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 async gitStoreFor(collection: string): Promise { const def = await this.schemaRegistry.getCollection(collection); if (!def || def.storage !== "git") return null; const conn = await gitConnection(this.db); if (!conn) { throw new GitStoreError( "This collection is stored in git — connect GitHub in Settings → General first.", "NOT_CONNECTED", 409, ); } return new GitContentStore(conn, collection); } private gitError(error: unknown, code: string, message: string) { if (error instanceof GitStoreError) { return { success: false as const, error: { code: error.code, message: error.message } }; } console.error(message, error); return { success: false as const, error: { code, message } }; } async 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; }, ) { try { const git = await this.gitStoreFor(collection); if (git) { let items = await git.list(); if (params.status) items = items.filter((i) => i.status === params.status); if (params.q) { const q = params.q.toLowerCase(); items = items.filter((i) => JSON.stringify(i.data).toLowerCase().includes(q) || (i.slug ?? "").includes(q)); } return { success: true as const, data: { items, nextCursor: undefined, total: items.length } }; } } catch (error) { return this.gitError(error, "CONTENT_LIST_ERROR", "Failed to list content"); } return handleContentList(this.db, collection, params); } async handleContentAuthors(collection: string) { return handleContentAuthors(this.db, collection); } async handleContentGet(collection: string, id: string, locale?: string) { try { const git = await this.gitStoreFor(collection); if (git) { const item = await git.get(id); if (!item) return { success: false as const, error: { code: "NOT_FOUND", message: `Content item not found: ${id}` } }; return { success: true as const, data: { item, _rev: item.updatedAt } }; } } catch (error) { return this.gitError(error, "CONTENT_GET_ERROR", "Failed to get content"); } const result = await handleContentGet(this.db, collection, id, locale); return this.hydrateDraftData(result); } async handleContentGetIncludingTrashed(collection: string, id: string, locale?: string) { const result = await handleContentGetIncludingTrashed(this.db, collection, id, locale); return this.hydrateDraftData(result); } /** * 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 async hydrateDraftData(result: T): Promise { if (!result || typeof result !== "object") return result; // eslint-disable-next-line typescript/no-unsafe-type-assertion -- shape probed below const r = result as { success?: boolean; data?: { item?: Record }; }; if (!r.success || !r.data?.item) return result; const item = r.data.item; const draftRevisionId = typeof item.draftRevisionId === "string" ? item.draftRevisionId : null; if (!draftRevisionId) return result; try { const revision = await new RevisionRepository(this.db).findById(draftRevisionId); if (!revision) return result; const liveData = item.data && typeof item.data === "object" ? // eslint-disable-next-line typescript/no-unsafe-type-assertion -- narrowed to object above (item.data as Record) : {}; // Strip leading-underscore keys (`_slug`, `_rev`, etc.) from the // revision data — those are handler-internal markers and don't // belong in the surfaced `data` field. Match syncDataColumns at // content.ts:~1119. const revisionData: Record = {}; for (const [key, value] of Object.entries(revision.data)) { if (!key.startsWith("_")) revisionData[key] = value; } const mergedData = { ...liveData, ...revisionData }; // Return a clone rather than mutating in place. The response // object isn't retained by the runtime today, but a future // request-cache layer would observe stale-after-mutation bugs; // cloning closes that footgun. // `r.data` was narrowed to `{ item?: ... }` at the top of this // method; spread its other keys (e.g. `_rev`) alongside the // hydrated item without going back through `unknown`. return { ...result, // eslint-disable-next-line typescript/no-unsafe-type-assertion -- shape preserved; result has been narrowed to the {success,data:{item}} envelope data: { ...r.data, item: { ...item, data: mergedData, liveData }, }, }; } catch (error) { // Non-fatal — fall back to the unhydrated response. Log so the // failure isn't completely silent (the response will look stale // to the caller but no error is raised). console.error("[emdash] draft hydration failed:", error); return result; } } async 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; }, ) { try { const git = await this.gitStoreFor(collection); if (git) { const item = await git.create({ slug: body.slug, status: body.status, locale: body.locale, data: body.data }); return { success: true as const, data: { item, _rev: item.updatedAt } }; } } catch (error) { return this.gitError(error, "CONTENT_CREATE_ERROR", "Failed to create content"); } // Run beforeSave hooks (trusted plugins) let processedData = body.data; if (this.hooks.hasHooks("content:beforeSave")) { const hookResult = await this.hooks.runContentBeforeSave(body.data, collection, true); processedData = hookResult.content; } // Run beforeSave hooks (sandboxed plugins) processedData = await this.runSandboxedBeforeSave(processedData, collection, true); // Normalize media fields (fill dimensions, storageKey, etc.) processedData = await this.normalizeMediaFields(collection, processedData); // Validate against the collection schema. Hook output is validated // rather than `body.data` so plugins that mutate field values can't // sneak invalid data past. const { validateContentData } = await import("./api/handlers/validation.js"); const validation = await validateContentData(this.db, collection, processedData, { partial: false, }); if (!validation.ok) { return { success: false as const, error: validation.error, }; } // Create the content const result = await handleContentCreate(this.db, collection, { ...body, data: processedData, authorId: body.authorId, bylines: body.bylines, }); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterSave hooks (fire-and-forget) if (result.success && result.data) { this.runAfterSaveHooks(contentItemToRecord(result.data.item), collection, true); } return result; } async 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; }, ) { try { const git = await this.gitStoreFor(collection); if (git) { const item = await git.update(id, { slug: body.slug, status: body.status, data: body.data }); return { success: true as const, data: { item, _rev: item.updatedAt } }; } } catch (error) { return this.gitError(error, "CONTENT_UPDATE_ERROR", "Failed to update content"); } // Resolve slug → ID if needed (before any lookups) const repo = new ContentRepository(this.db); const resolvedItem = await repo.findByIdOrSlug(collection, id, body.locale); const resolvedId = resolvedItem?.id ?? id; // Validate _rev early — before draft revision writes which modify updated_at. // After validation, strip _rev so the handler doesn't double-check against // the now-modified timestamp. if (body._rev) { if (!resolvedItem) { return { success: false as const, error: { code: "NOT_FOUND", message: `Content item not found: ${id}` }, }; } const revCheck = validateRev(body._rev, resolvedItem); if (!revCheck.valid) { return { success: false as const, error: { code: "CONFLICT", message: revCheck.message }, }; } } const { _rev: _discardedRev, ...bodyWithoutRev } = body; // Run beforeSave hooks if data is provided let processedData = bodyWithoutRev.data; if (bodyWithoutRev.data) { if (this.hooks.hasHooks("content:beforeSave")) { const hookResult = await this.hooks.runContentBeforeSave( bodyWithoutRev.data, collection, false, ); processedData = hookResult.content; } // Run sandboxed beforeSave hooks processedData = await this.runSandboxedBeforeSave(processedData!, collection, false); // Normalize media fields (fill dimensions, storageKey, etc.) processedData = await this.normalizeMediaFields(collection, processedData); // Validate field-level shape BEFORE the draft-revision write so // invalid updates can't silently land in revision history. const { validateContentData } = await import("./api/handlers/validation.js"); const validation = await validateContentData(this.db, collection, processedData, { partial: true, }); if (!validation.ok) { return { success: false as const, error: validation.error, }; } } // Draft-aware revision handling (if collection supports revisions) // Content table columns = published data (never written by saves). // Draft data lives only in the revisions table. let usesDraftRevisions = false; let draftStorageChanged = false; if (processedData) { const collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection); if (collectionInfo?.supports?.includes("revisions")) { usesDraftRevisions = true; const revisionRepo = new RevisionRepository(this.db); let existing = await repo.findById(collection, resolvedId); for (let attempt = 0; existing && attempt < MAX_DRAFT_STAGE_ATTEMPTS; attempt++) { let baseData: Record; if (existing.draftRevisionId) { const draftRevision = await revisionRepo.findById(existing.draftRevisionId); baseData = draftRevision?.data ?? existing.data; } else { baseData = existing.data; } const mergedData = { ...baseData, ...processedData }; if (bodyWithoutRev.slug !== undefined) { mergedData._slug = bodyWithoutRev.slug; } const revision = await revisionRepo.create({ collection, entryId: resolvedId, data: mergedData, authorId: bodyWithoutRev.authorId ?? undefined, }); let staged: boolean; try { staged = await repo.replaceDraftRevision(collection, resolvedId, revision.id, existing); } catch (error) { try { await revisionRepo.deleteIfUnreferenced(collection, resolvedId, revision.id); } catch (cleanupError) { console.error( `[emdash] Failed to clean up unstaged revision ${revision.id}:`, cleanupError, ); } throw error; } if (!staged) { try { await revisionRepo.deleteIfUnreferenced(collection, resolvedId, revision.id); } catch (cleanupError) { console.error( `[emdash] Failed to clean up unstaged revision ${revision.id}:`, cleanupError, ); } if (body._rev || attempt === MAX_DRAFT_STAGE_ATTEMPTS - 1) { const error = new ContentMutationConflictError(); return { success: false as const, error: { code: "CONFLICT", message: error.message }, }; } existing = await repo.findById(collection, resolvedId); continue; } draftStorageChanged = true; if (bodyWithoutRev.skipRevision && existing.draftRevisionId) { try { await revisionRepo.deleteIfUnreferenced( collection, resolvedId, existing.draftRevisionId, ); } catch (error) { console.error( `[emdash] Failed to clean up superseded revision ${existing.draftRevisionId}:`, error, ); } } else { after(async () => { try { await revisionRepo.pruneQueuedEntry(collection, resolvedId, revision.id, 50); } catch (error) { console.error( `[revisions] Failed to prune revisions for ${collection}/${resolvedId}:`, error, ); } }); } break; } } } // Public HTML comes from live columns / SEO / taxonomies, not draft revisions. const liveMetaTouched = Object.entries(bodyWithoutRev).some( ([key, value]) => value !== undefined && !DRAFT_ONLY_UPDATE_KEYS.has(key), ); // Update the content table: // - If collection uses draft revisions: only update metadata (no data fields, no slug) // - Otherwise: update everything as before const result = usesDraftRevisions && !liveMetaTouched ? await handleContentGet(this.db, collection, resolvedId) : await handleContentUpdate(this.db, collection, resolvedId, { ...bodyWithoutRev, data: usesDraftRevisions ? undefined : processedData, slug: usesDraftRevisions ? undefined : bodyWithoutRev.slug, authorId: bodyWithoutRev.authorId, bylines: bodyWithoutRev.bylines, }); const liveContentChanged = usesDraftRevisions ? liveMetaTouched : Boolean(processedData || bodyWithoutRev.slug !== undefined || liveMetaTouched); // Hydrate draft data BEFORE firing afterSave hooks so the hook sees // the same effective data the response surfaces — for revision- // supporting collections, that's the just-saved draft, not the live // columns. const hydrated = await this.hydrateDraftData(result); if (hydrated.success && hydrated.data) { const contentIdsToRefresh = [resolvedId]; if (!usesDraftRevisions && processedData) { try { contentIdsToRefresh.push( ...(await findNonTranslatableSiblingContentIds( this.db, collection, resolvedId, hydrated.data.item.translationGroup, processedData, )), ); } catch (error) { console.error( `[media-usage] Failed to discover synced i18n siblings for ${collection}/${resolvedId}:`, error, ); try { await markContentMediaUsageCollectionStale( this.db, collection, "CONTENT_USAGE_REFRESH_ERROR", ); } catch (staleError) { console.error(`[media-usage] Failed to mark ${collection} stale:`, staleError); } } } await this.refreshContentUsageAfterSuccessfulWrite(collection, contentIdsToRefresh); } else if (draftStorageChanged) { try { await markContentMediaUsageCollectionStale(this.db, collection, "CONTENT_USAGE_STALE"); } catch (error) { console.error(`[media-usage] Failed to mark ${collection} stale:`, error); } } // Run afterSave hooks (fire-and-forget) if (hydrated.success && hydrated.data) { this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false); } if (hydrated.success) { return { ...hydrated, liveContentChanged }; } return hydrated; } async handleContentDelete(collection: string, id: string) { try { const git = await this.gitStoreFor(collection); if (git) { const removed = await git.remove(id); if (!removed) return { success: false as const, error: { code: "NOT_FOUND", message: `Content item not found: ${id}` } }; return { success: true as const, data: { deleted: true } }; } } catch (error) { return this.gitError(error, "CONTENT_DELETE_ERROR", "Failed to delete content"); } // Run beforeDelete hooks (trusted plugins) if (this.hooks.hasHooks("content:beforeDelete")) { const { allowed } = await this.hooks.runContentBeforeDelete(id, collection); if (!allowed) { return { success: false, error: { code: "DELETE_BLOCKED", message: "Delete blocked by plugin hook", }, }; } } // Run sandboxed beforeDelete hooks const sandboxAllowed = await this.runSandboxedBeforeDelete(id, collection); if (!sandboxAllowed) { return { success: false, error: { code: "DELETE_BLOCKED", message: "Delete blocked by sandboxed plugin hook", }, }; } // Delete the content const result = await handleContentDelete(this.db, collection, id); if (result.success) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.id]); } // Run afterDelete hooks (deferred past the response via after()) if (result.success) { this.runAfterDeleteHooks(id, collection, false); } return result; } // ========================================================================= // Trash Handlers // ========================================================================= async handleContentListTrashed( collection: string, params: { cursor?: string; limit?: number } = {}, ) { return handleContentListTrashed(this.db, collection, params); } async handleContentRestore(collection: string, id: string) { const result = await handleContentRestore(this.db, collection, id); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterRestore hooks (fire-and-forget) if (result.success) { this.runAfterRestoreHooks(contentItemToRecord(result.data.item), collection); } return result; } async handleContentPermanentDelete(collection: string, id: string) { const result = await handleContentPermanentDelete(this.db, collection, id); if (result.success) { await this.deleteContentUsageAfterSuccessfulPermanentDelete(collection, result.data.id); } // Run afterDelete hooks so plugins (e.g. AI Search) can clean up if (result.success) { this.runAfterDeleteHooks(id, collection, true); } return result; } async handleContentCountTrashed(collection: string) { return handleContentCountTrashed(this.db, collection); } async handleContentDuplicate(collection: string, id: string, authorId?: string) { const result = await handleContentDuplicate(this.db, collection, id, authorId); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } return result; } // ========================================================================= // Publishing & Scheduling Handlers // ========================================================================= async handleContentPublish( collection: string, id: string, options: { publishedAt?: string; requireScheduledDue?: boolean; expectedScheduledAt?: string; } = {}, ) { try { const git = await this.gitStoreFor(collection); if (git) { const item = await git.update(id, { status: "published" }); return { success: true as const, data: { item } }; } } catch (error) { return this.gitError(error, "CONTENT_PUBLISH_ERROR", "Failed to publish content"); } const result = await handleContentPublish(this.db, collection, id, options); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterPublish hooks (fire-and-forget) if (result.success && result.data) { this.runAfterPublishHooks(contentItemToRecord(result.data.item), collection); } return result; } async handleContentUnpublish(collection: string, id: string) { try { const git = await this.gitStoreFor(collection); if (git) { const item = await git.update(id, { status: "draft" }); return { success: true as const, data: { item } }; } } catch (error) { return this.gitError(error, "CONTENT_UNPUBLISH_ERROR", "Failed to unpublish content"); } const result = await handleContentUnpublish(this.db, collection, id); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterUnpublish hooks (deferred past the response via after()) if (result.success && result.data) { this.runAfterUnpublishHooks(contentItemToRecord(result.data.item), collection); } return result; } async handleContentSchedule(collection: string, id: string, scheduledAt: string) { const result = await handleContentSchedule(this.db, collection, id, scheduledAt); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterSchedule hooks (fire-and-forget) if (result.success && result.data) { this.runAfterScheduleHooks(contentItemToRecord(result.data.item), collection); } return result; } async handleContentUnschedule(collection: string, id: string) { const result = await handleContentUnschedule(this.db, collection, id); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } // Run afterUnschedule hooks (fire-and-forget) if (result.success && result.data) { this.runAfterUnscheduleHooks(contentItemToRecord(result.data.item), collection); } return result; } async handleContentCountScheduled(collection: string) { return handleContentCountScheduled(this.db, collection); } async handleContentDiscardDraft(collection: string, id: string) { const result = await handleContentDiscardDraft(this.db, collection, id); if (result.success && result.data) { await this.refreshContentUsageAfterSuccessfulWrite(collection, [result.data.item.id]); } return result; } async handleContentCompare(collection: string, id: string) { return handleContentCompare(this.db, collection, id); } async handleContentTranslations(collection: string, id: string) { return handleContentTranslations(this.db, collection, id); } // ========================================================================= // Media Handlers // ========================================================================= async handleMediaList(params: { cursor?: string; limit?: number; mimeType?: string | readonly string[]; q?: string; }) { return handleMediaList(this.db, params); } async handleMediaGet(id: string) { return handleMediaGet(this.db, id); } async handleMediaCreate(input: { filename: string; mimeType: string; size?: number; width?: number; height?: number; storageKey: string; contentHash?: string; blurhash?: string; dominantColor?: string; authorId?: string; }) { // Run beforeUpload hooks let processedInput = input; if (this.hooks.hasHooks("media:beforeUpload")) { const hookResult = await this.hooks.runMediaBeforeUpload({ name: input.filename, type: input.mimeType, size: input.size || 0, }); processedInput = { ...input, filename: hookResult.file.name, mimeType: hookResult.file.type, size: hookResult.file.size, }; } // Create the media record const result = await handleMediaCreate(this.db, processedInput); // Run afterUpload hooks (fire-and-forget) if (result.success && this.hooks.hasHooks("media:afterUpload")) { const item = result.data.item; const mediaItem: MediaItem = { id: item.id, filename: item.filename, mimeType: item.mimeType, size: item.size, url: `/media/${item.id}/${item.filename}`, createdAt: item.createdAt, }; this.hooks .runMediaAfterUpload(mediaItem) .catch((err) => console.error("EmDash afterUpload hook error:", err)); } return result; } async handleMediaUpdate( id: string, input: { alt?: string; caption?: string; width?: number; height?: number }, ) { const result = await handleMediaUpdate(this.db, id, input); // Resolved media references in site settings (`logo`, `favicon`, // `seo.defaultOgImage`) bake in the media row's `contentType`, // `width`, and `height`. A metadata edit invalidates that snapshot // for every entry point: REST routes, MCP tools, plugin code, and // any future caller of `handleMediaUpdate`. Cross-isolate staleness // remains bounded by isolate lifetime. if (result.success) { invalidateSiteSettingsCache(); } return result; } async handleMediaDelete(id: string) { const result = await handleMediaDelete(this.db, id); // Same reasoning as `handleMediaUpdate`: if the deleted media row // was referenced by a setting, the cached resolved URL now points // at a 404. Invalidation is unconditional on success — cheaper than // querying which settings reference the id. if (result.success) { invalidateSiteSettingsCache(); } return result; } // ========================================================================= // Revision Handlers // ========================================================================= async handleRevisionList(collection: string, entryId: string, params: { limit?: number } = {}) { return handleRevisionList(this.db, collection, entryId, params); } async handleRevisionGet(revisionId: string) { return handleRevisionGet(this.db, revisionId); } async handleRevisionRestore(revisionId: string, callerUserId: string) { // Discover the parent entry up front so we can branch on whether // the collection uses draft revisions. const revisionRepo = new RevisionRepository(this.db); const revision = await revisionRepo.findById(revisionId); if (!revision) { return { success: false as const, error: { code: "NOT_FOUND", message: `Revision not found: ${revisionId}`, }, }; } const collectionInfo = await this.schemaRegistry.getCollectionWithFields(revision.collection); const usesDraftRevisions = collectionInfo?.supports?.includes("revisions") ?? false; // Non-revision collections: keep the legacy behavior of writing the // revision's data straight onto the live row. This preserves // behavior for collections that opt out of the draft model. if (!usesDraftRevisions) { const result = await handleRevisionRestore(this.db, revisionId, callerUserId); if (result.success) { await this.refreshContentUsageAfterSuccessfulWrite(revision.collection, [revision.entryId]); } return this.hydrateDraftData(result); } // Revision-capable collections: restore is "make this revision the // current draft". The live row's data columns are left untouched // (only `draft_revision_id` changes — no `updated_at` stamp, since // restoring to draft is the same kind of draft-only staging as // Save/Autosave and must not register a phantom modification for // sitemap / JSON-LD dateModified, #2143). The caller // must then `content_publish` to promote the restored draft to // live, matching the documented tool contract. try { const contentRepo = new ContentRepository(this.db); const existing = await contentRepo.findById(revision.collection, revision.entryId); if (!existing) { return { success: false as const, error: { code: "NOT_FOUND", message: `Content item not found: ${revision.entryId}`, }, }; } const newDraft = await revisionRepo.create({ collection: revision.collection, entryId: revision.entryId, data: revision.data, authorId: callerUserId, }); try { const staged = await contentRepo.replaceDraftRevision( revision.collection, revision.entryId, newDraft.id, existing, ); if (!staged) throw new ContentMutationConflictError(); } catch (error) { try { await revisionRepo.deleteIfUnreferenced( revision.collection, revision.entryId, newDraft.id, ); } catch (cleanupError) { console.error( `[emdash] Failed to clean up unrestored revision ${newDraft.id}:`, cleanupError, ); } throw error; } after(async () => { try { await revisionRepo.pruneQueuedEntry( revision.collection, revision.entryId, newDraft.id, 50, ); } catch (error) { console.error( `[revisions] Failed to prune revisions for ${revision.collection}/${revision.entryId}:`, error, ); } }); // Return the freshly-fetched item with the new draft hydrated // onto `data`. Without this the response would echo the live // columns and the next `content_get` would surface different // values (the bug that motivated this rewrite). const refetched = await handleContentGet(this.db, revision.collection, revision.entryId); const hydrated = await this.hydrateDraftData(refetched); if (hydrated.success) { await this.refreshContentUsageAfterSuccessfulWrite(revision.collection, [revision.entryId]); } return hydrated; } catch (error) { if (error instanceof ContentMutationConflictError) { return { success: false as const, error: { code: "CONFLICT", message: error.message }, }; } console.error("[emdash] revision restore failed:", error); return { success: false as const, error: { code: "REVISION_RESTORE_ERROR", message: "Failed to restore revision", }, }; } } private async refreshContentUsageAfterSuccessfulWrite( collection: string, contentIds: readonly string[], ): Promise { for (const contentId of new Set(contentIds)) { try { const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); if (work.outcome !== "inactive") return; await refreshContentMediaUsageAfterWrite(this.db, collection, contentId); } catch (error) { console.error( `[media-usage] Failed after content write ${collection}/${contentId}:`, error, ); return; } } } private async deleteContentUsageAfterSuccessfulPermanentDelete( collection: string, contentId: string, ): Promise { try { const work = await processMediaUsageWorkAfterWrite(this.db, collection, contentId); if (work.outcome !== "inactive") return; const result = await deleteContentMediaUsage(this.db, collection, contentId); if (!result.success) { console.error( `[media-usage] Usage delete for ${collection}/${contentId} finished with ${result.errorCode}`, ); } } catch (error) { console.error( `[media-usage] Failed after permanent content delete ${collection}/${contentId}:`, error, ); } } // ========================================================================= // Plugin Routes // ========================================================================= /** * 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 }> { const out: Array<{ pluginId: string; route: string; public: boolean; permission: string }> = []; const seen = new Set(); const push = (pluginId: string, route: string, meta: RouteMeta) => { const key = `${pluginId}/${route}`; if (seen.has(key)) return; seen.add(key); out.push({ pluginId, route, public: meta.public, permission: meta.permission ?? "plugins:manage", }); }; for (const plugin of this.configuredPlugins) { if (!this.isPluginEnabled(plugin.id)) continue; for (const [name, route] of Object.entries(plugin.routes ?? {})) push(plugin.id, name, buildRouteMeta(route)); } for (const [pluginId, routes] of sandboxedRouteMetaCache) { if (!this.isPluginEnabled(pluginId)) continue; for (const [name, meta] of routes) push(pluginId, name, meta); } return out.toSorted((a, b) => a.pluginId.localeCompare(b.pluginId) || a.route.localeCompare(b.route)); } getPluginRouteMeta(pluginId: string, path: string): RouteMeta | null { if (!this.isPluginEnabled(pluginId)) return null; const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); // Check trusted plugins first const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId); if (trustedPlugin) { const route = trustedPlugin.routes[routeKey]; if (!route) return null; return buildRouteMeta(route); } // Check sandboxed plugin route metadata cache const meta = sandboxedRouteMetaCache.get(pluginId); if (meta) { const routeMeta = meta.get(routeKey); if (routeMeta) return routeMeta; } // The "admin" route is implicitly available for any sandboxed plugin // that declares admin pages or widgets. This handles plugins installed // from bundles that predate the explicit admin route requirement. if (routeKey === "admin") { const manifestMeta = marketplaceManifestCache.get(pluginId); if (manifestMeta?.admin?.pages?.length || manifestMeta?.admin?.widgets?.length) { return { public: false }; } // Also check build-time sandboxed entries const entry = this.sandboxedPluginEntries.find((e) => e.id === pluginId); if (entry?.adminPages?.length || entry?.adminWidgets?.length) { return { public: false }; } } // Fallback: if the plugin exists in the sandbox cache, allow the route. // The sandbox runner will return an error if the route doesn't actually exist. if (this.findSandboxedPlugin(pluginId)) { return { public: false }; } return 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 { const meta = marketplaceManifestCache.get(pluginId); if (!meta) return null; return meta.admin?.settingsSchema ?? {}; } async handlePluginApiRoute( pluginId: string, _method: string, path: string, request: Request, user?: RouteCallerInput | null, ) { if (!this.isPluginEnabled(pluginId)) { return { success: false, error: { code: "NOT_FOUND", message: `Plugin not enabled: ${pluginId}` }, }; } // Authenticated caller for `ctx.user`. Undefined for public routes // (the catch-all only forwards the caller after private-route auth) // and for machine tokens with no bound user. const caller = user ? toRouteCallerInfo(user) : undefined; // Check trusted (configured) plugins first — this must match the // resolution order in getPluginRouteMeta to avoid auth/execution mismatches. const trustedPlugin = this.configuredPlugins.find((p) => p.id === pluginId); if (trustedPlugin && this.enabledPlugins.has(trustedPlugin.id)) { const routeRegistry = new PluginRouteRegistry({ ...this.pipelineFactoryOptions, emailPipeline: this.email ?? undefined, cronReschedule: () => this.cronScheduler?.reschedule(), trustedProxyHeaders: getTrustedProxyHeaders(this.config), }); routeRegistry.register(trustedPlugin); const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). const body = await parseRouteInput(request); return routeRegistry.invoke(pluginId, routeKey, { request, body, user: caller }); } // Check sandboxed (marketplace) plugins second const sandboxedPlugin = this.findSandboxedPlugin(pluginId); if (sandboxedPlugin) { return this.handleSandboxedRoute(sandboxedPlugin, path, request, caller); } return { success: false, error: { code: "NOT_FOUND", message: `Plugin not found: ${pluginId}` }, }; } async getPluginMcpTools(pluginId?: string) { const tools: Array<{ pluginId: string; name: string; description: string; route: string; permission: string; destructive: boolean; inputSchema: z.ZodType; outputSchema?: z.ZodType; }> = []; const seen = new Set(); for (const plugin of this.configuredPlugins) { if (pluginId && plugin.id !== pluginId) continue; for (const [name, tool] of Object.entries(plugin.mcp?.tools ?? {})) { const route = plugin.routes[tool.route]; if (!route || route.public || !route.permission || !(route.permission in Permissions)) continue; const key = `${plugin.id}__${name}`; if (seen.has(key)) continue; seen.add(key); tools.push({ pluginId: plugin.id, name, description: tool.description, route: tool.route, permission: route.permission, destructive: tool.destructive ?? false, inputSchema: tool.input, outputSchema: tool.output, }); } } const addManifestTools = (id: string, mcp: PluginMcpManifestConfig | undefined) => { if (pluginId && id !== pluginId) return; for (const tool of mcp?.tools ?? []) { const key = `${id}__${tool.name}`; const routeMeta = this.getPluginRouteMeta(id, tool.route); if ( seen.has(key) || !routeMeta || routeMeta.public || routeMeta.permission !== tool.permission || !(tool.permission in Permissions) ) { continue; } seen.add(key); tools.push({ pluginId: id, name: tool.name, description: tool.description, route: tool.route, permission: tool.permission, destructive: tool.destructive, inputSchema: z.fromJSONSchema({ ...tool.inputSchema }), outputSchema: tool.outputSchema ? z.fromJSONSchema({ ...tool.outputSchema }) : undefined, }); } }; for (const entry of this.sandboxedPluginEntries) addManifestTools(entry.id, entry.mcp); for (const [id, manifest] of marketplaceManifestCache) addManifestTools(id, manifest.mcp); return tools; } async getEnabledPluginMcpTools() { const [tools, states] = await Promise.all([ this.getPluginMcpTools(), new PluginStateRepository(this.db).getAll(), ]); const stateByPlugin = new Map(states.map((state) => [state.pluginId, state])); return tools.filter((tool) => { const state = stateByPlugin.get(tool.pluginId); if ( !state?.mcpToolsEnabled || state.status !== "active" || !this.isPluginEnabled(tool.pluginId) ) { return false; } const consented = state.mcpToolsConsent; return consented === this.serializePluginMcpConsent(tools, tool.pluginId); }); } serializePluginMcpConsent( tools: Awaited>, pluginId: string, ): string { return JSON.stringify( tools .filter((tool) => tool.pluginId === pluginId) .map((tool) => ({ name: tool.name, description: tool.description, route: tool.route, permission: tool.permission, destructive: tool.destructive, inputSchema: z.toJSONSchema(tool.inputSchema, { target: "draft-7" }), ...(tool.outputSchema ? { outputSchema: z.toJSONSchema(tool.outputSchema, { target: "draft-7" }) } : {}), })) .toSorted((a, b) => a.name.localeCompare(b.name)), ); } async handlePluginMcpTool( pluginId: string, toolName: string, route: string, input: unknown, actorId: string, request: Request, caller?: RouteCallerInput | null, ) { const requestMeta = extractRequestMeta(request, getTrustedProxyHeaders(this.config)); const audit = new AuditRepository(this.db); const headers = new Headers(request.headers); headers.delete("content-length"); headers.delete("content-encoding"); const internalRequest = new Request(request.url, { method: "POST", headers, body: JSON.stringify(input), }); const result = await this.handlePluginApiRoute( pluginId, "POST", route, internalRequest, caller, ); await audit.log({ actorId, actorIp: requestMeta.ip ?? undefined, action: "plugin_tool_invoke", resourceType: "plugin_mcp_tool", resourceId: `${pluginId}__${toolName}`, details: { pluginId, tool: toolName, route }, status: result.success ? "success" : "failure", }); return result; } async handlePluginMcpDenied( pluginId: string, toolName: string, route: string, actorId: string, request: Request, reason: string, ): Promise { const requestMeta = extractRequestMeta(request, getTrustedProxyHeaders(this.config)); await new AuditRepository(this.db).log({ actorId, actorIp: requestMeta.ip ?? undefined, action: "plugin_tool_invoke", resourceType: "plugin_mcp_tool", resourceId: `${pluginId}__${toolName}`, details: { pluginId, tool: toolName, route, reason }, status: "denied", }); } // ========================================================================= // Sandboxed Plugin Helpers // ========================================================================= private findSandboxedPlugin(pluginId: string): SandboxedPluginInstance | undefined { for (const [key, plugin] of this.sandboxedPlugins) { if (key.startsWith(pluginId + ":")) { return plugin; } } return undefined; } /** * Normalize image/file fields in content data. * Fills missing dimensions, storageKey, mimeType, and filename from providers. */ private async normalizeMediaFields( collection: string, data: Record, ): Promise> { let collectionInfo; try { collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection); } catch { return data; } if (!collectionInfo?.fields) return data; const imageFields = collectionInfo.fields.filter( (f) => f.type === "image" || f.type === "file", ); // Repeater fields can contain image sub-fields, whose values need the same normalization // (a bare media id posted inside a repeater item would otherwise be stored verbatim and // render as "Image not found" in the admin). const repeaterFields = collectionInfo.fields.filter( (f) => f.type === "repeater" && Array.isArray(f.validation?.subFields), ); if (imageFields.length === 0 && repeaterFields.length === 0) return data; const getProvider = (id: string) => this.getMediaProvider(id); const result = { ...data }; for (const field of imageFields) { const value = result[field.slug]; if (value == null) continue; try { const normalized = await normalizeMediaValue(value, getProvider); if (normalized) { result[field.slug] = normalized; } } catch { // Don't fail the save if normalization fails for a single field } } for (const field of repeaterFields) { const value = result[field.slug]; if (!Array.isArray(value)) continue; const mediaSubFieldSlugs = (field.validation?.subFields ?? []) .filter((sub) => sub.type === "image") .map((sub) => sub.slug); if (mediaSubFieldSlugs.length === 0) continue; const items: unknown[] = value; result[field.slug] = await Promise.all( items.map(async (item) => { if (!isRecord(item)) return item; const normalizedItem: Record = { ...item }; for (const slug of mediaSubFieldSlugs) { const subValue = normalizedItem[slug]; if (subValue == null) continue; try { const normalized = await normalizeMediaValue(subValue, getProvider); if (normalized) { normalizedItem[slug] = normalized; } } catch { // Don't fail the save if normalization fails for a single sub-field } } return normalizedItem; }), ); } return result; } private async runSandboxedBeforeSave( content: Record, collection: string, isNew: boolean, ): Promise> { let result = content; for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [id] = pluginKey.split(":"); if (!id || !this.isPluginEnabled(id)) continue; try { const hookResult = await plugin.invokeHook("content:beforeSave", { content: result, collection, isNew, }); if (hookResult && typeof hookResult === "object" && !Array.isArray(hookResult)) { // Sandbox returns unknown; convert to record by iterating own properties const record: Record = {}; for (const [k, v] of Object.entries(hookResult)) { record[k] = v; } result = record; } } catch (error) { console.error(`EmDash: Sandboxed plugin ${id} beforeSave hook error:`, error); } } return result; } private async runSandboxedBeforeDelete(id: string, collection: string): Promise { for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; try { const result = await plugin.invokeHook("content:beforeDelete", { id, collection, }); if (result === false) { return false; } } catch (error) { console.error(`EmDash: Sandboxed plugin ${pluginId} beforeDelete hook error:`, error); } } return true; } private runAfterSaveHooks( content: Record, collection: string, isNew: boolean, ): void { after(async () => { // Trusted plugins if (this.hooks.hasHooks("content:afterSave")) { try { await this.hooks.runContentAfterSave(content, collection, isNew); } catch (err) { console.error("EmDash afterSave hook error:", err); } } // Sandboxed plugins const tasks: Promise[] = []; for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [id] = pluginKey.split(":"); if (!id || !this.isPluginEnabled(id)) continue; tasks.push( (async () => { try { await plugin.invokeHook("content:afterSave", { content, collection, isNew }); } catch (err) { console.error(`EmDash: Sandboxed plugin ${id} afterSave error:`, err); } })(), ); } await Promise.allSettled(tasks); }); } private runAfterDeleteHooks(id: string, collection: string, permanent: boolean): void { after(async () => { // Trusted plugins if (this.hooks.hasHooks("content:afterDelete")) { try { await this.hooks.runContentAfterDelete(id, collection, permanent); } catch (err) { console.error("EmDash afterDelete hook error:", err); } } // Sandboxed plugins const tasks: Promise[] = []; for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; tasks.push( (async () => { try { await plugin.invokeHook("content:afterDelete", { id, collection, permanent }); } catch (err) { console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err); } })(), ); } await Promise.allSettled(tasks); }); } private runDeferredContentHook( name: | "content:afterPublish" | "content:afterUnpublish" | "content:afterRestore" | "content:afterSchedule" | "content:afterUnschedule", content: Record, collection: string, ): void { const label = name.slice("content:".length); after(async () => { // Trusted plugins if (this.hooks.hasHooks(name)) { try { switch (name) { case "content:afterPublish": await this.hooks.runContentAfterPublish(content, collection); break; case "content:afterUnpublish": await this.hooks.runContentAfterUnpublish(content, collection); break; case "content:afterRestore": await this.hooks.runContentAfterRestore(content, collection); break; case "content:afterSchedule": await this.hooks.runContentAfterSchedule(content, collection); break; case "content:afterUnschedule": await this.hooks.runContentAfterUnschedule(content, collection); break; } } catch (err) { console.error(`EmDash ${label} hook error:`, err); } } // Sandboxed plugins const tasks: Promise[] = []; for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; tasks.push( (async () => { try { await plugin.invokeHook(name, { content, collection }); } catch (err) { console.error(`EmDash: Sandboxed plugin ${pluginId} ${label} error:`, err); } })(), ); } await Promise.allSettled(tasks); }); } private runAfterPublishHooks(content: Record, collection: string): void { this.runDeferredContentHook("content:afterPublish", content, collection); } private runAfterUnpublishHooks(content: Record, collection: string): void { this.runDeferredContentHook("content:afterUnpublish", content, collection); } private runAfterRestoreHooks(content: Record, collection: string): void { this.runDeferredContentHook("content:afterRestore", content, collection); } private runAfterScheduleHooks(content: Record, collection: string): void { this.runDeferredContentHook("content:afterSchedule", content, collection); } private runAfterUnscheduleHooks(content: Record, collection: string): void { this.runDeferredContentHook("content:afterUnschedule", content, collection); } private async handleSandboxedRoute( plugin: SandboxedPluginInstance, path: string, request: Request, user?: UserInfo, ): Promise<{ success: boolean; data?: unknown; error?: { code: string; message: string }; status?: number; }> { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); // Body methods parse JSON; GET/HEAD/DELETE parse the query string (#2146). const body = await parseRouteInput(request); try { const headers = sanitizeHeadersForSandbox(request.headers); const meta = extractRequestMeta(request, this.config); const result = await plugin.invokeRoute(routeName, body, { url: request.url, method: request.method, headers, meta, user, }); return { success: true, data: result }; } catch (error) { console.error(`EmDash: Sandboxed plugin route error:`, error); const sandboxRouteError = getSandboxRouteErrorDetails(error); if (sandboxRouteError) { return { success: false, status: sandboxRouteError.status, error: { code: sandboxRouteError.code, message: sandboxRouteError.message, }, }; } return { success: false, error: { code: "ROUTE_ERROR", message: error instanceof Error ? error.message : String(error), }, }; } } // ========================================================================= // Public Page Contributions // ========================================================================= /** * 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 = new WeakMap>(); /** * Collect all page contributions (metadata + fragments) in a single pass. * Results are cached by page context object identity. */ async collectPageContributions(page: PublicPageContext): Promise { const cached = this.pageContributionCache.get(page); if (cached) return cached; const promise = this.doCollectPageContributions(page); this.pageContributionCache.set(page, promise); return promise; } private async doCollectPageContributions(page: PublicPageContext): Promise { const metadata: PageMetadataContribution[] = []; const fragments: PageFragmentContribution[] = []; // Trusted plugins via HookPipeline — both metadata and fragments if (this.hooks.hasHooks("page:metadata")) { const results = await this.hooks.runPageMetadata({ page }); for (const r of results) { metadata.push(...r.contributions); } } if (this.hooks.hasHooks("page:fragments")) { const results = await this.hooks.runPageFragments({ page }); for (const r of results) { fragments.push(...r.contributions); } } // Sandboxed plugins — metadata only, never fragments for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [id] = pluginKey.split(":"); if (!id || !this.isPluginEnabled(id)) continue; try { const result = await plugin.invokeHook("page:metadata", { page }); if (result != null) { const items = Array.isArray(result) ? result : [result]; for (const item of items) { if (isValidMetadataContribution(item)) { metadata.push(item); } } } } catch (error) { console.error(`EmDash: Sandboxed plugin ${id} page:metadata error:`, error); } } return { metadata, fragments }; } /** * Collect page metadata contributions from trusted and sandboxed plugins. * Delegates to the single-pass collector and returns the metadata portion. */ async collectPageMetadata(page: PublicPageContext): Promise { const { metadata } = await this.collectPageContributions(page); return metadata; } /** * Collect page fragment contributions from trusted plugins only. * Delegates to the single-pass collector and returns the fragments portion. */ async collectPageFragments(page: PublicPageContext): Promise { const { fragments } = await this.collectPageContributions(page); return fragments; } private isPluginEnabled(pluginId: string): boolean { const status = this.pluginStates.get(pluginId); return status === undefined || status === "active"; } }