/** * 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 type { Element } from "@emdash-cms/blocks"; import { Kysely, sql, type Dialect } from "kysely"; import virtualConfig from "virtual:emdash/config"; 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 { isSqlite } from "./database/dialect-helpers.js"; import { kyselyLogOption } from "./database/instrumentation.js"; import { MIGRATION_RACE_WAIT_MS, runMigrations } from "./database/migrations/runner.js"; import { RevisionRepository } from "./database/repositories/revision.js"; import type { ContentItem as ContentItemInternal, ContentDateField, } from "./database/repositories/types.js"; import { validateIdentifier } from "./database/validate.js"; import { normalizeMediaValue } from "./media/normalize.js"; import type { MediaProvider, MediaProviderCapabilities } from "./media/types.js"; import type { SandboxedPluginInstance, SandboxRunner } from "./plugins/sandbox/types.js"; import type { ResolvedPlugin, MediaItem, PluginManifest, PluginCapability, PluginStorageConfig, PublicPageContext, PageMetadataContribution, PageFragmentContribution, PortableTextBlockConfig, FieldWidgetConfig, } from "./plugins/types.js"; import type { FieldType } from "./schema/types.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 = /^\//; /** * 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 { 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 { 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 { PluginRouteRegistry, type RouteMeta } from "./plugins/routes.js"; import type { CronScheduler } from "./plugins/scheduler/types.js"; import { PluginStateRepository } from "./plugins/state.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", }; /** * 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; /** Admin pages */ adminPages?: Array<{ path: string; label?: string; icon?: string }>; /** Dashboard widgets */ adminWidgets?: Array<{ id: string; title?: string; size?: string }>; /** 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; 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 matching SandboxRunnerFactory signature */ createSandboxRunner: | ((opts: { db: Kysely; mediaStorage?: { upload(options: { key: string; body: Uint8Array; contentType: string }): Promise; delete(key: string): Promise; }; }) => SandboxRunner) | 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; storage?: Storage; siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }; }; 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; } 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() }; globalSymbolStore[DB_HOLDER_KEY] = holder; } return holder; } /** * 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[]; }; } >(); /** Route metadata for sandboxed plugins: pluginId -> routeName -> RouteMeta */ const sandboxedRouteMetaCache = new Map>(); let sandboxRunner: SandboxRunner | null = null; /** * 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[]; /** Factory options for the hook pipeline context factory */ private pipelineFactoryOptions: { db: Kysely; getDb?: () => Kysely; storage?: Storage; siteInfo?: { siteName?: string; siteUrl?: string; locale?: string }; }; /** 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 publishDueContent(this.db, { publish: (collection, id, options) => this.handleContentPublish(collection, id, options), }); } /** * 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 { // Route through the runtime wrapper so content:afterPublish hooks fire. published = await publishDueContent(this.db, { publish: (collection, id, opts) => this.handleContentPublish(collection, id, opts), onPublished: 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); } return { published }; } /** * 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 }); } if (this.cronScheduler) { const scheduler = this.cronScheduler; newPipeline.setContextFactory({ cronReschedule: () => scheduler.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. */ async syncMarketplacePlugins(): Promise { 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, }); // 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, { public: normalized.public === true }); } 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, }); 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, { public: normalized.public === true }); } 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, })), }); 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 database (connects, runs migrations if needed) const db = await phase("rt.db", "DB init + migrations", () => 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(); let siteInfo: { siteName?: string; siteUrl?: string; locale?: string } | 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; 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); const readSiteInfo = async () => { const siteOpts = await optionsRepo.getMany([ "emdash:site_title", "emdash:site_url", "emdash:locale", ]); return { siteName: siteOpts.get("emdash:site_title") ?? undefined, siteUrl: siteOpts.get("emdash:site_url") ?? undefined, locale: siteOpts.get("emdash:locale") ?? undefined, }; }; 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 { // _plugin_state may not exist yet on a pre-migration db. } }), phase("rt.site", "Site info options", async () => { try { siteInfo = await readSiteInfo(); } catch { // 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 { // Leave the "already set up" default so a read failure never // triggers a seed onto a half-built db. } }), ); } await Promise.all(coldStartReads); // 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. if (readDbDisposable) { try { await readDbDisposable.destroy(); } catch { // Non-fatal — the underlying binding is shared and needs no teardown. } } 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, 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), ); // 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, ), ), ); } // 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, ), ), ); } 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); // 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; // 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; await publishDueContent(db, { publish: runtime ? (collection, id, options) => runtime.handleContentPublish(collection, id, options) : undefined, }); } 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); } }); // Add cron reschedule callback (merges with existing factory options) pipeline.setContextFactory({ cronReschedule: () => cronScheduler?.reschedule(), }); // 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(); return initWithLock( holder.lock, () => holder.cache.get(cacheKey), async (isCurrentClaim) => { const dialect = deps.createDialect(dbConfig.config); const db = new Kysely({ dialect, log: kyselyLogOption() }); await runMigrations(db); // 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, 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, ): 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({ db, mediaStorage: mediaStorage ? { upload: (opts) => mediaStorage.upload({ key: opts.key, body: opts.body, contentType: opts.contentType, }), delete: (key) => mediaStorage.delete(key), } : undefined, }); } 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 @emdash-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: [], routes: [], admin: {}, }; const plugin = await sandboxRunner.load(manifest, entry.code); sandboxedPluginCache.set(pluginKey, plugin); console.log( `EmDash: Loaded sandboxed plugin ${pluginKey} with capabilities: [${manifest.capabilities.join(", ")}]`, ); } 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, ): 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({ db, mediaStorage: { upload: (opts) => storage.upload({ key: opts.key, body: opts.body, contentType: opts.contentType, }), delete: (key) => storage.delete(key), }, }); } // 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, }); // 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, { public: normalized.public === true }); } 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, }); 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, { public: normalized.public === true }); } 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, })), }); 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; } > = {}; 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, }; // 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; } manifestCollections[collection.slug] = { label: collection.label, labelSingular: collection.labelSingular || collection.label, supports: collection.supports || [], hasSeo: collection.hasSeo, urlPattern: collection.urlPattern, 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<{ name: string; label: string; labelSingular?: string; hierarchical: boolean; collections: string[]; }> = []; try { const rows = await this.db .selectFrom("_emdash_taxonomy_defs") .selectAll() .orderBy("name") .execute(); manifestTaxonomies = rows.map((row) => ({ name: row.name, label: row.label, labelSingular: row.label_singular ?? undefined, hierarchical: row.hierarchical === 1, collections: parseStringArray(row.collections).toSorted(), })); } catch (error) { console.debug("EmDash: Could not load taxonomy definitions:", 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; 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, 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 // ========================================================================= 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; }, ) { return handleContentList(this.db, collection, params); } async handleContentAuthors(collection: string) { return handleContentAuthors(this.db, collection); } async handleContentGet(collection: string, id: string, locale?: string) { 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 }, }, } as T; } 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; status?: string; authorId?: string; bylines?: Array<{ bylineId: string; roleLabel?: string | null }>; locale?: string; translationOf?: string; }, ) { // 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, }); // 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; 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; }; publishedAt?: string | null; locale?: string; /** Skip revision creation (used by autosave) */ skipRevision?: boolean; _rev?: string; }, ) { // Resolve slug → ID if needed (before any lookups) const { ContentRepository } = await import("./database/repositories/content.js"); 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; if (processedData) { try { const collectionInfo = await this.schemaRegistry.getCollectionWithFields(collection); if (collectionInfo?.supports?.includes("revisions")) { usesDraftRevisions = true; const revisionRepo = new RevisionRepository(this.db); // Re-fetch to get latest state (resolvedItem may be stale after _rev check) const existing = await repo.findById(collection, resolvedId); if (existing) { // Build the draft data: merge with existing draft revision if one exists, // otherwise merge with the published data from the content table let baseData: Record; if (existing.draftRevisionId) { const draftRevision = await revisionRepo.findById(existing.draftRevisionId); baseData = draftRevision?.data ?? existing.data; } else { baseData = existing.data; } // Include slug in the revision data if it changed const mergedData = { ...baseData, ...processedData }; if (bodyWithoutRev.slug !== undefined) { mergedData._slug = bodyWithoutRev.slug; } if (bodyWithoutRev.skipRevision && existing.draftRevisionId) { // Autosave: update existing draft revision in place await revisionRepo.updateData(existing.draftRevisionId, mergedData); } else { // Create new draft revision const revision = await revisionRepo.create({ collection, entryId: resolvedId, data: mergedData, authorId: bodyWithoutRev.authorId ?? undefined, }); // Update entry to point to new draft (metadata only, not data columns) validateIdentifier(collection, "collection"); const tableName = `ec_${collection}`; await sql` UPDATE ${sql.ref(tableName)} SET draft_revision_id = ${revision.id}, updated_at = ${new Date().toISOString()} WHERE id = ${resolvedId} `.execute(this.db); // Fire-and-forget: prune old revisions to prevent unbounded growth void revisionRepo.pruneOldRevisions(collection, resolvedId, 50).catch(() => {}); } } } } catch { // Don't fail the update if revision creation fails } } // Update the content table: // - If collection uses draft revisions: only update metadata (no data fields, no slug) // - Otherwise: update everything as before const result = await handleContentUpdate(this.db, collection, resolvedId, { ...bodyWithoutRev, data: usesDraftRevisions ? undefined : processedData, slug: usesDraftRevisions ? undefined : bodyWithoutRev.slug, authorId: bodyWithoutRev.authorId, bylines: bodyWithoutRev.bylines, }); // 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); // Run afterSave hooks (fire-and-forget) if (hydrated.success && hydrated.data) { this.runAfterSaveHooks(contentItemToRecord(hydrated.data.item), collection, false); } return hydrated; } async handleContentDelete(collection: string, id: string) { // 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); // Run afterDelete hooks (fire-and-forget) 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); // 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); // 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) { return handleContentDuplicate(this.db, collection, id, authorId); } // ========================================================================= // Publishing & Scheduling Handlers // ========================================================================= async handleContentPublish( collection: string, id: string, options: { publishedAt?: string; requireScheduledDue?: boolean } = {}, ) { const result = await handleContentPublish(this.db, collection, id, options); // 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) { const result = await handleContentUnpublish(this.db, collection, id); // Run afterUnpublish hooks (fire-and-forget) 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); // 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); // 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) { return handleContentDiscardDraft(this.db, collection, id); } 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); 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` and `updated_at` change). The caller // must then `content_publish` to promote the restored draft to // live, matching the documented tool contract. try { const newDraft = await revisionRepo.create({ collection: revision.collection, entryId: revision.entryId, data: revision.data, authorId: callerUserId, }); validateIdentifier(revision.collection, "collection"); const tableName = `ec_${revision.collection}`; await sql` UPDATE ${sql.ref(tableName)} SET draft_revision_id = ${newDraft.id}, updated_at = ${new Date().toISOString()} WHERE id = ${revision.entryId} `.execute(this.db); // Fire-and-forget: prune old revisions to prevent unbounded growth void revisionRepo .pruneOldRevisions(revision.collection, revision.entryId, 50) .catch(() => {}); // 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); return this.hydrateDraftData(refetched); } catch (error) { console.error("[emdash] revision restore failed:", error); return { success: false as const, error: { code: "REVISION_RESTORE_ERROR", message: "Failed to restore revision", }, }; } } // ========================================================================= // 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. */ 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 { public: route.public === true }; } // 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; } async handlePluginApiRoute(pluginId: string, _method: string, path: string, request: Request) { if (!this.isPluginEnabled(pluginId)) { return { success: false, error: { code: "NOT_FOUND", message: `Plugin not enabled: ${pluginId}` }, }; } // 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({ db: this.db, storage: this.storage ?? undefined, emailPipeline: this.email ?? undefined, trustedProxyHeaders: getTrustedProxyHeaders(this.config), }); routeRegistry.register(trustedPlugin); const routeKey = path.replace(LEADING_SLASH_PATTERN, ""); let body: unknown = undefined; try { body = await request.json(); } catch { // No body or not JSON } return routeRegistry.invoke(pluginId, routeKey, { request, body }); } // Check sandboxed (marketplace) plugins second const sandboxedPlugin = this.findSandboxedPlugin(pluginId); if (sandboxedPlugin) { return this.handleSandboxedRoute(sandboxedPlugin, path, request); } return { success: false, error: { code: "NOT_FOUND", message: `Plugin not found: ${pluginId}` }, }; } // ========================================================================= // 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", ); if (imageFields.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 } } 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 { // Trusted plugins if (this.hooks.hasHooks("content:afterDelete")) { this.hooks .runContentAfterDelete(id, collection, permanent) .catch((err) => console.error("EmDash afterDelete hook error:", err)); } // Sandboxed plugins for (const [pluginKey, plugin] of this.sandboxedPlugins) { const [pluginId] = pluginKey.split(":"); if (!pluginId || !this.isPluginEnabled(pluginId)) continue; plugin .invokeHook("content:afterDelete", { id, collection, permanent }) .catch((err) => console.error(`EmDash: Sandboxed plugin ${pluginId} afterDelete error:`, err), ); } } 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, ): Promise<{ success: boolean; data?: unknown; error?: { code: string; message: string }; }> { const routeName = path.replace(LEADING_SLASH_PATTERN, ""); let body: unknown = undefined; try { body = await request.json(); } catch { // No body or not JSON } 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, }); return { success: true, data: result }; } catch (error) { console.error(`EmDash: Sandboxed plugin route error:`, error); 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"; } }