diff --git a/.changeset/bumpy-crabs-nail.md b/.changeset/bumpy-crabs-nail.md new file mode 100644 index 0000000000..797a77aa0a --- /dev/null +++ b/.changeset/bumpy-crabs-nail.md @@ -0,0 +1,11 @@ +--- +"emdash": minor +"@emdash-cms/cloudflare": patch +"@emdash-cms/sandbox-workerd": minor +--- + +Adds workerd-based plugin sandboxing for Node.js deployments. + +- **emdash**: Adds `isHealthy()` to `SandboxRunner` interface, `SandboxUnavailableError` class, `sandbox: false` config option, `mediaStorage` field on `SandboxOptions`, and exports `createHttpAccess`/`createUnrestrictedHttpAccess`/`PluginStorageRepository`/`UserRepository`/`OptionsRepository` for platform adapters. +- **@emdash-cms/cloudflare**: Implements `isHealthy()` on `CloudflareSandboxRunner`. Fixes `storageQuery()` and `storageCount()` to honor `where`, `orderBy`, and `cursor` options (previously ignored, causing infinite pagination loops and incorrect filtered counts). Adds `storageConfig` to `PluginBridgeProps` so `PluginStorageRepository` can use declared indexes. +- **@emdash-cms/sandbox-workerd**: New package. `WorkerdSandboxRunner` for production (workerd child process + capnp config + authenticated HTTP backing service) and `MiniflareDevRunner` for development. diff --git a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx index ee92528ae5..28625a1be7 100644 --- a/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/capabilities.mdx @@ -77,7 +77,7 @@ When a sandbox runner is active, the runtime enforces: 4. **No host bindings.** Sandboxed plugins don't see environment variables, the filesystem, or any platform bindings — even if your host worker has them. The plugin runtime is a clean isolate with only the bridge and the declared capabilities. -5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that. +5. **Resource limits.** The runner can enforce CPU, subrequest, wall-clock, and memory limits per invocation. The exact limits depend on which runner you're using; the Cloudflare runner uses the platform's Worker Loader limits (50ms CPU per invocation, 10 subrequests, 30 second wall-clock, ~128MB memory). The Node.js workerd runner (`@emdash-cms/sandbox-workerd`) enforces wall-clock time via `Promise.race`; CPU and memory limits are Cloudflare platform features and are not enforced by standalone workerd. Hooks that exceed the runner's limits are aborted; the EmDash hook timeout (`timeout` in the hook config) enforces a stricter ceiling on top of that. diff --git a/docs/src/content/docs/plugins/installing.mdx b/docs/src/content/docs/plugins/installing.mdx index 0d0134b097..7f8ef3af6e 100644 --- a/docs/src/content/docs/plugins/installing.mdx +++ b/docs/src/content/docs/plugins/installing.mdx @@ -31,6 +31,27 @@ To install marketplace plugins, your site needs: }); ``` + On **Cloudflare Workers**, sandboxing uses the Dynamic Worker Loader API (no additional setup needed). On **Node.js**, install the workerd sandbox runner: + + ```bash + npm install @emdash-cms/sandbox-workerd + ``` + + Then pass the runner explicitly: + + ```typescript title="astro.config.mjs" + emdash({ + marketplace: "https://marketplace.emdashcms.com", + sandboxRunner: "@emdash-cms/sandbox-workerd/sandbox", + }) + ``` + + In development, install `miniflare` as a dev dependency for faster sandbox startup: + + ```bash + npm install -D miniflare + ``` + 2. **Admin access** — Only administrators can install or remove plugins. ### Browse and Install diff --git a/packages/cloudflare/src/sandbox/bridge.ts b/packages/cloudflare/src/sandbox/bridge.ts index 22ccadec33..8063b45fe7 100644 --- a/packages/cloudflare/src/sandbox/bridge.ts +++ b/packages/cloudflare/src/sandbox/bridge.ts @@ -7,9 +7,12 @@ * */ +import type { D1Database } from "@cloudflare/workers-types"; import { WorkerEntrypoint } from "cloudflare:workers"; import type { SandboxEmailSendCallback } from "emdash"; -import { ulid } from "emdash"; +import { ulid, PluginStorageRepository } from "emdash"; +import { Kysely } from "kysely"; +import { D1Dialect } from "kysely-d1"; import { sandboxHttpFetch } from "./bridge-http.js"; @@ -127,6 +130,11 @@ export interface PluginBridgeProps { capabilities: string[]; allowedHosts: string[]; storageCollections: string[]; + /** Per-collection storage config (matches manifest.storage entries) */ + storageConfig?: Record< + string, + { indexes?: Array; uniqueIndexes?: Array } + >; } /** @@ -141,6 +149,28 @@ export interface PluginBridgeProps { * 3. Plugins call bridge methods which validate and proxy to the database */ export class PluginBridge extends WorkerEntrypoint { + /** + * Construct a PluginStorageRepository for the requested collection. + * Uses the indexes from the plugin's storage config (if provided) so + * query/count operations support WHERE/ORDER BY/cursor pagination + * matching in-process and workerd sandbox plugins. + */ + private getStorageRepo(collection: string): PluginStorageRepository { + const { pluginId, storageConfig } = this.ctx.props; + const config = storageConfig?.[collection]; + // Merge unique indexes into the indexes list since both are queryable + const allIndexes: Array = [ + ...(config?.indexes ?? []), + ...(config?.uniqueIndexes ?? []), + ]; + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- D1 is the kysely-d1 dialect database type + const db = new Kysely({ + dialect: new D1Dialect({ database: this.env.DB as D1Database }), + }); + // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Kysely is compatible with PluginStorageRepository's expected db + return new PluginStorageRepository(db as never, pluginId, collection, allIndexes); + } + // ========================================================================= // KV Operations - scoped to plugin namespace // ========================================================================= @@ -242,45 +272,45 @@ export class PluginBridge extends WorkerEntrypoint; + orderBy?: Record; + } = {}, ): Promise<{ items: Array<{ id: string; data: unknown }>; hasMore: boolean; cursor?: string; }> { - const { pluginId, storageCollections } = this.ctx.props; + const { storageCollections } = this.ctx.props; if (!storageCollections.includes(collection)) { throw new Error(`Storage collection not declared: ${collection}`); } - const limit = Math.min(opts.limit ?? 50, 1000); - const results = await this.env.DB.prepare( - "SELECT id, data FROM _plugin_storage WHERE plugin_id = ? AND collection = ? LIMIT ?", - ) - .bind(pluginId, collection, limit + 1) - .all<{ id: string; data: string }>(); - - const items = (results.results ?? []).slice(0, limit).map((row) => ({ - id: row.id, - data: JSON.parse(row.data), - })); + // Delegate to PluginStorageRepository for proper WHERE/ORDER BY/cursor support + const repo = this.getStorageRepo(collection); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record + const result = await repo.query({ + where: opts.where as never, + orderBy: opts.orderBy, + limit: opts.limit, + cursor: opts.cursor, + }); return { - items, - hasMore: (results.results ?? []).length > limit, - cursor: items.length > 0 ? items.at(-1)!.id : undefined, + items: result.items, + hasMore: result.hasMore, + cursor: result.cursor, }; } - async storageCount(collection: string): Promise { - const { pluginId, storageCollections } = this.ctx.props; + async storageCount(collection: string, where?: Record): Promise { + const { storageCollections } = this.ctx.props; if (!storageCollections.includes(collection)) { throw new Error(`Storage collection not declared: ${collection}`); } - const result = await this.env.DB.prepare( - "SELECT COUNT(*) as count FROM _plugin_storage WHERE plugin_id = ? AND collection = ?", - ) - .bind(pluginId, collection) - .first<{ count: number }>(); - return result?.count ?? 0; + const repo = this.getStorageRepo(collection); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record + return repo.count(where as never); } async storageGetMany(collection: string, ids: string[]): Promise> { diff --git a/packages/cloudflare/src/sandbox/runner.ts b/packages/cloudflare/src/sandbox/runner.ts index fcf45df600..b82bcac4a2 100644 --- a/packages/cloudflare/src/sandbox/runner.ts +++ b/packages/cloudflare/src/sandbox/runner.ts @@ -49,6 +49,10 @@ export interface PluginBridgeProps { capabilities: string[]; allowedHosts: string[]; storageCollections: string[]; + storageConfig?: Record< + string, + { indexes?: Array; uniqueIndexes?: Array } + >; } /** @@ -125,6 +129,13 @@ export class CloudflareSandboxRunner implements SandboxRunner { return !!getLoader() && !!getPluginBridge(); } + /** + * Worker Loader runs in-process, always healthy if available. + */ + isHealthy(): boolean { + return this.isAvailable(); + } + /** * Load a sandboxed plugin. * @@ -243,6 +254,15 @@ class CloudflareSandboxedPlugin implements SandboxedPluginInstance { capabilities: normalizeCapabilities(this.manifest.capabilities || []), allowedHosts: this.manifest.allowedHosts || [], storageCollections: Object.keys(this.manifest.storage || {}), + storageConfig: this.manifest.storage as + | Record< + string, + { + indexes?: Array; + uniqueIndexes?: Array; + } + > + | undefined, }, }); diff --git a/packages/core/src/api/handlers/marketplace.ts b/packages/core/src/api/handlers/marketplace.ts index b310cf7c52..86ff76ddbe 100644 --- a/packages/core/src/api/handlers/marketplace.ts +++ b/packages/core/src/api/handlers/marketplace.ts @@ -320,7 +320,18 @@ export async function handleMarketplaceInstall( sandboxRunner: SandboxRunner | null, marketplaceUrl: string | undefined, pluginId: string, - opts?: { version?: string; configuredPluginIds?: Set; siteOrigin?: string }, + opts?: { + version?: string; + configuredPluginIds?: Set; + siteOrigin?: string; + /** + * When true, sandbox: false bypass mode is active. The sandbox runner + * is the noop runner (isAvailable() === false) but the runtime will + * load the marketplace plugin in-process via syncMarketplacePlugins(). + * Skip the SANDBOX_NOT_AVAILABLE gate so the install can proceed. + */ + sandboxBypassed?: boolean; + }, ): Promise> { const client = getClient(marketplaceUrl, opts?.siteOrigin); if (!client) { @@ -343,7 +354,9 @@ export async function handleMarketplaceInstall( }; } - if (!sandboxRunner || !sandboxRunner.isAvailable()) { + // Sandbox availability check: skip when sandbox: false bypass is active. + // The runtime's syncMarketplacePlugins() will load the plugin in-process. + if (!opts?.sandboxBypassed && (!sandboxRunner || !sandboxRunner.isAvailable())) { return { success: false, error: { @@ -524,6 +537,13 @@ export async function handleMarketplaceUpdate( version?: string; confirmCapabilityChanges?: boolean; confirmRouteVisibilityChanges?: boolean; + /** + * When true, sandbox: false bypass mode is active. The sandbox runner + * is the noop runner (isAvailable() === false) but the runtime will + * load the marketplace plugin in-process via syncMarketplacePlugins(). + * Skip the SANDBOX_NOT_AVAILABLE gate so the update can proceed. + */ + sandboxBypassed?: boolean; }, ): Promise> { const client = getClient(marketplaceUrl); @@ -539,7 +559,9 @@ export async function handleMarketplaceUpdate( error: { code: "STORAGE_NOT_CONFIGURED", message: "Storage is required" }, }; } - if (!sandboxRunner || !sandboxRunner.isAvailable()) { + // Sandbox availability check: skip when sandbox: false bypass is active. + // The runtime's syncMarketplacePlugins() will load the plugin in-process. + if (!opts?.sandboxBypassed && (!sandboxRunner || !sandboxRunner.isAvailable())) { return { success: false, error: { code: "SANDBOX_NOT_AVAILABLE", message: "Sandbox runner is required" }, diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 31aee75bf2..a07b9d2c46 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -199,6 +199,17 @@ export interface EmDashConfig { */ sandboxRunner?: string; + /** + * Explicitly disable plugin sandboxing, even if a sandbox runner is configured. + * Use this as a debugging escape hatch to determine whether a bug is in your + * plugin code or in the sandbox runtime. + * + * When set to `false`, all plugins run in-process without isolation. + * + * @default true (sandboxing enabled if sandboxRunner is configured) + */ + sandbox?: boolean; + /** * Authentication configuration * diff --git a/packages/core/src/astro/integration/virtual-modules.ts b/packages/core/src/astro/integration/virtual-modules.ts index 96c0ebfade..f130c2309c 100644 --- a/packages/core/src/astro/integration/virtual-modules.ts +++ b/packages/core/src/astro/integration/virtual-modules.ts @@ -283,10 +283,14 @@ ${entries.join("\n")} /** * Generates the sandbox runner module. * Imports the configured sandbox runner factory or provides a noop default. + * + * When sandbox is explicitly false (debugging escape hatch), we still mark + * sandboxEnabled = true so sandboxed plugin entries are loaded, but we use + * the noop runner which falls through to in-process loading via adaptSandboxEntry. */ -export function generateSandboxRunnerModule(sandboxRunner?: string): string { +export function generateSandboxRunnerModule(sandboxRunner?: string, sandbox?: boolean): string { if (!sandboxRunner) { - // No sandbox runner configured - use noop + // No sandbox runner configured - sandboxed plugins disabled return ` // No sandbox runner configured - sandboxed plugins disabled import { createNoopSandboxRunner } from "emdash"; @@ -296,6 +300,19 @@ export const sandboxEnabled = false; `; } + if (sandbox === false) { + // sandbox: false escape hatch - plugins are loaded but run in-process + // (no isolation, for debugging) + return ` +// Sandbox explicitly disabled (sandbox: false) - plugins run in-process +import { createNoopSandboxRunner } from "emdash"; + +export const createSandboxRunner = createNoopSandboxRunner; +export const sandboxEnabled = true; +export const sandboxBypassed = true; +`; + } + return ` // Auto-generated sandbox runner module import { createSandboxRunner as _createSandboxRunner } from "${sandboxRunner}"; diff --git a/packages/core/src/astro/integration/vite-config.ts b/packages/core/src/astro/integration/vite-config.ts index 36c0bb88e3..1241619369 100644 --- a/packages/core/src/astro/integration/vite-config.ts +++ b/packages/core/src/astro/integration/vite-config.ts @@ -233,7 +233,7 @@ export function createVirtualModulesPlugin(options: VitePluginOptions): Plugin { } // Generate sandbox runner module if (id === RESOLVED_VIRTUAL_SANDBOX_RUNNER_ID) { - return generateSandboxRunnerModule(resolvedConfig.sandboxRunner); + return generateSandboxRunnerModule(resolvedConfig.sandboxRunner, resolvedConfig.sandbox); } // Generate sandboxed plugins config module if (id === RESOLVED_VIRTUAL_SANDBOXED_PLUGINS_ID) { diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index bdbd628daa..347b04f973 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -20,11 +20,8 @@ import type { RequestScopedDbOpts } from "virtual:emdash/dialect"; import { mediaProviders as virtualMediaProviders } from "virtual:emdash/media-providers"; // @ts-ignore - virtual module import { plugins as virtualPlugins } from "virtual:emdash/plugins"; -import { - createSandboxRunner as virtualCreateSandboxRunner, - sandboxEnabled as virtualSandboxEnabled, - // @ts-ignore - virtual module -} from "virtual:emdash/sandbox-runner"; +// @ts-ignore - virtual module +import * as virtualSandboxRunnerModule from "virtual:emdash/sandbox-runner"; // @ts-ignore - virtual module import { sandboxedPlugins as virtualSandboxedPlugins } from "virtual:emdash/sandboxed-plugins"; // @ts-ignore - virtual module @@ -124,12 +121,26 @@ function buildDependencies(config: EmDashConfig): RuntimeDependencies { // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above) createStorage: virtualCreateStorage as ((config: Record) => Storage) | null, // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above) - sandboxEnabled: virtualSandboxEnabled as boolean, + sandboxEnabled: (virtualSandboxRunnerModule as Record) + .sandboxEnabled as boolean, + sandboxBypassed: + ((virtualSandboxRunnerModule as Record).sandboxBypassed as boolean) ?? false, // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above) sandboxedPluginEntries: (virtualSandboxedPlugins as SandboxedPluginEntry[]) || [], // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above) - createSandboxRunner: virtualCreateSandboxRunner as - | ((opts: { db: Kysely }) => SandboxRunner) + createSandboxRunner: (virtualSandboxRunnerModule as Record) + .createSandboxRunner as + | ((opts: { + db: Kysely; + mediaStorage?: { + upload(options: { + key: string; + body: Uint8Array; + contentType: string; + }): Promise; + delete(key: string): Promise; + }; + }) => SandboxRunner) | null, // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import is untyped (@ts-ignore above) mediaProviderEntries: (virtualMediaProviders as MediaProviderEntry[]) || [], @@ -523,6 +534,7 @@ export const onRequest = defineMiddleware(async (context, next) => { // Sandbox runner (for marketplace plugin install/update) getSandboxRunner: runtime.getSandboxRunner.bind(runtime), + isSandboxBypassed: runtime.isSandboxBypassed.bind(runtime), // Sync marketplace plugin states (after install/update/uninstall) syncMarketplacePlugins: runtime.syncMarketplacePlugins.bind(runtime), diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts index 9e27473997..17f76794ae 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/update.ts @@ -48,6 +48,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { version: body.version, confirmCapabilityChanges: body.confirmCapabilityChanges, confirmRouteVisibilityChanges: body.confirmRouteVisibilityChanges, + sandboxBypassed: emdash.isSandboxBypassed(), }, ); diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts index 33b3298751..7f9e7ee7f8 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/install.ts @@ -49,7 +49,12 @@ export const POST: APIRoute = async ({ params, request, locals }) => { emdash.getSandboxRunner(), emdash.config.marketplace, id, - { version: body.version, configuredPluginIds, siteOrigin }, + { + version: body.version, + configuredPluginIds, + siteOrigin, + sandboxBypassed: emdash.isSandboxBypassed(), + }, ); if (!result.success) return unwrapResult(result); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 16dd2c7dfd..50027cac8a 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -427,6 +427,10 @@ export interface EmDashHandlers { // Sandbox runner (for marketplace plugin install/update) getSandboxRunner: () => import("../plugins/sandbox/types.js").SandboxRunner | null; + // Whether sandbox bypass mode (sandbox: false) is active. Marketplace + // install/update routes use this to skip the SANDBOX_NOT_AVAILABLE gate. + isSandboxBypassed: () => boolean; + // Sync marketplace plugin states (after install/update/uninstall) syncMarketplacePlugins: () => Promise; diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 737f9bcdbb..71aedaa428 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -247,11 +247,21 @@ export interface RuntimeDependencies { // 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; /** Media provider entries from virtual module */ mediaProviderEntries?: MediaProviderEntry[]; sandboxedPluginEntries: SandboxedPluginEntry[]; /** Factory function matching SandboxRunnerFactory signature */ - createSandboxRunner: ((opts: { db: Kysely }) => SandboxRunner) | null; + createSandboxRunner: + | ((opts: { + db: Kysely; + mediaStorage?: { + upload(options: { key: string; body: Uint8Array; contentType: string }): Promise; + delete(key: string): Promise; + }; + }) => SandboxRunner) + | null; } /** @@ -428,6 +438,16 @@ export class EmDashRuntime { 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; + } + /** * Tick the cron system from request context (piggyback mode). * Call this from middleware on each request to ensure cron tasks @@ -519,6 +539,17 @@ export class EmDashRuntime { */ 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"); } @@ -537,8 +568,8 @@ export class EmDashRuntime { /** * Internal: reconcile in-memory sandboxed-plugin state with the * `_plugin_state` table for the given source tier. Shared - * implementation behind `syncMarketplacePlugins` and - * `syncRegistryPlugins`. + * 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. @@ -652,6 +683,165 @@ export class EmDashRuntime { } } + /** + * 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 configured = this.configuredPlugins as ResolvedPlugin[]; + const configIdx = configured.findIndex((p) => p.id === pluginId); + if (configIdx !== -1) configured.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") { + 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")}`; + 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 as ResolvedPlugin[]).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 */ @@ -737,6 +927,11 @@ export class EmDashRuntime { // 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. @@ -784,6 +979,53 @@ export class EmDashRuntime { 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)); @@ -795,13 +1037,14 @@ export class EmDashRuntime { }; const pipeline = createHookPipeline(enabledPluginList, pipelineFactoryOptions); - // Load sandboxed plugins (build-time) + // Load sandboxed plugins (build-time, sandbox runner path) const sandboxedPlugins = await phase("rt.sandbox", "Sandboxed plugins", () => - EmDashRuntime.loadSandboxedPlugins(deps, db), + EmDashRuntime.loadSandboxedPlugins(deps, db, storage), ); - // Cold-start: load marketplace-installed plugins from site R2 - if (deps.config.marketplace && storage) { + // Cold-start: load marketplace-installed plugins from site R2 via + // the sandbox runner. In bypass mode this was already handled above. + if (deps.config.marketplace && storage && !deps.sandboxBypassed) { await phase("rt.market", "Marketplace plugins", () => EmDashRuntime.loadInstalledSandboxedPlugins( "marketplace", @@ -942,7 +1185,10 @@ export class EmDashRuntime { return new EmDashRuntime({ db, storage, - configuredPlugins: deps.plugins, + // 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, @@ -1115,12 +1361,82 @@ export class EmDashRuntime { 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")}`; + 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, + storage: entry.storage as never, + adminPages, + adminWidgets, + }); + 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) { @@ -1128,26 +1444,56 @@ export class EmDashRuntime { } // Check if sandboxing is enabled - if (!deps.sandboxEnabled || deps.sandboxedPluginEntries.length === 0) { + if (!deps.sandboxEnabled) { return sandboxedPluginCache; } // Create sandbox runner if not exists if (!sandboxRunner && deps.createSandboxRunner) { - sandboxRunner = deps.createSandboxRunner({ db }); + 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) + // 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.debug("EmDash: Sandbox runner not available (missing bindings), skipping sandbox"); + 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 + // Load each sandboxed plugin via sandbox runner for (const entry of deps.sandboxedPluginEntries) { const pluginKey = `${entry.id}:${entry.version}`; if (sandboxedPluginCache.has(pluginKey)) { @@ -1201,10 +1547,26 @@ export class EmDashRuntime { deps: RuntimeDependencies, cache: Map, ): Promise { - // Ensure sandbox runner exists + // 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 }); + 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; } @@ -1270,6 +1632,105 @@ export class EmDashRuntime { } } + /** + * 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")}`; + 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. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 608c500896..d70ad0bd21 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,9 @@ export type { export { ContentRepository, MediaRepository, + PluginStorageRepository, + UserRepository, + OptionsRepository, EmDashValidationError, InvalidCursorError, } from "./database/repositories/index.js"; @@ -198,7 +201,11 @@ export { // Sandbox NoopSandboxRunner, SandboxNotAvailableError, + SandboxUnavailableError, createNoopSandboxRunner, + // HTTP access for plugins (shared between in-process, Cloudflare, and workerd runners) + createHttpAccess, + createUnrestrictedHttpAccess, } from "./plugins/index.js"; export type { PluginDefinition, diff --git a/packages/core/src/plugins/index.ts b/packages/core/src/plugins/index.ts index abc92c5b41..51cb6e31cd 100644 --- a/packages/core/src/plugins/index.ts +++ b/packages/core/src/plugins/index.ts @@ -67,6 +67,7 @@ export type { PluginManagerOptions, PluginState } from "./manager.js"; export { NoopSandboxRunner, SandboxNotAvailableError, + SandboxUnavailableError, createNoopSandboxRunner, } from "./sandbox/index.js"; export type { diff --git a/packages/core/src/plugins/sandbox/index.ts b/packages/core/src/plugins/sandbox/index.ts index d52df66489..2b03e8cc0e 100644 --- a/packages/core/src/plugins/sandbox/index.ts +++ b/packages/core/src/plugins/sandbox/index.ts @@ -4,6 +4,7 @@ */ export { NoopSandboxRunner, SandboxNotAvailableError, createNoopSandboxRunner } from "./noop.js"; +export { SandboxUnavailableError } from "./types.js"; export type { SandboxRunner, diff --git a/packages/core/src/plugins/sandbox/noop.ts b/packages/core/src/plugins/sandbox/noop.ts index 1899d89b52..9c9a969e6c 100644 --- a/packages/core/src/plugins/sandbox/noop.ts +++ b/packages/core/src/plugins/sandbox/noop.ts @@ -15,9 +15,10 @@ import type { SandboxRunner, SandboxedPluginInstance, SandboxOptions } from "./t export class SandboxNotAvailableError extends Error { constructor() { super( - "Plugin sandboxing is not available on this platform. " + - "Sandboxed plugins require Cloudflare Workers with Worker Loader. " + - "Use trusted plugins (from config) instead, or deploy to Cloudflare.", + "Plugin sandboxing is not available. " + + "Configure a sandbox runner: use @emdash-cms/cloudflare/sandbox on Cloudflare, " + + "or @emdash-cms/sandbox-workerd/sandbox on Node.js (requires workerd). " + + "Without sandboxing, use trusted plugins (from config) instead.", ); this.name = "SandboxNotAvailableError"; } @@ -40,6 +41,13 @@ export class NoopSandboxRunner implements SandboxRunner { return false; } + /** + * Always returns false - no sandbox runtime to be healthy. + */ + isHealthy(): boolean { + return false; + } + /** * Always throws - can't load sandboxed plugins without isolation. */ diff --git a/packages/core/src/plugins/sandbox/types.ts b/packages/core/src/plugins/sandbox/types.ts index 22e50296db..e95545353d 100644 --- a/packages/core/src/plugins/sandbox/types.ts +++ b/packages/core/src/plugins/sandbox/types.ts @@ -75,6 +75,15 @@ export interface SandboxOptions { siteInfo?: { name: string; url: string; locale: string }; /** Email send callback, wired from the EmailPipeline by the runtime */ emailSend?: SandboxEmailSendCallback; + /** + * Media storage adapter for sandboxed plugin uploads and deletes. + * When provided, plugins with write:media can upload and delete files + * via ctx.media.upload() and ctx.media.delete(). + */ + mediaStorage?: { + upload(options: { key: string; body: Uint8Array; contentType: string }): Promise; + delete(key: string): Promise; + }; } /** @@ -137,6 +146,14 @@ export interface SandboxRunner { */ isAvailable(): boolean; + /** + * Check if the sandbox runtime is currently healthy. + * For in-process runners this always returns true. + * For sidecar-based runners (workerd), returns false if the + * child process has crashed and hasn't been restarted yet. + */ + isHealthy(): boolean; + /** * Load a sandboxed plugin from code. * @@ -161,6 +178,17 @@ export interface SandboxRunner { terminateAll(): Promise; } +/** + * Error thrown when the sandbox runtime is unavailable. + * This happens when the sidecar process has crashed or hasn't started. + */ +export class SandboxUnavailableError extends Error { + constructor(pluginId: string, reason: string) { + super(`Plugin sandbox unavailable for ${pluginId}: ${reason}`); + this.name = "SandboxUnavailableError"; + } +} + /** * Factory function type for creating sandbox runners. * Exported by platform adapters (e.g., @emdash-cms/adapter-cloudflare/sandbox). diff --git a/packages/marketplace/package.json b/packages/marketplace/package.json index f649ddcafe..617887ed4b 100644 --- a/packages/marketplace/package.json +++ b/packages/marketplace/package.json @@ -18,7 +18,7 @@ "zod": "^3.25.67" }, "devDependencies": { - "@types/better-sqlite3": "^7.6.13", + "@types/better-sqlite3": "catalog:", "@types/node": "catalog:", "better-sqlite3": "catalog:", "typescript": "catalog:", diff --git a/packages/workerd/package.json b/packages/workerd/package.json new file mode 100644 index 0000000000..fe19bddf1d --- /dev/null +++ b/packages/workerd/package.json @@ -0,0 +1,61 @@ +{ + "name": "@emdash-cms/sandbox-workerd", + "version": "0.0.1", + "description": "workerd-based plugin sandbox for EmDash on Node.js", + "type": "module", + "main": "dist/index.mjs", + "files": [ + "dist", + "src" + ], + "exports": { + ".": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./sandbox": { + "types": "./dist/sandbox/index.d.mts", + "default": "./dist/sandbox/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "test": "vitest run" + }, + "dependencies": { + "emdash": "workspace:*", + "ulidx": "^2.4.1" + }, + "peerDependencies": { + "kysely": ">=0.27.0", + "workerd": ">=1.0.0" + }, + "optionalDependencies": { + "miniflare": "^4.20250408.0" + }, + "devDependencies": { + "@types/better-sqlite3": "catalog:", + "better-sqlite3": "catalog:", + "kysely": "^0.27.0", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/emdash-cms/emdash.git", + "directory": "packages/workerd" + }, + "homepage": "https://github.com/emdash-cms/emdash", + "keywords": [ + "emdash", + "workerd", + "sandbox", + "plugins", + "isolation", + "v8-isolate" + ], + "author": "Benjamin Price", + "license": "MIT" +} diff --git a/packages/workerd/src/index.ts b/packages/workerd/src/index.ts new file mode 100644 index 0000000000..52a20a880d --- /dev/null +++ b/packages/workerd/src/index.ts @@ -0,0 +1 @@ +export { WorkerdSandboxRunner, createSandboxRunner } from "./sandbox/index.js"; diff --git a/packages/workerd/src/sandbox/backing-service.ts b/packages/workerd/src/sandbox/backing-service.ts new file mode 100644 index 0000000000..b4f9e0c01a --- /dev/null +++ b/packages/workerd/src/sandbox/backing-service.ts @@ -0,0 +1,122 @@ +/** + * Backing Service HTTP Handler + * + * Runs in the Node process for production workerd deployments. + * Receives HTTP requests from plugin workers running in workerd isolates. + * Each request is authenticated via a per-plugin auth token. + * + * This is a thin wrapper around createBridgeHandler that adds: + * - Auth token validation (extracting claims from the HMAC token) + * - Node http.IncomingMessage -> Request conversion + * - Response -> http.ServerResponse conversion + * + * The actual bridge logic (dispatch, capability enforcement, DB queries) + * lives in bridge-handler.ts and is shared with the dev runner. + */ + +import type { IncomingMessage, ServerResponse } from "node:http"; + +import { createBridgeHandler } from "./bridge-handler.js"; +import type { WorkerdSandboxRunner } from "./runner.js"; + +export interface BackingServiceHandler { + handler: (req: IncomingMessage, res: ServerResponse) => void; + removePlugin: (pluginId: string) => void; +} + +/** + * Create an HTTP request handler for the backing service. + */ +export function createBackingServiceHandler(runner: WorkerdSandboxRunner): BackingServiceHandler { + // Cache bridge handlers per pluginId to avoid re-creation + const handlerCache = new Map Promise>(); + + const handler = async (req: IncomingMessage, res: ServerResponse) => { + try { + // Parse auth token from Authorization header + const authHeader = req.headers.authorization; + if (!authHeader?.startsWith("Bearer ")) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing or invalid authorization" })); + return; + } + + const token = authHeader.slice(7); + const claims = runner.validateToken(token); + if (!claims) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid auth token" })); + return; + } + + // Get or create bridge handler for this plugin + const cacheKey = claims.pluginId; + let bridgeHandler = handlerCache.get(cacheKey); + if (!bridgeHandler) { + bridgeHandler = createBridgeHandler({ + pluginId: claims.pluginId, + version: claims.version, + capabilities: claims.capabilities, + allowedHosts: claims.allowedHosts, + storageCollections: claims.storageCollections, + storageConfig: runner.getPluginStorageConfig(claims.pluginId, claims.version) as + | Record }> + | undefined, + db: runner.db, + emailSend: () => runner.emailSend, + storage: runner.mediaStorage, + }); + handlerCache.set(cacheKey, bridgeHandler); + } + + // Convert Node request to web Request + const body = await readBody(req); + const url = `http://bridge${req.url || "/"}`; + const webRequest = new Request(url, { + method: req.method || "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + // Dispatch through the shared bridge handler + const webResponse = await bridgeHandler(webRequest); + const responseBody = await webResponse.text(); + + res.writeHead(webResponse.status, { "Content-Type": "application/json" }); + res.end(responseBody); + } catch (error) { + const statusCode = + error instanceof Error && "statusCode" in error + ? (error as Error & { statusCode: number }).statusCode + : 500; + const message = error instanceof Error ? error.message : "Internal error"; + res.writeHead(statusCode, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: message })); + } + }; + + return { + handler, + removePlugin(pluginId: string) { + handlerCache.delete(pluginId); + }, + }; +} + +const MAX_BRIDGE_BODY_BYTES = 10 * 1024 * 1024; + +async function readBody(req: IncomingMessage): Promise> { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of req) { + totalBytes += (chunk as Buffer).length; + if (totalBytes > MAX_BRIDGE_BODY_BYTES) { + const err = new Error("Request body too large"); + (err as Error & { statusCode: number }).statusCode = 413; + throw err; + } + chunks.push(chunk as Buffer); + } + const raw = Buffer.concat(chunks).toString(); + return raw ? (JSON.parse(raw) as Record) : {}; +} diff --git a/packages/workerd/src/sandbox/bridge-handler.ts b/packages/workerd/src/sandbox/bridge-handler.ts new file mode 100644 index 0000000000..afdd47db7a --- /dev/null +++ b/packages/workerd/src/sandbox/bridge-handler.ts @@ -0,0 +1,1214 @@ +/** + * Bridge Handler + * + * Handles bridge calls from sandboxed plugin workers. + * Used in two contexts: + * - Dev mode: as a miniflare outboundService function (Request -> Response) + * - Production: called from the backing service HTTP handler + * + * Each handler is scoped to a specific plugin with its capabilities. + * Capability enforcement happens here, not in the plugin. + * + * This implementation maintains behavioral parity with the Cloudflare + * PluginBridge (packages/cloudflare/src/sandbox/bridge.ts). Same inputs + * must produce same outputs, same return shapes, same error messages. + */ + +// @ts-ignore -- value exports used at runtime +import { createHttpAccess, createUnrestrictedHttpAccess, PluginStorageRepository } from "emdash"; +import type { Database } from "emdash"; +import type { SandboxEmailSendCallback } from "emdash"; +import { sql, type Kysely } from "kysely"; + +/** Validates collection/field names to prevent SQL injection */ +const COLLECTION_NAME_RE = /^[a-z][a-z0-9_]*$/; + +/** System columns that plugins cannot directly write to */ +const SYSTEM_COLUMNS = new Set([ + "id", + "slug", + "status", + "author_id", + "created_at", + "updated_at", + "published_at", + "scheduled_at", + "deleted_at", + "version", + "live_revision_id", + "draft_revision_id", +]); + +/** Minimal storage interface for media uploads and deletes */ +export interface BridgeStorage { + upload(options: { key: string; body: Uint8Array; contentType: string }): Promise; + delete(key: string): Promise; +} + +/** Per-collection storage config (matches manifest.storage entries) */ +export interface BridgeStorageCollectionConfig { + indexes?: Array; + uniqueIndexes?: Array; +} + +export interface BridgeHandlerOptions { + pluginId: string; + version: string; + capabilities: string[]; + allowedHosts: string[]; + /** Storage collection names declared by the plugin */ + storageCollections: string[]; + /** Full storage config (with indexes) for proper query/count delegation */ + storageConfig?: Record; + db: Kysely; + emailSend: () => SandboxEmailSendCallback | null; + /** Storage for media uploads. Optional; media/upload throws if not provided. */ + storage?: BridgeStorage | null; +} + +/** + * Create a bridge handler function scoped to a specific plugin. + * Returns an async function that takes a Request and returns a Response. + */ +export function createBridgeHandler( + opts: BridgeHandlerOptions, +): (request: Request) => Promise { + return async (request: Request): Promise => { + try { + const url = new URL(request.url); + const method = url.pathname.slice(1); + + let body: Record = {}; + if (request.method === "POST") { + const text = await request.text(); + if (text) { + body = JSON.parse(text) as Record; + } + } + + const result = await dispatch(opts, method, body); + return Response.json({ result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + return new Response(JSON.stringify({ error: message }), { + status: 500, + headers: { "Content-Type": "application/json" }, + }); + } + }; +} + +// ── Dispatch ───────────────────────────────────────────────────────────── + +async function dispatch( + opts: BridgeHandlerOptions, + method: string, + body: Record, +): Promise { + const { db, pluginId } = opts; + + switch (method) { + // ── KV (stored in _plugin_storage with collection='__kv') ──────── + case "kv/get": + return kvGet(db, pluginId, requireString(body, "key")); + case "kv/set": + return kvSet(db, pluginId, requireString(body, "key"), body.value); + case "kv/delete": + return kvDelete(db, pluginId, requireString(body, "key")); + case "kv/list": + return kvList(db, pluginId, (body.prefix as string) ?? ""); + + // ── Content ───────────────────────────────────────────────────── + case "content/get": + requireCapability(opts, "read:content"); + return contentGet(db, requireString(body, "collection"), requireString(body, "id")); + case "content/list": + requireCapability(opts, "read:content"); + return contentList(db, requireString(body, "collection"), body); + case "content/create": + requireCapability(opts, "write:content"); + return contentCreate( + db, + requireString(body, "collection"), + body.data as Record, + ); + case "content/update": + requireCapability(opts, "write:content"); + return contentUpdate( + db, + requireString(body, "collection"), + requireString(body, "id"), + body.data as Record, + ); + case "content/delete": + requireCapability(opts, "write:content"); + return contentDelete(db, requireString(body, "collection"), requireString(body, "id")); + case "content/createMany": + requireCapability(opts, "write:content"); + return contentCreateMany( + db, + requireString(body, "collection"), + body.items as Array>, + ); + case "content/updateMany": + requireCapability(opts, "write:content"); + return contentUpdateMany( + db, + requireString(body, "collection"), + body.items as Array<{ id: string; data: Record }>, + ); + case "content/deleteMany": + requireCapability(opts, "write:content"); + return contentDeleteMany(db, requireString(body, "collection"), body.ids as string[]); + + // ── Media ─────────────────────────────────────────────────────── + case "media/get": + requireCapability(opts, "read:media"); + return mediaGet(db, requireString(body, "id")); + case "media/list": + requireCapability(opts, "read:media"); + return mediaList(db, body); + case "media/upload": + requireCapability(opts, "write:media"); + return mediaUpload( + db, + requireString(body, "filename"), + requireString(body, "contentType"), + body.bytes as string | number[], + body.encoding as string | undefined, + opts.storage, + ); + case "media/delete": + requireCapability(opts, "write:media"); + return mediaDelete(db, requireString(body, "id"), opts.storage); + + // ── HTTP ──────────────────────────────────────────────────────── + case "http/fetch": + requireCapability(opts, "network:fetch"); + return httpFetch(requireString(body, "url"), body.init, opts); + + // ── Email ─────────────────────────────────────────────────────── + case "email/send": { + requireCapability(opts, "email:send"); + const message = body.message as { + to: string; + subject: string; + text: string; + html?: string; + }; + if (!message?.to || !message?.subject || !message?.text) { + throw new Error("email/send requires message with to, subject, and text"); + } + const emailSend = opts.emailSend(); + if (!emailSend) throw new Error("Email is not configured. No email provider is available."); + await emailSend(message, pluginId); + return null; + } + + // ── Users ─────────────────────────────────────────────────────── + case "users/get": + requireCapability(opts, "read:users"); + return userGet(db, requireString(body, "id")); + case "users/getByEmail": + requireCapability(opts, "read:users"); + return userGetByEmail(db, requireString(body, "email")); + case "users/list": + requireCapability(opts, "read:users"); + return userList(db, body); + + // ── Storage (document store, scoped to declared collections) ──── + case "storage/get": + validateStorageCollection(opts, requireString(body, "collection")); + return storageGet(opts, requireString(body, "collection"), requireString(body, "id")); + case "storage/put": + validateStorageCollection(opts, requireString(body, "collection")); + return storagePut( + opts, + requireString(body, "collection"), + requireString(body, "id"), + body.data, + ); + case "storage/delete": + validateStorageCollection(opts, requireString(body, "collection")); + return storageDelete(opts, requireString(body, "collection"), requireString(body, "id")); + case "storage/query": + validateStorageCollection(opts, requireString(body, "collection")); + return storageQuery(opts, requireString(body, "collection"), body); + case "storage/count": + validateStorageCollection(opts, requireString(body, "collection")); + return storageCount( + opts, + requireString(body, "collection"), + body.where as Record | undefined, + ); + case "storage/getMany": + validateStorageCollection(opts, requireString(body, "collection")); + return storageGetMany(opts, requireString(body, "collection"), body.ids as string[]); + case "storage/putMany": + validateStorageCollection(opts, requireString(body, "collection")); + return storagePutMany( + opts, + requireString(body, "collection"), + body.items as Array<{ id: string; data: unknown }>, + ); + case "storage/deleteMany": + validateStorageCollection(opts, requireString(body, "collection")); + return storageDeleteMany(opts, requireString(body, "collection"), body.ids as string[]); + + // ── Logging ───────────────────────────────────────────────────── + case "log": { + const level = requireString(body, "level") as "debug" | "info" | "warn" | "error"; + const msg = requireString(body, "msg"); + console[level](`[plugin:${pluginId}]`, msg, body.data ?? ""); + return null; + } + + default: + // All outbound fetch() from sandboxed plugins is routed to the + // backing service via workerd's globalOutbound config. If a plugin + // calls plain fetch("https://anywhere.com/path") instead of + // ctx.http.fetch(), we land here. This is intentional: plugins + // must use ctx.http.fetch (which goes through the http/fetch + // bridge with capability + host enforcement) to reach the network. + throw new Error(`Unknown bridge method: ${method}`); + } +} + +// ── Validation ─────────────────────────────────────────────────────────── + +function requireString(body: Record, key: string): string { + const value = body[key]; + if (typeof value !== "string") throw new Error(`Missing required string parameter: ${key}`); + return value; +} + +function requireCapability(opts: BridgeHandlerOptions, capability: string): void { + // Strict capability check matching the Cloudflare PluginBridge. + // We do NOT imply write → read here: a plugin that declares only + // write:content cannot call ctx.content.get/list. The plugin must + // declare read:content explicitly. This matches the Cloudflare bridge + // behavior and ensures sandboxed plugins behave the same on both runners. + // + // Note: the in-process PluginContextFactory in core does build the read + // API onto the write object, so a trusted plugin can read with only + // write:content. The sandbox bridges are stricter on purpose — they + // enforce the manifest as written. + // + // The one exception: network:fetch:any is documented as a strict + // superset of network:fetch, so the broader capability satisfies it. + if (capability === "network:fetch" && opts.capabilities.includes("network:fetch:any")) return; + if (!opts.capabilities.includes(capability)) { + // Error message matches Cloudflare PluginBridge format + throw new Error(`Missing capability: ${capability}`); + } +} + +function validateStorageCollection(opts: BridgeHandlerOptions, collection: string): void { + if (!opts.storageCollections.includes(collection)) { + // Error message matches Cloudflare PluginBridge format + throw new Error(`Storage collection not declared: ${collection}`); + } +} + +function validateCollectionName(collection: string): void { + if (!COLLECTION_NAME_RE.test(collection)) { + throw new Error(`Invalid collection name: ${collection}`); + } +} + +// ── Value serialization (matches Cloudflare bridge) ────────────────────── + +function serializeValue(value: unknown): unknown { + if (value === null || value === undefined) return null; + if (typeof value === "boolean") return value ? 1 : 0; + if (typeof value === "object") return JSON.stringify(value); + return value; +} + +/** + * Transform a raw DB row into the content item shape returned to plugins. + * Matches the Cloudflare bridge's rowToContentItem. + */ +function rowToContentItem( + collection: string, + row: Record, +): { + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; +} { + const data: Record = {}; + for (const [key, value] of Object.entries(row)) { + if (!SYSTEM_COLUMNS.has(key)) { + if (typeof value === "string" && (value.startsWith("{") || value.startsWith("["))) { + try { + data[key] = JSON.parse(value); + } catch { + data[key] = value; + } + } else if (value !== null) { + data[key] = value; + } + } + } + + return { + id: typeof row.id === "string" ? row.id : String(row.id), + type: collection, + data, + createdAt: typeof row.created_at === "string" ? row.created_at : new Date().toISOString(), + updatedAt: typeof row.updated_at === "string" ? row.updated_at : new Date().toISOString(), + }; +} + +// ── KV Operations ──────────────────────────────────────────────────────── +// Uses _plugin_storage with collection='__kv' (matching Cloudflare bridge) + +async function kvGet(db: Kysely, pluginId: string, key: string): Promise { + const row = await db + .selectFrom("_plugin_storage" as keyof Database) + .where("plugin_id", "=", pluginId) + .where("collection", "=", "__kv") + .where("id", "=", key) + .select("data") + .executeTakeFirst(); + if (!row) return null; + try { + return JSON.parse(row.data as string); + } catch { + return row.data; + } +} + +async function kvSet( + db: Kysely, + pluginId: string, + key: string, + value: unknown, +): Promise { + const serialized = JSON.stringify(value); + const now = new Date().toISOString(); + await db + .insertInto("_plugin_storage" as keyof Database) + .values({ + plugin_id: pluginId, + collection: "__kv", + id: key, + data: serialized, + created_at: now, + updated_at: now, + } as never) + .onConflict((oc) => + oc.columns(["plugin_id", "collection", "id"] as never[]).doUpdateSet({ + data: serialized, + updated_at: now, + } as never), + ) + .execute(); +} + +async function kvDelete(db: Kysely, pluginId: string, key: string): Promise { + const result = await db + .deleteFrom("_plugin_storage" as keyof Database) + .where("plugin_id", "=", pluginId) + .where("collection", "=", "__kv") + .where("id", "=", key) + .executeTakeFirst(); + return BigInt(result.numDeletedRows) > 0n; +} + +async function kvList( + db: Kysely, + pluginId: string, + prefix: string, +): Promise> { + const rows = await db + .selectFrom("_plugin_storage" as keyof Database) + .where("plugin_id", "=", pluginId) + .where("collection", "=", "__kv") + .where("id", "like", `${prefix}%`) + .select(["id", "data"]) + .execute(); + + return rows.map((r) => ({ + key: r.id as string, + value: JSON.parse(r.data as string), + })); +} + +// ── Content Operations ─────────────────────────────────────────────────── + +async function contentGet( + db: Kysely, + collection: string, + id: string, +): Promise<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; +} | null> { + validateCollectionName(collection); + try { + const row = await db + .selectFrom(`ec_${collection}` as keyof Database) + .where("id", "=", id) + .where("deleted_at", "is", null) + .selectAll() + .executeTakeFirst(); + if (!row) return null; + return rowToContentItem(collection, row as Record); + } catch { + return null; + } +} + +async function contentList( + db: Kysely, + collection: string, + opts: Record, +): Promise<{ + items: Array<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; + }>; + cursor?: string; + hasMore: boolean; +}> { + validateCollectionName(collection); + const limit = Math.max(1, Math.min(Number(opts.limit) || 50, 100)); + try { + let query = db + .selectFrom(`ec_${collection}` as keyof Database) + .where("deleted_at", "is", null) + .selectAll() + .orderBy("id", "desc"); + + if (typeof opts.cursor === "string") { + query = query.where("id", "<", opts.cursor); + } + + const rows = await query.limit(limit + 1).execute(); + const pageRows = rows.slice(0, limit); + const items = pageRows.map((row) => + rowToContentItem(collection, row as Record), + ); + const hasMore = rows.length > limit; + + return { + items, + cursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined, + hasMore, + }; + } catch { + return { items: [], hasMore: false }; + } +} + +async function contentCreate( + db: Kysely, + collection: string, + data: Record, +): Promise<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; +}> { + validateCollectionName(collection); + + // Generate ULID for the new content item + const { ulid } = await import("ulidx"); + const id = ulid(); + const now = new Date().toISOString(); + + // Build insert values: system columns + user data columns + const values: Record = { + id, + slug: typeof data.slug === "string" ? data.slug : null, + status: typeof data.status === "string" ? data.status : "draft", + author_id: typeof data.author_id === "string" ? data.author_id : null, + created_at: now, + updated_at: now, + version: 1, + }; + + // Add user data fields (skip system columns, validate names) + for (const [key, value] of Object.entries(data)) { + if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_RE.test(key)) { + values[key] = serializeValue(value); + } + } + + await db + .insertInto(`ec_${collection}` as keyof Database) + .values(values as never) + .execute(); + + // Re-read the created row + const created = await db + .selectFrom(`ec_${collection}` as keyof Database) + .where("id", "=", id) + .where("deleted_at", "is", null) + .selectAll() + .executeTakeFirst(); + + if (!created) { + return { id, type: collection, data: {}, createdAt: now, updatedAt: now }; + } + return rowToContentItem(collection, created as Record); +} + +async function contentUpdate( + db: Kysely, + collection: string, + id: string, + data: Record, +): Promise<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; +}> { + validateCollectionName(collection); + + const now = new Date().toISOString(); + + // Build update: always bump updated_at and version + let query = db + .updateTable(`ec_${collection}` as keyof Database) + .set({ updated_at: now } as never) + .set({ version: sql`version + 1` } as never) + .where("id", "=", id) + .where("deleted_at", "is", null); + + // System field updates + if (typeof data.status === "string") { + query = query.set({ status: data.status } as never); + } + if (data.slug !== undefined) { + query = query.set({ slug: typeof data.slug === "string" ? data.slug : null } as never); + } + + // User data fields + for (const [key, value] of Object.entries(data)) { + if (!SYSTEM_COLUMNS.has(key) && COLLECTION_NAME_RE.test(key)) { + query = query.set({ [key]: serializeValue(value) } as never); + } + } + + const result = await query.executeTakeFirst(); + if (BigInt(result.numUpdatedRows) === 0n) { + throw new Error(`Content not found or deleted: ${collection}/${id}`); + } + + // Re-read the updated row + const updated = await db + .selectFrom(`ec_${collection}` as keyof Database) + .where("id", "=", id) + .where("deleted_at", "is", null) + .selectAll() + .executeTakeFirst(); + + if (!updated) { + throw new Error(`Content not found: ${collection}/${id}`); + } + return rowToContentItem(collection, updated as Record); +} + +async function contentDelete( + db: Kysely, + collection: string, + id: string, +): Promise { + validateCollectionName(collection); + + // Soft-delete: set deleted_at timestamp (matching Cloudflare bridge) + const now = new Date().toISOString(); + const result = await db + .updateTable(`ec_${collection}` as keyof Database) + .set({ deleted_at: now, updated_at: now } as never) + .where("id", "=", id) + .where("deleted_at", "is", null) + .executeTakeFirst(); + + return BigInt(result.numUpdatedRows) > 0n; +} + +// ── Batch Content Operations ───────────────────────────────────────────── + +const MAX_BATCH_SIZE = 100; + +async function contentCreateMany( + db: Kysely, + collection: string, + items: Array>, +): Promise< + Array<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; + }> +> { + if (items.length > MAX_BATCH_SIZE) { + throw new Error(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`); + } + return db.transaction().execute(async (trx) => { + const results = []; + for (const data of items) { + results.push(await contentCreate(trx, collection, data)); + } + return results; + }); +} + +async function contentUpdateMany( + db: Kysely, + collection: string, + items: Array<{ id: string; data: Record }>, +): Promise< + Array<{ + id: string; + type: string; + data: Record; + createdAt: string; + updatedAt: string; + }> +> { + if (items.length > MAX_BATCH_SIZE) { + throw new Error(`Batch size ${items.length} exceeds maximum of ${MAX_BATCH_SIZE}`); + } + return db.transaction().execute(async (trx) => { + const results = []; + for (const item of items) { + results.push(await contentUpdate(trx, collection, item.id, item.data)); + } + return results; + }); +} + +async function contentDeleteMany( + db: Kysely, + collection: string, + ids: string[], +): Promise { + if (ids.length > MAX_BATCH_SIZE) { + throw new Error(`Batch size ${ids.length} exceeds maximum of ${MAX_BATCH_SIZE}`); + } + return db.transaction().execute(async (trx) => { + let count = 0; + for (const id of ids) { + const deleted = await contentDelete(trx, collection, id); + if (deleted) count++; + } + return count; + }); +} + +// ── Media Operations ───────────────────────────────────────────────────── + +interface MediaRow { + id: string; + filename: string; + mime_type: string; + size: number | null; + storage_key: string; + created_at: string; +} + +function rowToMediaItem(row: MediaRow) { + return { + id: row.id, + filename: row.filename, + mimeType: row.mime_type, + size: row.size, + url: `/_emdash/api/media/file/${row.storage_key}`, + createdAt: row.created_at, + }; +} + +async function mediaGet( + db: Kysely, + id: string, +): Promise<{ + id: string; + filename: string; + mimeType: string; + size: number | null; + url: string; + createdAt: string; +} | null> { + const row = await db + .selectFrom("media" as keyof Database) + .where("id", "=", id) + .selectAll() + .executeTakeFirst(); + if (!row) return null; + return rowToMediaItem(row as unknown as MediaRow); +} + +async function mediaList( + db: Kysely, + opts: Record, +): Promise<{ + items: Array<{ + id: string; + filename: string; + mimeType: string; + size: number | null; + url: string; + createdAt: string; + }>; + cursor?: string; + hasMore: boolean; +}> { + const limit = Math.max(1, Math.min(Number(opts.limit) || 50, 100)); + + // Only return ready items (matching Cloudflare bridge) + let query = db + .selectFrom("media" as keyof Database) + .where("status", "=", "ready") + .selectAll() + .orderBy("id", "desc"); + + if (typeof opts.mimeType === "string") { + query = query.where("mime_type", "like", `${opts.mimeType}%`); + } + + if (typeof opts.cursor === "string") { + query = query.where("id", "<", opts.cursor); + } + + const rows = await query.limit(limit + 1).execute(); + const pageRows = rows.slice(0, limit); + const items = pageRows.map((row) => rowToMediaItem(row as unknown as MediaRow)); + const hasMore = rows.length > limit; + + return { + items, + cursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined, + hasMore, + }; +} + +const ALLOWED_MIME_PREFIXES = ["image/", "video/", "audio/", "application/pdf"]; +const FILE_EXT_RE = /^\.[a-z0-9]{1,10}$/i; + +async function mediaUpload( + db: Kysely, + filename: string, + contentType: string, + bytes: string | number[], + encoding: string | undefined, + storage?: BridgeStorage | null, +): Promise<{ mediaId: string; storageKey: string; url: string }> { + if (!storage) { + throw new Error( + "Media storage is not configured. Cannot upload files without a storage adapter.", + ); + } + + if (!ALLOWED_MIME_PREFIXES.some((prefix) => contentType.startsWith(prefix))) { + throw new Error( + `Unsupported content type: ${contentType}. Allowed: image/*, video/*, audio/*, application/pdf`, + ); + } + + const { ulid } = await import("ulidx"); + const mediaId = ulid(); + const basename = filename.includes("/") + ? filename.slice(filename.lastIndexOf("/") + 1) + : filename; + const rawExt = basename.includes(".") ? basename.slice(basename.lastIndexOf(".")) : ""; + const ext = FILE_EXT_RE.test(rawExt) ? rawExt : ""; + const storageKey = `${mediaId}${ext}`; + const now = new Date().toISOString(); + let byteArray: Uint8Array; + if (encoding === "base64" && typeof bytes === "string") { + const binary = atob(bytes); + byteArray = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) byteArray[i] = binary.charCodeAt(i); + } else { + byteArray = new Uint8Array(bytes as number[]); + } + + // Write bytes to storage first, then create DB record. + // If DB insert fails, delete the storage object so we don't leak files. + // (cleanupPendingUploads only deletes 'pending' DB rows; objects with no + // row are invisible to it.) + await storage.upload({ key: storageKey, body: byteArray, contentType }); + + try { + await db + .insertInto("media" as keyof Database) + .values({ + id: mediaId, + filename, + mime_type: contentType, + size: byteArray.byteLength, + storage_key: storageKey, + status: "ready", + created_at: now, + } as never) + .execute(); + } catch (error) { + // Best-effort cleanup of the orphaned storage object. Log if cleanup + // itself fails so operators see the leak instead of silently dropping it. + try { + await storage.delete(storageKey); + } catch (cleanupError) { + console.warn( + `[bridge] media/upload: DB insert failed and storage cleanup failed for ${storageKey}. ` + + `Storage object is leaked.`, + cleanupError, + ); + } + throw error; + } + + return { + mediaId, + storageKey, + url: `/_emdash/api/media/file/${storageKey}`, + }; +} + +async function mediaDelete( + db: Kysely, + id: string, + storage?: BridgeStorage | null, +): Promise { + // Look up storage key before deleting + const media = await db + .selectFrom("media" as keyof Database) + .where("id", "=", id) + .select("storage_key") + .executeTakeFirst(); + + if (!media) return false; + + // Delete the DB row first + const result = await db + .deleteFrom("media" as keyof Database) + .where("id", "=", id) + .executeTakeFirst(); + + // Delete the storage object. If this fails, log but don't throw — + // the DB row is already deleted and the orphan cleanup cron will + // catch it. Matches the Cloudflare bridge's behavior. + if (storage && (media as { storage_key: string }).storage_key) { + try { + await storage.delete((media as { storage_key: string }).storage_key); + } catch (error) { + console.warn( + `[bridge] Failed to delete storage object ${(media as { storage_key: string }).storage_key}:`, + error, + ); + } + } + + return BigInt(result.numDeletedRows) > 0n; +} + +// ── HTTP Operations ────────────────────────────────────────────────────── + +/** Marshaled RequestInit shape sent over the bridge from the wrapper */ +interface MarshaledRequestInit { + method?: string; + redirect?: RequestRedirect; + /** List of [name, value] pairs to preserve multi-value headers */ + headers?: Array<[string, string]>; + bodyType?: "string" | "base64" | "formdata"; + body?: unknown; +} + +/** + * Reverse the wrapper's marshalRequestInit() to reconstruct a real RequestInit + * with proper Headers, binary bodies, and FormData. + */ +function unmarshalRequestInit( + marshaled: MarshaledRequestInit | undefined, +): RequestInit | undefined { + if (!marshaled) return undefined; + const init: RequestInit = {}; + if (marshaled.method) init.method = marshaled.method; + if (marshaled.redirect) init.redirect = marshaled.redirect; + if (marshaled.headers && marshaled.headers.length > 0) { + // Use a Headers instance and append() so duplicates are preserved + // (e.g., multiple Set-Cookie). A plain Record would collapse them. + const headers = new Headers(); + for (const [name, value] of marshaled.headers) { + headers.append(name, value); + } + init.headers = headers; + } + if (marshaled.bodyType && marshaled.body !== undefined) { + switch (marshaled.bodyType) { + case "string": + init.body = marshaled.body as string; + break; + case "base64": + init.body = Buffer.from(marshaled.body as string, "base64"); + break; + case "formdata": { + const fd = new FormData(); + const parts = marshaled.body as Array<{ + name: string; + value: string; + filename?: string; + type?: string; + isBlob?: boolean; + }>; + for (const part of parts) { + if (part.isBlob) { + const bytes = Buffer.from(part.value, "base64"); + const blob = new Blob([bytes], { type: part.type || "application/octet-stream" }); + fd.append(part.name, blob, part.filename); + } else { + fd.append(part.name, part.value); + } + } + init.body = fd; + break; + } + } + } + return init; +} + +async function httpFetch( + url: string, + marshaledInit: unknown, + opts: BridgeHandlerOptions, +): Promise<{ + status: number; + statusText: string; + headers: Record; + bodyBase64: string; +}> { + const hasAnyFetch = opts.capabilities.includes("network:fetch:any"); + const httpAccess = hasAnyFetch + ? createUnrestrictedHttpAccess(opts.pluginId) + : createHttpAccess(opts.pluginId, opts.allowedHosts || []); + + const init = unmarshalRequestInit(marshaledInit as MarshaledRequestInit | undefined); + const res = await httpAccess.fetch(url, init); + // Read as bytes to preserve binary content (images, audio, etc.) + const bytes = new Uint8Array(await res.arrayBuffer()); + const headers: Record = {}; + res.headers.forEach((v, k) => { + headers[k] = v; + }); + return { + status: res.status, + statusText: res.statusText, + headers, + bodyBase64: Buffer.from(bytes).toString("base64"), + }; +} + +// ── User Operations ────────────────────────────────────────────────────── + +interface UserRow { + id: string; + email: string; + name: string | null; + role: number; + created_at: string; +} + +function rowToUser(row: UserRow) { + return { + id: row.id, + email: row.email, + name: row.name, + role: row.role, + createdAt: row.created_at, + }; +} + +async function userGet( + db: Kysely, + id: string, +): Promise<{ + id: string; + email: string; + name: string | null; + role: number; + createdAt: string; +} | null> { + const row = await db + .selectFrom("users" as keyof Database) + .where("id", "=", id) + .select(["id", "email", "name", "role", "created_at"]) + .executeTakeFirst(); + if (!row) return null; + return rowToUser(row as unknown as UserRow); +} + +async function userGetByEmail( + db: Kysely, + email: string, +): Promise<{ + id: string; + email: string; + name: string | null; + role: number; + createdAt: string; +} | null> { + const row = await db + .selectFrom("users" as keyof Database) + .where("email", "=", email.toLowerCase()) + .select(["id", "email", "name", "role", "created_at"]) + .executeTakeFirst(); + if (!row) return null; + return rowToUser(row as unknown as UserRow); +} + +async function userList( + db: Kysely, + opts: Record, +): Promise<{ + items: Array<{ id: string; email: string; name: string | null; role: number; createdAt: string }>; + nextCursor?: string; +}> { + const limit = Math.max(1, Math.min(Number(opts.limit) || 50, 100)); + + let query = db + .selectFrom("users" as keyof Database) + .select(["id", "email", "name", "role", "created_at"]) + .orderBy("id", "desc"); + + if (opts.role !== undefined) { + query = query.where("role", "=", Number(opts.role)); + } + if (typeof opts.cursor === "string") { + query = query.where("id", "<", opts.cursor); + } + + const rows = await query.limit(limit + 1).execute(); + const pageRows = rows.slice(0, limit); + const items = pageRows.map((row) => rowToUser(row as unknown as UserRow)); + const hasMore = rows.length > limit; + + return { + items, + nextCursor: hasMore && items.length > 0 ? items.at(-1)!.id : undefined, + }; +} + +// ── Storage Operations ─────────────────────────────────────────────────── + +/** + * Construct a PluginStorageRepository for the requested collection. + * Uses the indexes from the plugin's storage config (if provided) so + * query/count operations support the same WHERE/ORDER BY clauses as + * in-process plugins. + */ +function getStorageRepo(opts: BridgeHandlerOptions, collection: string): PluginStorageRepository { + const config = opts.storageConfig?.[collection]; + // Merge unique indexes into the indexes list since both are queryable + const allIndexes: Array = [ + ...(config?.indexes ?? []), + ...(config?.uniqueIndexes ?? []), + ]; + return new PluginStorageRepository(opts.db, opts.pluginId, collection, allIndexes); +} + +async function storageGet( + opts: BridgeHandlerOptions, + collection: string, + id: string, +): Promise { + return getStorageRepo(opts, collection).get(id); +} + +async function storagePut( + opts: BridgeHandlerOptions, + collection: string, + id: string, + data: unknown, +): Promise { + await getStorageRepo(opts, collection).put(id, data); +} + +async function storageDelete( + opts: BridgeHandlerOptions, + collection: string, + id: string, +): Promise { + return getStorageRepo(opts, collection).delete(id); +} + +async function storageQuery( + opts: BridgeHandlerOptions, + collection: string, + queryOpts: Record, +): Promise<{ items: Array<{ id: string; data: unknown }>; hasMore: boolean; cursor?: string }> { + const repo = getStorageRepo(opts, collection); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record + const result = await repo.query({ + where: queryOpts.where as never, + orderBy: queryOpts.orderBy as Record | undefined, + limit: + typeof queryOpts.limit === "number" ? Math.max(1, Math.min(queryOpts.limit, 100)) : undefined, + cursor: typeof queryOpts.cursor === "string" ? queryOpts.cursor : undefined, + }); + return { + items: result.items, + hasMore: result.hasMore, + cursor: result.cursor, + }; +} + +async function storageCount( + opts: BridgeHandlerOptions, + collection: string, + where?: Record, +): Promise { + const repo = getStorageRepo(opts, collection); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- WhereClause is structurally Record + return repo.count(where as never); +} + +async function storageGetMany( + opts: BridgeHandlerOptions, + collection: string, + ids: string[], +): Promise> { + if (!ids || ids.length === 0) return []; + const repo = getStorageRepo(opts, collection); + const result = await repo.getMany(ids); + // Return as a list of [id, data] pairs rather than a plain object so + // special property names like "__proto__" survive transport. The wrapper + // reconstructs a Map from these entries. + return [...result.entries()]; +} + +async function storagePutMany( + opts: BridgeHandlerOptions, + collection: string, + items: Array<{ id: string; data: unknown }>, +): Promise { + if (!items || items.length === 0) return; + await getStorageRepo(opts, collection).putMany(items); +} + +async function storageDeleteMany( + opts: BridgeHandlerOptions, + collection: string, + ids: string[], +): Promise { + if (!ids || ids.length === 0) return 0; + return getStorageRepo(opts, collection).deleteMany(ids); +} diff --git a/packages/workerd/src/sandbox/capnp.ts b/packages/workerd/src/sandbox/capnp.ts new file mode 100644 index 0000000000..4fb0db69ce --- /dev/null +++ b/packages/workerd/src/sandbox/capnp.ts @@ -0,0 +1,138 @@ +/** + * Cap'n Proto Config Generator for workerd + * + * Generates workerd configuration from plugin manifests. + * Each plugin becomes a nanoservice with: + * - Its own listening socket (for hook/route invocation from Node) + * - An external service definition for the Node backing service + * - globalOutbound set to the backing service (all fetch() calls route + * through the backing service, which enforces capability checks) + */ + +import type { PluginManifest } from "emdash"; + +/** For string values in capnp config (service/socket names) */ +const SAFE_NAME_RE = /[^a-z0-9_-]/gi; +const NON_ALNUM_RE = /[^a-z0-9]+/i; + +/** Convert a plugin ID to a camelCase capnp identifier. + * capnp requires camelCase for const declarations (no underscores or hyphens). */ +function toCapnpId(pluginId: string): string { + const parts = pluginId.split(NON_ALNUM_RE).filter(Boolean); + return parts + .map((p, i) => + i === 0 ? p.toLowerCase() : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase(), + ) + .join(""); +} + +interface LoadedPlugin { + manifest: PluginManifest; + code: string; + port: number; + token: string; +} + +interface CapnpOptions { + plugins: Map; + backingServiceAddress: string; + configDir: string; + /** Filename (relative to configDir) of the shared "emdash" shim module. */ + emdashShimFile: string; +} + +/** + * Generate a workerd capnp configuration file. + * + * Each plugin gets its own worker (nanoservice) with: + * - A listener socket on its assigned port + * - Modules for wrapper + plugin code + * - globalOutbound pointing to the backing service external server + * (all outbound fetch() goes through the backing service for + * capability enforcement, SSRF protection, and host allowlist checks) + * + * KNOWN LIMITATION on resource limits: + * Standalone workerd does NOT support per-worker cpuMs/memoryMb/subrequests + * limits — those are Cloudflare platform features, not workerd capnp options. + * The only limit we enforce on the Node path is wallTimeMs, which is wrapped + * via Promise.race in WorkerdSandboxedPlugin.invokeHook/invokeRoute. + * For true CPU/memory isolation, deploy on Cloudflare Workers. + */ +export function generateCapnpConfig(options: CapnpOptions): string { + const { plugins, backingServiceAddress, emdashShimFile } = options; + + const lines: string[] = [ + `# Auto-generated workerd configuration for EmDash plugin sandbox`, + `# Generated at: ${new Date().toISOString()}`, + `# Plugins: ${plugins.size}`, + ``, + `using Workerd = import "/workerd/workerd.capnp";`, + ``, + `const config :Workerd.Config = (`, + ` services = [`, + // External service: the Node backing service + ` (name = "emdash-backing", external = (address = "${backingServiceAddress}")),`, + ]; + + // Add a service + socket for each plugin + const socketEntries: string[] = []; + + for (const [pluginId, plugin] of plugins) { + const constId = toCapnpId(pluginId); + const safeName = pluginId.replace(SAFE_NAME_RE, "_"); + + lines.push(` (name = "plugin-${safeName}", worker = .plugin${constId}),`); + socketEntries.push( + ` (name = "socket-${safeName}", address = "127.0.0.1:${plugin.port}", service = "plugin-${safeName}"),`, + ); + } + + lines.push(` ],`); + + // Socket definitions + lines.push(` sockets = [`); + for (const socket of socketEntries) { + lines.push(socket); + } + lines.push(` ],`); + lines.push(`);`); + lines.push(``); + + // Worker definitions for each plugin + for (const [pluginId] of plugins) { + const constId = toCapnpId(pluginId); + const safeName = pluginId.replace(SAFE_NAME_RE, "_"); + const wrapperFile = `${safeName}-wrapper.js`; + const pluginFile = `${safeName}-plugin.js`; + + lines.push(`const plugin${constId} :Workerd.Worker = (`); + lines.push(` modules = [`); + lines.push(` (name = "worker.js", esModule = embed "${wrapperFile}"),`); + lines.push(` (name = "sandbox-plugin.js", esModule = embed "${pluginFile}"),`); + lines.push(` (name = "emdash", esModule = embed "${emdashShimFile}"),`); + lines.push(` ],`); + lines.push(` compatibilityDate = "2025-01-01",`); + lines.push(` compatibilityFlags = ["nodejs_compat"],`); + // globalOutbound routes ALL outbound fetch() calls from the plugin + // through the backing service. This is intentional security posture: + // + // - Bridge calls (e.g., fetch("http://bridge/content/get")) are + // dispatched normally by the backing service path router. + // - Direct fetch() calls to arbitrary URLs (e.g., fetch("https://evil.com")) + // also arrive at the backing service. They will NOT match any known + // bridge method and will return 500 "Unknown bridge method". + // + // In other words: plugins cannot reach the internet by calling plain + // fetch(). They must use ctx.http.fetch(), which goes through the + // http/fetch bridge handler, which enforces network:fetch capability + // and the allowedHosts allowlist. + lines.push(` globalOutbound = "emdash-backing",`); + // Note: workerd capnp config does not support per-worker cpu/memory + // limits. Wall-time is enforced in WorkerdSandboxedPlugin via + // Promise.race. See generateCapnpConfig docstring above. + lines.push(`);`); + lines.push(``); + } + + return lines.join("\n"); +} diff --git a/packages/workerd/src/sandbox/dev-runner.ts b/packages/workerd/src/sandbox/dev-runner.ts new file mode 100644 index 0000000000..47a22760d8 --- /dev/null +++ b/packages/workerd/src/sandbox/dev-runner.ts @@ -0,0 +1,317 @@ +/** + * Miniflare Dev Runner + * + * Uses miniflare for plugin sandboxing during development. + * Provides the same SandboxRunner interface as WorkerdSandboxRunner + * but uses miniflare's serviceBindings-as-functions pattern instead + * of raw workerd + capnp + HTTP backing service. + * + * Advantages over raw workerd in dev: + * - No HTTP backing service needed (bridge calls are Node functions) + * - No capnp config generation + * - No child process management + * - Faster startup + */ + +import { randomBytes } from "node:crypto"; +import { createRequire } from "node:module"; + +import type { + SandboxRunner, + SandboxedPluginInstance, + SandboxEmailSendCallback, + SandboxOptions, + SerializedRequest, +} from "emdash"; + +const DEFAULT_WALL_TIME_MS = 30_000; +import type { PluginManifest } from "emdash"; + +import { createBridgeHandler } from "./bridge-handler.js"; +import { generatePluginWrapper } from "./wrapper.js"; + +const SAFE_ID_RE = /[^a-z0-9_-]/gi; + +/** + * Stub for the "emdash" module that sandbox-entry plugins import to get + * `definePlugin`. The marketplace bundler inlines this via an alias, but + * statically-loaded sandboxed plugins (from `sandboxed: [...]`) embed + * their `dist/sandbox-entry.mjs` as-is, which still has the bare import. + * Providing the module here keeps that path working without rebuilding + * every plugin. Mirrors `EMDASH_SHIM` in @emdash-cms/cloudflare. + */ +const EMDASH_SHIM = "export const definePlugin = (d) => d;\n"; + +/** + * Miniflare-based sandbox runner for development. + */ +export class MiniflareDevRunner implements SandboxRunner { + private options: SandboxOptions; + private siteInfo?: { name: string; url: string; locale: string }; + private emailSendCallback: SandboxEmailSendCallback | null = null; + + /** Miniflare instance (lazily created) */ + private mf: InstanceType | null = null; + + /** Loaded plugins */ + private plugins = new Map(); + + /** Whether miniflare is running */ + private running = false; + + /** + * Per-startup token sent on every hook/route invocation. Plugins reject + * requests without this token. In dev mode the plugin worker is only + * reachable through miniflare's dispatchFetch, but we still wire the + * token for consistency with production and so the wrapper template + * is identical in both modes. + */ + private devInvokeToken: string; + + constructor(options: SandboxOptions) { + this.options = options; + this.siteInfo = options.siteInfo; + this.emailSendCallback = options.emailSend ?? null; + this.devInvokeToken = randomBytes(32).toString("hex"); + } + + get wallTimeMs(): number { + return this.options.limits?.wallTimeMs ?? DEFAULT_WALL_TIME_MS; + } + + /** Get the per-startup invoke token (sent on hook/route requests to plugins) */ + get invokeAuthToken() { + return this.devInvokeToken; + } + + isAvailable(): boolean { + try { + const esmRequire = createRequire(import.meta.url); + esmRequire.resolve("miniflare"); + return true; + } catch { + return false; + } + } + + isHealthy(): boolean { + return this.running; + } + + setEmailSend(callback: SandboxEmailSendCallback | null): void { + this.emailSendCallback = callback; + } + + async load(manifest: PluginManifest, code: string): Promise { + const pluginId = `${manifest.id}:${manifest.version}`; + this.plugins.set(pluginId, { manifest, code }); + + // Rebuild miniflare with all plugins + await this.rebuild(); + + return new MiniflareDevPlugin(pluginId, manifest, this); + } + + async terminateAll(): Promise { + if (this.mf) { + await this.mf.dispose(); + this.mf = null; + } + this.plugins.clear(); + this.running = false; + } + + /** + * Unload a single plugin and rebuild miniflare without it. + * Called from MiniflareDevPlugin.terminate() so marketplace + * update/uninstall flows actually drop the old plugin from + * the dev sandbox instead of leaving stale entries. + */ + async unloadPlugin(pluginId: string): Promise { + if (this.plugins.delete(pluginId)) { + await this.rebuild(); + } + } + + /** + * Rebuild miniflare with current plugin configuration. + * Called on each plugin load/unload. + */ + private async rebuild(): Promise { + if (this.mf) { + await this.mf.dispose(); + this.mf = null; + } + + if (this.plugins.size === 0) { + this.running = false; + return; + } + + const { Miniflare } = await import("miniflare"); + + // Build worker configs with outboundService to intercept bridge calls. + // The wrapper code does fetch("http://bridge/method", ...). + // outboundService intercepts all outbound fetches and routes bridge + // calls to the Node handler function. + const workerConfigs = []; + + for (const [pluginId, { manifest, code }] of this.plugins) { + const bridgeHandler = createBridgeHandler({ + pluginId: manifest.id, + version: manifest.version || "0.0.0", + capabilities: manifest.capabilities || [], + allowedHosts: manifest.allowedHosts || [], + storageCollections: Object.keys(manifest.storage || {}), + storageConfig: manifest.storage as + | Record }> + | undefined, + db: this.options.db, + emailSend: () => this.emailSendCallback, + storage: this.options.mediaStorage, + }); + + const wrapperCode = generatePluginWrapper(manifest, { + site: this.siteInfo, + backingServiceUrl: "http://bridge", + authToken: "dev-mode", + invokeToken: this.devInvokeToken, + }); + + // outboundService intercepts all fetch() calls from this worker. + // Calls to http://bridge/... go to the Node bridge handler. + // Other calls pass through for network:fetch. + workerConfigs.push({ + name: pluginId.replace(SAFE_ID_RE, "_"), + // The wrapper imports "sandbox-plugin.js", so we provide both + // the wrapper as the main module and the plugin code as a + // named module that the wrapper can import. + modulesRoot: "/", + modules: [ + { type: "ESModule" as const, path: "worker.js", contents: wrapperCode }, + { type: "ESModule" as const, path: "sandbox-plugin.js", contents: code }, + { type: "ESModule" as const, path: "emdash", contents: EMDASH_SHIM }, + ], + outboundService: async (request: Request) => { + const url = new URL(request.url); + // Only allow bridge calls. Any other outbound fetch is blocked + // to enforce that all network access goes through ctx.http.fetch + // (which routes via the bridge with capability + host validation). + // Without this, plugins could bypass network:fetch / allowedHosts + // by calling plain fetch() directly. + if (url.hostname === "bridge") { + return bridgeHandler(request); + } + return new Response( + `Direct fetch() blocked in sandbox. Plugin "${manifest.id}" must use ctx.http.fetch() (requires network:fetch capability).`, + { status: 403 }, + ); + }, + }); + } + + this.mf = new Miniflare({ workers: workerConfigs }); + this.running = true; + } + + /** + * Dispatch a fetch to a specific plugin worker in miniflare. + */ + async dispatchToPlugin(pluginId: string, url: string, init?: RequestInit): Promise { + if (!this.mf) { + throw new Error(`Miniflare not running, cannot dispatch to ${pluginId}`); + } + const workerName = pluginId.replace(SAFE_ID_RE, "_"); + const worker = await this.mf.getWorker(workerName); + return worker.fetch(url, init); + } +} + +/** + * A plugin running in a miniflare dev isolate. + */ +class MiniflareDevPlugin implements SandboxedPluginInstance { + readonly id: string; + private manifest: PluginManifest; + private runner: MiniflareDevRunner; + + constructor(id: string, manifest: PluginManifest, runner: MiniflareDevRunner) { + this.id = id; + this.manifest = manifest; + this.runner = runner; + } + + async invokeHook(hookName: string, event: unknown): Promise { + if (!this.runner.isHealthy()) { + throw new Error(`Dev sandbox unavailable for ${this.id}`); + } + return this.withWallTimeLimit(`hook:${hookName}`, async () => { + const res = await this.runner.dispatchToPlugin(this.id, `http://plugin/hook/${hookName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.runner.invokeAuthToken}`, + }, + body: JSON.stringify({ event }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Plugin ${this.id} hook ${hookName} failed: ${text}`); + } + const result = (await res.json()) as { value: unknown }; + return result.value; + }); + } + + async invokeRoute( + routeName: string, + input: unknown, + request: SerializedRequest, + ): Promise { + if (!this.runner.isHealthy()) { + throw new Error(`Dev sandbox unavailable for ${this.id}`); + } + return this.withWallTimeLimit(`route:${routeName}`, async () => { + const res = await this.runner.dispatchToPlugin(this.id, `http://plugin/route/${routeName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.runner.invokeAuthToken}`, + }, + body: JSON.stringify({ input, request }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); + } + return res.json(); + }); + } + + private async withWallTimeLimit(operation: string, fn: () => Promise): Promise { + const wallTimeMs = this.runner.wallTimeMs; + let timer: ReturnType | undefined; + + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`, + ), + ); + }, wallTimeMs); + }); + + try { + return await Promise.race([fn(), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } + + async terminate(): Promise { + // Drop this plugin from the runner so marketplace update/uninstall + // actually removes it from the dev sandbox. + await this.runner.unloadPlugin(this.id); + } +} diff --git a/packages/workerd/src/sandbox/index.ts b/packages/workerd/src/sandbox/index.ts new file mode 100644 index 0000000000..306ae67b23 --- /dev/null +++ b/packages/workerd/src/sandbox/index.ts @@ -0,0 +1,3 @@ +export { WorkerdSandboxRunner, createSandboxRunner } from "./runner.js"; +export { MiniflareDevRunner } from "./dev-runner.js"; +export { createBridgeHandler } from "./bridge-handler.js"; diff --git a/packages/workerd/src/sandbox/runner.ts b/packages/workerd/src/sandbox/runner.ts new file mode 100644 index 0000000000..0d232ec03f --- /dev/null +++ b/packages/workerd/src/sandbox/runner.ts @@ -0,0 +1,1026 @@ +/** + * Workerd Sandbox Runner + * + * Implements the SandboxRunner interface for Node.js deployments using + * workerd as a sidecar process. Plugins run in isolated V8 isolates + * with capability-scoped access to EmDash APIs. + * + * Architecture: + * - Node spawns workerd with a generated capnp config + * - Each plugin is a nanoservice with its own internal port + * - Plugins communicate with Node via a backing service HTTP server + * - Node invokes plugin hooks/routes via HTTP to the plugin's port + * - Plugins call back to Node for content/media/KV/email operations + * + * The backing service HTTP server runs in the Node process and handles + * authenticated requests from plugins. Each plugin receives a unique + * auth token that encodes its ID and capabilities. + */ + +import { execFileSync, spawn } from "node:child_process"; +import type { ChildProcess } from "node:child_process"; +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { writeFile, mkdir, rm, unlink } from "node:fs/promises"; +import { createServer } from "node:http"; +import type { Server } from "node:http"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import type { + SandboxRunner, + SandboxedPluginInstance, + SandboxEmailSendCallback, + SandboxOptions, + SandboxRunnerFactory, + SerializedRequest, +} from "emdash"; +import type { PluginManifest } from "emdash"; +// @ts-ignore -- SandboxUnavailableError is a class export, not type-only +import { SandboxUnavailableError } from "emdash"; + +import { createBackingServiceHandler } from "./backing-service.js"; +import type { BackingServiceHandler } from "./backing-service.js"; +import { generateCapnpConfig } from "./capnp.js"; +import { MiniflareDevRunner } from "./dev-runner.js"; +import { generatePluginWrapper } from "./wrapper.js"; + +/** Replace non-alphanumeric chars for safe file/worker names */ +const SAFE_ID_RE = /[^a-z0-9_-]/gi; + +/** + * Stub for the "emdash" module that sandbox-entry plugins import to get + * `definePlugin`. The marketplace bundler inlines this via an alias, but + * statically-loaded sandboxed plugins (from `sandboxed: [...]`) embed + * their `dist/sandbox-entry.mjs` as-is, which still has the bare import. + * Providing the module here keeps that path working without rebuilding + * every plugin. Mirrors `EMDASH_SHIM` in @emdash-cms/cloudflare. + */ +const EMDASH_SHIM = "export const definePlugin = (d) => d;\n"; +const EMDASH_SHIM_FILE = "emdash-shim.js"; + +/** + * Build the minimal env for spawning workerd. Passing the full + * process.env risks leaking host secrets (DATABASE_URL, API keys, etc.) + * to plugins via workerd's nodejs_compat process.env polyfill, even + * though plugin code is otherwise isolated. Keep this list as small as + * possible. + * + * Operators who need additional vars in the workerd child can list + * them in EMDASH_WORKERD_PASSTHROUGH_ENV (comma-separated). + */ +export function minimalWorkerdEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + // Vars workerd / its dependencies might need to start: + const ALLOW = ["PATH", "HOME", "TMPDIR", "TMP", "TEMP", "LANG", "LC_ALL"]; + for (const k of ALLOW) { + const v = process.env[k]; + if (v !== undefined) env[k] = v; + } + const passthrough = process.env.EMDASH_WORKERD_PASSTHROUGH_ENV; + if (passthrough) { + for (const k of passthrough.split(",")) { + const trimmed = k.trim(); + if (!trimmed) continue; + const v = process.env[trimmed]; + if (v !== undefined) env[trimmed] = v; + } + } + return env; +} + +/** Use Unix domain sockets for the backing service (lower latency than TCP). + * Falls back to TCP on Windows where Unix sockets are not available. */ +const USE_UNIX_SOCKET = process.platform !== "win32"; + +const activeRunners = new Set(); +let sigHandlerRegistered = false; + +function registerSigHandler(runner: WorkerdSandboxRunner): void { + activeRunners.add(runner); + if (!sigHandlerRegistered) { + sigHandlerRegistered = true; + process.on("SIGTERM", () => { + for (const r of activeRunners) { + r["shuttingDown"] = true; + void r.terminateAll(); + } + }); + } +} + +function unregisterSigHandler(runner: WorkerdSandboxRunner): void { + activeRunners.delete(runner); +} + +/** + * Default resource limits for sandboxed plugins. + * Matches Cloudflare production limits. + */ +const DEFAULT_LIMITS = { + cpuMs: 50, + memoryMb: 128, + subrequests: 10, + wallTimeMs: 30_000, +} as const; + +/** + * Resolved resource limits with defaults applied. + */ +interface ResolvedLimits { + cpuMs: number; + memoryMb: number; + subrequests: number; + wallTimeMs: number; +} + +function resolveLimits(limits?: SandboxOptions["limits"]): ResolvedLimits { + return { + cpuMs: limits?.cpuMs ?? DEFAULT_LIMITS.cpuMs, + memoryMb: limits?.memoryMb ?? DEFAULT_LIMITS.memoryMb, + subrequests: limits?.subrequests ?? DEFAULT_LIMITS.subrequests, + wallTimeMs: limits?.wallTimeMs ?? DEFAULT_LIMITS.wallTimeMs, + }; +} + +/** + * State for a loaded plugin in the workerd process. + */ +interface LoadedPlugin { + manifest: PluginManifest; + code: string; + /** Port the plugin's nanoservice listens on inside workerd */ + port: number; + /** Auth token for this plugin's backing service requests */ + token: string; +} + +/** + * Probe every plugin's `/__ready` endpoint in parallel. Returns true iff + * every plugin responds with `ok`. Exported for testing; not re-exported + * from the package barrel. + */ +export async function probeAllReady( + plugins: Iterable<{ port: number }>, + invokeToken: string, + perProbeTimeoutMs = 1000, +): Promise { + const targets = [...plugins]; + if (targets.length === 0) return true; + const checks = targets.map((p) => + fetch(`http://127.0.0.1:${p.port}/__ready`, { + signal: AbortSignal.timeout(perProbeTimeoutMs), + headers: { Authorization: `Bearer ${invokeToken}` }, + }) + .then((r) => r.ok) + .catch(() => false), + ); + const results = await Promise.all(checks); + return results.every(Boolean); +} + +/** + * Minimal shape of the runner that the exit handler needs. Pulled out so + * the handler can be built and tested in isolation -- the identity guard + * (`host.workerdProcess !== proc`) is the load-bearing piece. + */ +export type ExitHandlerHost = { + workerdProcess: ChildProcess | null; + healthy: boolean; + shuttingDown: boolean; + intentionalStop: boolean; + scheduleRestart(): void; +}; + +/** + * Build an exit handler bound to a specific spawned workerd process. + * + * The handler short-circuits if the host has already moved on to a newer + * workerd (`host.workerdProcess !== proc`), which would otherwise let a + * late exit from a prior process clobber the live one's handle and mark + * it unhealthy. Exported for testing; not re-exported from the package + * barrel. + */ +export function makeWorkerdExitHandler( + host: ExitHandlerHost, + proc: ChildProcess, +): (code: number | null, signal: NodeJS.Signals | null) => void { + return (code, signal) => { + if (host.workerdProcess !== proc) return; // stale handler from a prior workerd + host.healthy = false; + host.workerdProcess = null; + if (host.shuttingDown) return; + // Skip crash recovery for intentional stops (e.g., reload via + // stopWorkerd() during restart()). Reset the flag so the next + // exit, if it happens unexpectedly, is treated as a real crash. + if (host.intentionalStop) { + host.intentionalStop = false; + return; + } + // Restart on non-zero exit code OR signal-based termination (OOM, kill) + if ((code !== 0 && code !== null) || signal) { + const reason = signal ? `signal ${signal}` : `code ${code}`; + console.error(`[emdash:workerd] workerd exited with ${reason}`); + host.scheduleRestart(); + } + }; +} + +/** + * Send SIGTERM and resolve when the process exits, escalating to SIGKILL + * after `timeoutMs` if it ignores the term signal. + * + * The fallback timer is cleared on clean exit so it does not keep the Node + * event loop alive past termination -- previously a stale timer would + * delay `terminateAll()` and process shutdown by up to 5 seconds. Exported + * for testing. + */ +export function waitForProcessExit(proc: ChildProcess, timeoutMs = 5000): Promise { + let killTimer: ReturnType | null = null; + const exitPromise = new Promise((resolve) => { + proc.once("exit", () => { + if (killTimer !== null) { + clearTimeout(killTimer); + killTimer = null; + } + resolve(); + }); + }); + proc.kill("SIGTERM"); + killTimer = setTimeout(() => { + killTimer = null; + if (proc.exitCode === null) proc.kill("SIGKILL"); + }, timeoutMs); + return exitPromise; +} + +/** + * Workerd sandbox runner for Node.js deployments. + * + * Manages a workerd child process and a backing service HTTP server. + * Plugins are added/removed by regenerating the capnp config and + * restarting workerd (millisecond cold start). + */ +export class WorkerdSandboxRunner implements SandboxRunner { + private options: SandboxOptions; + private limits: ResolvedLimits; + private siteInfo?: { name: string; url: string; locale: string }; + + /** Loaded plugins indexed by pluginId (manifest.id:manifest.version) */ + private plugins = new Map(); + + /** Backing service HTTP server (runs in Node) */ + private backingServer: Server | null = null; + private backingPort = 0; + private backingService: BackingServiceHandler | null = null; + private backingSocketPath: string | null = null; + private eagerStartTimer: ReturnType | null = null; + + /** workerd child process */ + private workerdProcess: ChildProcess | null = null; + + /** Master secret for generating per-plugin auth tokens */ + private masterSecret = randomBytes(32).toString("hex"); + + /** + * Per-startup token the runner sends on every hook/route invocation + * to its plugins. Plugins reject requests without this token, which + * prevents same-host attackers from invoking plugin hooks directly + * via the per-plugin TCP listener on 127.0.0.1. + */ + private invokeToken = randomBytes(32).toString("hex"); + + /** Temporary directory for capnp config and plugin code files */ + private configDir: string | null = null; + + /** Email send callback, wired from EmailPipeline */ + private emailSendCallback: SandboxEmailSendCallback | null = null; + + /** Epoch counter, incremented on each workerd restart */ + private epoch = 0; + + /** Next available port for plugin nanoservices */ + private nextPluginPort = 18788; + + /** + * Ports freed by unloadPlugin(), preferred over nextPluginPort on the + * next load() so install/uninstall churn (marketplace updates, dev + * watcher reloads) doesn't leak the port range upward toward 65535. + */ + private freePorts: number[] = []; + + /** Whether workerd is currently healthy */ + private healthy = false; + + /** Whether workerd needs to be (re)started before next invocation */ + private needsRestart = false; + + /** Serializes concurrent ensureRunning() calls */ + private startupPromise: Promise | null = null; + + /** Crash restart state */ + private crashCount = 0; + private crashWindowStart = 0; + private restartTimer: ReturnType | null = null; + private shuttingDown = false; + + /** + * True when stopWorkerd() is intentionally tearing down the child + * (e.g., on intentional restart() to reload plugins). The exit handler + * uses this to skip crash recovery for intentional stops, otherwise + * every plugin reload would trigger a phantom crash-restart cycle. + */ + private intentionalStop = false; + + constructor(options: SandboxOptions) { + this.options = options; + this.limits = resolveLimits(options.limits); + this.siteInfo = options.siteInfo; + this.emailSendCallback = options.emailSend ?? null; + + // Warn about unenforceable resource limits. Standalone workerd + // only supports wall-time enforcement on the Node path (via + // Promise.race). cpuMs, memoryMb, and subrequests are Cloudflare + // platform features and are not enforced here. + if ( + options.limits && + (options.limits.cpuMs !== undefined || + options.limits.memoryMb !== undefined || + options.limits.subrequests !== undefined) + ) { + console.warn( + "[emdash:workerd] cpuMs, memoryMb, and subrequests limits are not enforced " + + "by standalone workerd. Only wallTimeMs is enforced on the Node path. " + + "For full resource isolation, deploy on Cloudflare Workers.", + ); + } + + // Forward SIGTERM to workerd child for clean shutdown + registerSigHandler(this); + } + + /** + * Check if workerd is available on this system. + */ + isAvailable(): boolean { + try { + const bin = this.resolveWorkerdBinary(); + // execFileSync (not execSync) so paths with spaces or shell + // metacharacters are passed verbatim, not shell-split. + execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 }); + return true; + } catch { + return false; + } + } + + /** + * Resolve the workerd binary path from node_modules. + * Avoids npx which can download binaries at runtime (supply chain risk). + */ + private resolveWorkerdBinary(): string { + try { + // workerd package: main is lib/main.js, bin is bin/workerd + const esmRequire = createRequire(import.meta.url); + const workerdMain = esmRequire.resolve("workerd"); + // workerdMain = .../node_modules/workerd/lib/main.js + // binary = .../node_modules/workerd/bin/workerd + const pkgDir = join(workerdMain, "..", ".."); + const binName = process.platform === "win32" ? "workerd.exe" : "workerd"; + return join(pkgDir, "bin", binName); + } catch { + // Fallback: try workerd on PATH + return "workerd"; + } + } + + /** + * Check if the workerd process is currently healthy. + * + * Returns false when needsRestart is set (process not yet started or + * needs to be restarted), since callers using this for monitoring or + * external health checks expect "running and serving requests". + * + * Internal callers that just want to defer-then-invoke should use + * ensureRunning() instead, which handles the deferred startup. + */ + isHealthy(): boolean { + if (this.needsRestart) return false; + return this.healthy && this.workerdProcess !== null && !this.workerdProcess.killed; + } + + /** + * Ensure workerd is running. Called before first invocation. + * Batches plugin loading: all plugins are registered via load(), + * then workerd starts once on the first hook/route call. + */ + async ensureRunning(): Promise { + // If a startup is already in progress, wait for it + if (this.startupPromise) { + await this.startupPromise; + return; + } + if (!this.needsRestart) return; + + // Serialize: concurrent callers await the same promise. + // Don't clear needsRestart until startup succeeds, so a transient + // failure (waitForReady timeout, spawn error) can be retried by + // the next invocation. + this.startupPromise = this.restart(); + try { + await this.startupPromise; + this.needsRestart = false; + } finally { + // Always clear startupPromise so a failed start doesn't block + // subsequent retries. needsRestart stays true on failure (set above + // only after the await succeeds), enabling automatic retry. + this.startupPromise = null; + } + } + + /** + * Set the email send callback for sandboxed plugins. + */ + setEmailSend(callback: SandboxEmailSendCallback | null): void { + this.emailSendCallback = callback; + } + + /** + * Load a sandboxed plugin. + * + * Adds the plugin to the configuration and restarts workerd + * to pick up the new nanoservice. + */ + async load(manifest: PluginManifest, code: string): Promise { + const pluginId = `${manifest.id}:${manifest.version}`; + + // Return cached plugin if already loaded + const existing = this.plugins.get(pluginId); + if (existing) { + return new WorkerdSandboxedPlugin(pluginId, manifest, existing.port, this.limits, this); + } + + // Assign port and generate auth token. Reuse a freed port if one is + // available, otherwise allocate the next sequential port. + const port = this.freePorts.pop() ?? this.nextPluginPort++; + const token = this.generatePluginToken(manifest); + + this.plugins.set(pluginId, { manifest, code, port, token }); + + // Defer workerd start: collect all plugins first, start once. + // The runtime loads plugins sequentially, so we batch by deferring + // the actual workerd spawn until the first hook/route invocation. + this.needsRestart = true; + this.scheduleEagerStart(); + + return new WorkerdSandboxedPlugin(pluginId, manifest, port, this.limits, this); + } + + /** + * Unload a single plugin (called from WorkerdSandboxedPlugin.terminate()). + * + * Removes the plugin from the in-memory map and marks needsRestart so + * the next invocation rebuilds workerd without it. We don't restart + * eagerly here because update/uninstall flows often unload immediately + * before loading the new version, and back-to-back restarts are wasteful. + */ + unloadPlugin(pluginId: string): void { + // Read the entry before delete() -- Map.delete returns a boolean, + // not the removed value, so we need the port for the free-port pool. + const entry = this.plugins.get(pluginId); + if (!entry) return; + this.plugins.delete(pluginId); + this.freePorts.push(entry.port); + this.backingService?.removePlugin(pluginId); + if (this.plugins.size === 0) { + void this.stopWorkerd(); + } else { + this.needsRestart = true; + this.scheduleEagerStart(); + } + } + + /** + * Schedule eager workerd start with a short debounce. + * Batches rapid load/unload sequences into a single restart. + */ + private scheduleEagerStart(): void { + if (this.eagerStartTimer) clearTimeout(this.eagerStartTimer); + this.eagerStartTimer = setTimeout(() => { + this.eagerStartTimer = null; + this.ensureRunning().catch((err) => { + console.error("[emdash:workerd] eager start failed:", err); + this.scheduleRestart(); + }); + }, 50); + } + + /** + * Terminate all loaded plugins and shut down workerd. + */ + async terminateAll(): Promise { + this.shuttingDown = true; + if (this.restartTimer) { + clearTimeout(this.restartTimer); + this.restartTimer = null; + } + if (this.eagerStartTimer) { + clearTimeout(this.eagerStartTimer); + this.eagerStartTimer = null; + } + unregisterSigHandler(this); + this.plugins.clear(); + await this.stopWorkerd(); + await this.stopBackingServer(); + if (this.configDir) { + await rm(this.configDir, { recursive: true, force: true }).catch(() => {}); + this.configDir = null; + } + } + + /** + * Schedule a restart with exponential backoff. + * Backoff: 1s, 2s, 4s, cap at 30s. + * Gives up after 5 failures within 60 seconds. + */ + private scheduleRestart(): void { + if (this.shuttingDown || this.plugins.size === 0) return; + + const now = Date.now(); + + // Reset crash window if it's been more than 60 seconds + if (now - this.crashWindowStart > 60_000) { + this.crashCount = 0; + this.crashWindowStart = now; + } + + this.crashCount++; + + if (this.crashCount > 5) { + console.error( + "[emdash:workerd] workerd crashed 5 times in 60 seconds, giving up. " + + "Plugins will run unsandboxed. Restart the server to retry.", + ); + return; + } + + // Exponential backoff: 1s, 2s, 4s, 8s, 16s, capped at 30s + const delayMs = Math.min(1000 * 2 ** (this.crashCount - 1), 30_000); + console.warn(`[emdash:workerd] restarting in ${delayMs}ms (attempt ${this.crashCount}/5)`); + + this.restartTimer = setTimeout(() => { + this.restartTimer = null; + // Just mark as needing restart. The next plugin invocation will + // drive the actual restart through ensureRunning(), which serializes + // concurrent attempts via startupPromise. We don't call ensureRunning() + // here because that would race with plugin-invocation-driven calls + // (the finally block clears startupPromise so a second concurrent + // caller could enter restart() while the first is still running). + // + // If no plugin invocations happen after a crash, there's nothing + // to recover for, so deferring restart until next use is fine. + this.needsRestart = true; + }, delayMs); + } + + /** + * Generate a per-plugin auth token. + * Encodes pluginId and capabilities for server-side validation. + */ + private generatePluginToken(manifest: PluginManifest): string { + const payload = JSON.stringify({ + pluginId: manifest.id, + version: manifest.version, + capabilities: manifest.capabilities || [], + allowedHosts: manifest.allowedHosts || [], + storageCollections: Object.keys(manifest.storage || {}), + }); + const payloadB64 = Buffer.from(payload).toString("base64url"); + const hmac = createHmac("sha256", this.masterSecret).update(payload).digest("base64url"); + return `${payloadB64}.${hmac}`; + } + + /** + * Validate a plugin auth token and extract its claims. + * Returns null if invalid. + */ + validateToken(token: string): { + pluginId: string; + version: string; + capabilities: string[]; + allowedHosts: string[]; + storageCollections: string[]; + } | null { + const parts = token.split("."); + if (parts.length !== 2) return null; + + const [payloadB64, hmacB64] = parts; + if (!payloadB64 || !hmacB64) return null; + + const payload = Buffer.from(payloadB64, "base64url").toString(); + const expectedHmac = createHmac("sha256", this.masterSecret) + .update(payload) + .digest("base64url"); + + // Constant-time comparison to prevent timing side channels + const a = Buffer.from(hmacB64); + const b = Buffer.from(expectedHmac); + if (a.length !== b.length || !timingSafeEqual(a, b)) return null; + + try { + return JSON.parse(payload) as { + pluginId: string; + version: string; + capabilities: string[]; + allowedHosts: string[]; + storageCollections: string[]; + }; + } catch { + return null; + } + } + + /** + * Start or restart workerd with current plugin configuration. + */ + private async restart(): Promise { + await this.stopWorkerd(); + + // Ensure backing server is running + if (!this.backingServer) { + await this.startBackingServer(); + } + + // Create temp directory for config files + if (!this.configDir) { + this.configDir = join(tmpdir(), `emdash-workerd-${process.pid}-${Date.now()}`); + await mkdir(this.configDir, { recursive: true }); + } + + // Write the shared emdash shim once -- every plugin worker references + // it as a module so `import { definePlugin } from "emdash"` resolves. + await writeFile(join(this.configDir, EMDASH_SHIM_FILE), EMDASH_SHIM); + + // Write plugin code files to disk (workerd needs file paths) + for (const [pluginId, plugin] of this.plugins) { + const safeId = pluginId.replace(SAFE_ID_RE, "_"); + const wrapperCode = generatePluginWrapper(plugin.manifest, { + site: this.siteInfo, + backingServiceUrl: this.backingServiceUrl, + authToken: plugin.token, + invokeToken: this.invokeToken, + }); + await writeFile(join(this.configDir, `${safeId}-wrapper.js`), wrapperCode); + await writeFile(join(this.configDir, `${safeId}-plugin.js`), plugin.code); + } + + // Generate capnp config. Note: cpuMs/memoryMb/subrequests from + // this.limits are NOT passed here because standalone workerd doesn't + // support per-worker enforcement of those limits (Cloudflare-only). + // Only wallTimeMs is enforced (via Promise.race in invokeHook/invokeRoute). + const capnpConfig = generateCapnpConfig({ + plugins: this.plugins, + backingServiceAddress: this.backingServiceAddress, + configDir: this.configDir, + emdashShimFile: EMDASH_SHIM_FILE, + }); + + const configPath = join(this.configDir, "workerd.capnp"); + await writeFile(configPath, capnpConfig); + + // Spawn workerd using resolved binary (not npx). Capture into a local + // `proc` immediately so the exit handler closure can gate on identity + // and ignore late exits from a previous workerd if another restart() + // has already reassigned this.workerdProcess. + const workerdBin = this.resolveWorkerdBinary(); + const proc = spawn(workerdBin, ["serve", configPath], { + stdio: ["ignore", "pipe", "pipe"], + env: minimalWorkerdEnv(), + }); + this.workerdProcess = proc; + + this.epoch++; + + // Drain stdout/stderr to prevent pipe buffer deadlock + proc.stdout?.on("data", (chunk: Buffer) => { + process.stdout.write(`[emdash:workerd] ${chunk.toString()}`); + }); + proc.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write(`[emdash:workerd] ${chunk.toString()}`); + }); + + // Handle workerd exit with auto-restart on crash. Gate on identity + // so a late exit from a prior workerd cannot null out a newer one. + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- runner satisfies ExitHandlerHost structurally; cast bypasses TS's nominal handling of private members + proc.on("exit", makeWorkerdExitHandler(this as unknown as ExitHandlerHost, proc)); + + // Wait for workerd to be ready + await this.waitForReady(); + this.healthy = true; + } + + /** + * Wait for workerd to be ready by polling every plugin port. + */ + private async waitForReady(): Promise { + const startTime = Date.now(); + const timeout = 10_000; + + if (this.plugins.size === 0) { + this.healthy = true; + return; + } + + while (Date.now() - startTime < timeout) { + if (await probeAllReady(this.plugins.values(), this.invokeToken)) { + return; + } + await new Promise((r) => setTimeout(r, 100)); + } + + throw new Error("[emdash:workerd] workerd failed to start within 10 seconds"); + } + + /** + * Stop the workerd child process. + * + * Marks the stop as intentional so the exit handler in restart() does + * not interpret it as a crash and trigger scheduleRestart(). Without + * this, every intentional reload (plugin install/uninstall) would + * cascade into a phantom crash-restart cycle. + */ + private async stopWorkerd(): Promise { + if (!this.workerdProcess) return; + this.healthy = false; + this.intentionalStop = true; + + const proc = this.workerdProcess; + this.workerdProcess = null; + + // Fast path: process already exited (exitCode is set after exit) + if (proc.exitCode !== null) { + return; + } + + // Force kill after 5 seconds if SIGTERM was ignored. The fallback + // timer is cleared on clean exit so it doesn't keep the Node event + // loop alive for up to 5s past termination. + return waitForProcessExit(proc); + } + + /** + * Start the backing service HTTP server. + */ + private async startBackingServer(): Promise { + this.backingService = createBackingServiceHandler(this); + + return new Promise((resolve, reject) => { + this.backingServer = createServer(this.backingService!.handler); + + if (USE_UNIX_SOCKET) { + const socketPath = join(tmpdir(), `emdash-sandbox-${process.pid}-${Date.now()}.sock`); + this.backingSocketPath = socketPath; + this.backingServer.listen(socketPath, () => { + resolve(); + }); + } else { + // Windows fallback: TCP on localhost + this.backingServer.listen(0, "127.0.0.1", () => { + const addr = this.backingServer!.address(); + if (addr && typeof addr === "object") { + this.backingPort = addr.port; + } + resolve(); + }); + } + + this.backingServer.on("error", reject); + }); + } + + /** Address string for capnp config */ + get backingServiceAddress(): string { + if (USE_UNIX_SOCKET && this.backingSocketPath) { + return `unix:${this.backingSocketPath}`; + } + return `127.0.0.1:${this.backingPort}`; + } + + /** URL for wrapper code to use as BACKING_URL. + * With globalOutbound the hostname is just a label — all outbound + * fetch() calls route through the external service regardless. */ + get backingServiceUrl(): string { + if (USE_UNIX_SOCKET && this.backingSocketPath) { + return "http://emdash-backing"; + } + return `http://127.0.0.1:${this.backingPort}`; + } + + /** + * Stop the backing service HTTP server. + */ + private async stopBackingServer(): Promise { + if (!this.backingServer) return; + const socketPath = this.backingSocketPath; + return new Promise((resolve) => { + this.backingServer!.close(() => { + if (socketPath) { + unlink(socketPath).catch(() => {}); + this.backingSocketPath = null; + } + resolve(); + }); + this.backingServer = null; + }); + } + + /** Get the database for backing service operations */ + get db() { + return this.options.db; + } + + /** Get the email send callback */ + get emailSend() { + return this.emailSendCallback; + } + + /** Get the media storage adapter */ + get mediaStorage() { + return this.options.mediaStorage ?? null; + } + + /** Get the per-startup invoke token (sent on hook/route requests to plugins) */ + get invokeAuthToken() { + return this.invokeToken; + } + + /** + * Look up the storage config (with indexes) for a specific plugin version. + * The plugins map is keyed by `${id}:${version}`. Looking up by id alone + * could return a stale version's storage schema after a plugin upgrade, + * so we require both id and version. + */ + getPluginStorageConfig(pluginId: string, version: string): Record | undefined { + const plugin = this.plugins.get(`${pluginId}:${version}`); + if (plugin) { + return plugin.manifest.storage as Record | undefined; + } + return undefined; + } + + /** Get the current epoch (incremented on each workerd restart) */ + get currentEpoch() { + return this.epoch; + } +} + +/** + * A plugin running in a workerd V8 isolate. + */ +class WorkerdSandboxedPlugin implements SandboxedPlugin { + readonly id: string; + private manifest: PluginManifest; + private port: number; + private limits: ResolvedLimits; + private runner: WorkerdSandboxRunner; + constructor( + id: string, + manifest: PluginManifest, + port: number, + limits: ResolvedLimits, + runner: WorkerdSandboxRunner, + ) { + this.id = id; + this.manifest = manifest; + this.port = port; + this.limits = limits; + this.runner = runner; + } + + /** + * Ensure workerd is running before invoking a hook or route. + * On first call, this triggers deferred workerd startup (batching + * all plugins registered via load() into a single workerd start). + */ + private async ensureReady(): Promise { + await this.runner.ensureRunning(); + if (!this.runner.isHealthy()) { + throw new SandboxUnavailableError(this.id, "workerd is not running"); + } + } + + /** + * Invoke a hook in the sandboxed plugin via HTTP. + */ + async invokeHook(hookName: string, event: unknown): Promise { + await this.ensureReady(); + return this.withWallTimeLimit(`hook:${hookName}`, async () => { + const res = await fetch(`http://127.0.0.1:${this.port}/hook/${hookName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.runner.invokeAuthToken}`, + }, + body: JSON.stringify({ event }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Plugin ${this.id} hook ${hookName} failed: ${text}`); + } + const result = (await res.json()) as { value: unknown }; + return result.value; + }); + } + + /** + * Invoke an API route in the sandboxed plugin via HTTP. + */ + async invokeRoute( + routeName: string, + input: unknown, + request: SerializedRequest, + ): Promise { + await this.ensureReady(); + return this.withWallTimeLimit(`route:${routeName}`, async () => { + const res = await fetch(`http://127.0.0.1:${this.port}/route/${routeName}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.runner.invokeAuthToken}`, + }, + body: JSON.stringify({ input, request }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Plugin ${this.id} route ${routeName} failed: ${text}`); + } + return res.json(); + }); + } + + /** + * Terminate the sandboxed plugin. + * + * Removes this plugin from the runner's plugins map and marks + * needsRestart so the next load/invocation rebuilds workerd without + * its listener. Without this, marketplace update/uninstall would + * leak old plugin entries (and their ports) until full server restart. + */ + async terminate(): Promise { + this.runner.unloadPlugin(this.id); + } + + /** + * Enforce wall-time limit on an operation. + */ + private async withWallTimeLimit(operation: string, fn: () => Promise): Promise { + const wallTimeMs = this.limits.wallTimeMs; + let timer: ReturnType | undefined; + + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + reject( + new Error( + `Plugin ${this.manifest.id} exceeded wall-time limit of ${wallTimeMs}ms during ${operation}`, + ), + ); + }, wallTimeMs); + }); + + try { + return await Promise.race([fn(), timeout]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } + } +} + +/** + * Factory function for creating the workerd sandbox runner. + * + * Selects MiniflareDevRunner only when explicitly in development mode + * (NODE_ENV === "development"). Any other value — including unset (which + * is the default for `node server.js` and `astro preview` on self-hosted + * deployments) — uses the production WorkerdSandboxRunner. + * + * The dev runner skips production hardening (wall-time wrapper, child + * process supervision, crash/restart with backoff), so falling back to + * it silently in production would be a security regression. + * + * Operators who want the dev runner explicitly should set NODE_ENV=development. + */ +export const createSandboxRunner: SandboxRunnerFactory = (options) => { + const isDev = process.env.EMDASH_SANDBOX_DEV === "1" || process.env.NODE_ENV === "development"; + + if (isDev) { + // MiniflareDevRunner is statically imported (no miniflare dependency + // at this point — dev-runner only imports miniflare dynamically inside + // rebuild()). isAvailable() does the actual miniflare resolution check. + const devRunner = new MiniflareDevRunner(options); + if (devRunner.isAvailable()) { + return devRunner; + } + } + + return new WorkerdSandboxRunner(options); +}; diff --git a/packages/workerd/src/sandbox/wrapper.ts b/packages/workerd/src/sandbox/wrapper.ts new file mode 100644 index 0000000000..f48aea83c4 --- /dev/null +++ b/packages/workerd/src/sandbox/wrapper.ts @@ -0,0 +1,426 @@ +/** + * Plugin Wrapper Generator for workerd + * + * Generates the code that wraps a plugin to run in a workerd isolate. + * Unlike the Cloudflare wrapper which uses RPC via service bindings, + * this wrapper uses HTTP fetch to call the Node backing service. + * + * The wrapper: + * - Imports plugin hooks and routes from "sandbox-plugin.js" + * - Creates plugin context that proxies operations via HTTP to the backing service + * - Exposes an HTTP fetch handler for hook/route invocation + */ + +import type { PluginManifest } from "emdash"; + +const TRAILING_SLASH_RE = /\/$/; +const NEWLINE_RE = /[\n\r]/g; +const COMMENT_CLOSE_RE = /\*\//g; + +export interface WrapperOptions { + site?: { name: string; url: string; locale: string }; + /** URL of the Node backing service (e.g., http://127.0.0.1:18787) */ + backingServiceUrl: string; + /** Auth token the plugin sends on outbound bridge calls to Node */ + authToken: string; + /** + * Auth token the Node runner must send on inbound hook/route invocations. + * Prevents same-host attackers from invoking plugin hooks directly via + * the per-plugin TCP listener (which is exposed on 127.0.0.1). + */ + invokeToken: string; +} + +export function generatePluginWrapper(manifest: PluginManifest, options: WrapperOptions): string { + const site = options.site ?? { name: "", url: "", locale: "en" }; + const hasReadUsers = manifest.capabilities.includes("read:users"); + const hasEmailSend = manifest.capabilities.includes("email:send"); + + return ` +// ============================================================================= +// Sandboxed Plugin Wrapper (workerd) +// Generated by @emdash-cms/sandbox-workerd +// Plugin: ${sanitizeComment(manifest.id)}@${sanitizeComment(manifest.version)} +// ============================================================================= + +import pluginModule from "sandbox-plugin.js"; + +const hooks = pluginModule?.hooks || pluginModule?.default?.hooks || {}; +const routes = pluginModule?.routes || pluginModule?.default?.routes || {}; + +const BACKING_URL = ${JSON.stringify(options.backingServiceUrl)}; +const AUTH_TOKEN = ${JSON.stringify(options.authToken)}; +const INVOKE_TOKEN = ${JSON.stringify(options.invokeToken)}; + +// ----------------------------------------------------------------------------- +// Bridge - HTTP calls to Node backing service +// ----------------------------------------------------------------------------- + +async function bridgeCall(method, body) { + const res = await fetch(BACKING_URL + "/" + method, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": "Bearer " + AUTH_TOKEN, + }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error("Bridge call " + method + " failed: " + text); + } + const data = await res.json(); + return data.result; +} + +// ----------------------------------------------------------------------------- +// Context Factory +// ----------------------------------------------------------------------------- + +function createContext() { + const kv = { + get: (key) => bridgeCall("kv/get", { key }), + set: (key, value) => bridgeCall("kv/set", { key, value }), + delete: (key) => bridgeCall("kv/delete", { key }), + list: (prefix) => bridgeCall("kv/list", { prefix }), + }; + + function createStorageCollection(collectionName) { + return { + get: (id) => bridgeCall("storage/get", { collection: collectionName, id }), + put: (id, data) => bridgeCall("storage/put", { collection: collectionName, id, data }), + delete: (id) => bridgeCall("storage/delete", { collection: collectionName, id }), + exists: async (id) => (await bridgeCall("storage/get", { collection: collectionName, id })) !== null, + query: (opts) => bridgeCall("storage/query", { collection: collectionName, ...opts }), + count: (where) => bridgeCall("storage/count", { collection: collectionName, where }), + getMany: async (ids) => { + // Bridge returns a list of [id, data] pairs (not a plain object) + // so special IDs like "__proto__" survive transport. Convert + // back to Map to match StorageCollection.getMany() contract. + const entries = await bridgeCall("storage/getMany", { collection: collectionName, ids }); + return new Map(entries || []); + }, + putMany: (items) => bridgeCall("storage/putMany", { collection: collectionName, items }), + deleteMany: (ids) => bridgeCall("storage/deleteMany", { collection: collectionName, ids }), + }; + } + + const storage = new Proxy({}, { + get(_, collectionName) { + if (typeof collectionName !== "string") return undefined; + return createStorageCollection(collectionName); + } + }); + + const content = { + get: (collection, id) => bridgeCall("content/get", { collection, id }), + list: (collection, opts) => bridgeCall("content/list", { collection, ...opts }), + create: (collection, data) => bridgeCall("content/create", { collection, data }), + update: (collection, id, data) => bridgeCall("content/update", { collection, id, data }), + delete: (collection, id) => bridgeCall("content/delete", { collection, id }), + createMany: (collection, items) => bridgeCall("content/createMany", { collection, items }), + updateMany: (collection, items) => bridgeCall("content/updateMany", { collection, items }), + deleteMany: (collection, ids) => bridgeCall("content/deleteMany", { collection, ids }), + }; + + const media = { + get: (id) => bridgeCall("media/get", { id }), + list: (opts) => bridgeCall("media/list", opts || {}), + upload: (filename, contentType, bytes) => { + // Convert any binary input into a Uint8Array view pointing at the + // SAME underlying bytes (not reinterpreted). For ArrayBufferView + // inputs (Uint16Array, Int32Array, DataView, etc.) we must use + // the view's buffer + byteOffset + byteLength so we don't + // reinterpret element-typed values as bytes and corrupt the file. + let view; + if (bytes instanceof Uint8Array) { + view = bytes; + } else if (bytes instanceof ArrayBuffer) { + view = new Uint8Array(bytes); + } else if (ArrayBuffer.isView(bytes)) { + view = new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength); + } else { + throw new TypeError("media.upload: bytes must be ArrayBuffer or ArrayBufferView"); + } + let binary = ""; + for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]); + return bridgeCall("media/upload", { + filename, + contentType, + bytes: btoa(binary), + encoding: "base64", + }); + }, + getUploadUrl: () => { throw new Error("getUploadUrl is not available in sandbox mode. Use media.upload() instead."); }, + delete: (id) => bridgeCall("media/delete", { id }), + }; + + // Marshal a RequestInit into a JSON-safe shape so headers, body, and other + // fields survive transport over the bridge. The bridge handler reverses + // this in unmarshalRequestInit(). + async function marshalRequestInit(init) { + if (!init) return undefined; + const out = {}; + if (init.method) out.method = init.method; + if (init.redirect) out.redirect = init.redirect; + // Headers: serialize as a list of [name, value] pairs so multi-value + // headers (Set-Cookie etc.) survive round-trip. A plain object would + // collapse duplicate names. + if (init.headers) { + const headers = []; + if (init.headers instanceof Headers) { + init.headers.forEach((v, k) => { headers.push([k, v]); }); + } else if (Array.isArray(init.headers)) { + for (const [k, v] of init.headers) headers.push([k, v]); + } else { + for (const [k, v] of Object.entries(init.headers)) { + headers.push([k, v]); + } + } + out.headers = headers; + } + // Helper: convert a Uint8Array view to base64, preserving offset/length + function viewToBase64(view) { + let binary = ""; + for (let i = 0; i < view.length; i++) binary += String.fromCharCode(view[i]); + return btoa(binary); + } + + // Helper: get a Uint8Array view from any binary input, respecting + // the original byteOffset and byteLength so we don't serialize the + // entire backing buffer for views like Uint8Array.subarray(). + function toBytes(input) { + if (input instanceof Uint8Array) return input; + if (input instanceof ArrayBuffer) return new Uint8Array(input); + if (ArrayBuffer.isView(input)) { + // DataView, Int8Array, Float32Array, etc. — preserve the window + return new Uint8Array(input.buffer, input.byteOffset, input.byteLength); + } + // Should never reach here: callers gate with ArrayBuffer/isView checks. + // Throw loudly so unexpected body types surface as errors instead of + // silently dropping data. + throw new TypeError("toBytes: unsupported binary input type"); + } + + // Body: convert to base64 to preserve binary, or pass strings through + if (init.body !== undefined && init.body !== null) { + if (typeof init.body === "string") { + out.bodyType = "string"; + out.body = init.body; + } else if (init.body instanceof ArrayBuffer || ArrayBuffer.isView(init.body)) { + out.bodyType = "base64"; + out.body = viewToBase64(toBytes(init.body)); + } else if (typeof Blob !== "undefined" && init.body instanceof Blob) { + // Blob/File (without going through FormData): read bytes and + // preserve content type if not already set + const bytes = new Uint8Array(await init.body.arrayBuffer()); + out.bodyType = "base64"; + out.body = viewToBase64(bytes); + if (init.body.type) { + if (!Array.isArray(out.headers)) out.headers = []; + const hasContentType = out.headers.some(([k]) => k.toLowerCase() === "content-type"); + if (!hasContentType) { + out.headers.push(["content-type", init.body.type]); + } + } + } else if (init.body instanceof FormData) { + // FormData: serialize entries as { name, value, filename? } + const parts = []; + for (const [k, v] of init.body.entries()) { + if (typeof v === "string") { + parts.push({ name: k, value: v }); + } else { + // File/Blob: read as base64 + const bytes = new Uint8Array(await v.arrayBuffer()); + parts.push({ + name: k, + value: viewToBase64(bytes), + filename: v.name, + type: v.type, + isBlob: true, + }); + } + } + out.bodyType = "formdata"; + out.body = parts; + } else if (init.body instanceof URLSearchParams) { + out.bodyType = "string"; + out.body = init.body.toString(); + if (!Array.isArray(out.headers)) out.headers = []; + const hasContentType = out.headers.some(([k]) => k.toLowerCase() === "content-type"); + if (!hasContentType) { + out.headers.push(["content-type", "application/x-www-form-urlencoded"]); + } + } else { + // Fall back to JSON for plain objects + out.bodyType = "string"; + out.body = JSON.stringify(init.body); + } + } + return out; + } + + const http = { + fetch: async (url, init) => { + const marshaledInit = await marshalRequestInit(init); + const result = await bridgeCall("http/fetch", { url, init: marshaledInit }); + // Decode base64 body back to bytes to preserve binary content + // (images, audio, etc.) so arrayBuffer()/blob() work correctly. + const binaryString = atob(result.bodyBase64); + const bytes = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i); + } + return new Response(bytes, { + status: result.status, + statusText: result.statusText, + headers: result.headers, + }); + } + }; + + const log = { + debug: (msg, data) => bridgeCall("log", { level: "debug", msg, data }), + info: (msg, data) => bridgeCall("log", { level: "info", msg, data }), + warn: (msg, data) => bridgeCall("log", { level: "warn", msg, data }), + error: (msg, data) => bridgeCall("log", { level: "error", msg, data }), + }; + + const site = ${JSON.stringify(site)}; + const siteBaseUrl = ${JSON.stringify(site.url.replace(TRAILING_SLASH_RE, ""))}; + + function url(path) { + if (!path.startsWith("/")) { + throw new Error('URL path must start with "/", got: "' + path + '"'); + } + if (path.startsWith("//")) { + throw new Error('URL path must not be protocol-relative, got: "' + path + '"'); + } + return siteBaseUrl + path; + } + + const users = ${hasReadUsers} ? { + get: (id) => bridgeCall("users/get", { id }), + getByEmail: (email) => bridgeCall("users/getByEmail", { email }), + list: (opts) => bridgeCall("users/list", opts || {}), + } : undefined; + + const email = ${hasEmailSend} ? { + send: (message) => bridgeCall("email/send", { message }), + } : undefined; + + return { + plugin: { + id: ${JSON.stringify(manifest.id)}, + version: ${JSON.stringify(manifest.version || "0.0.0")}, + }, + storage, + kv, + content, + media, + http, + log, + site, + url, + users, + email, + }; +} + +// ----------------------------------------------------------------------------- +// HTTP Handler (replaces WorkerEntrypoint for workerd-on-Node) +// ----------------------------------------------------------------------------- + +// Constant-time string comparison. workerd doesn't expose +// crypto.timingSafeEqual, so XOR char codes manually. Always processes +// the full length of the longer input to avoid early-exit timing leaks. +function constantTimeEqual(a, b) { + let result = a.length === b.length ? 0 : 1; + const len = Math.max(a.length, b.length); + for (let i = 0; i < len; i++) { + const ac = i < a.length ? a.charCodeAt(i) : 0; + const bc = i < b.length ? b.charCodeAt(i) : 0; + result |= ac ^ bc; + } + return result === 0; +} + +export default { + async fetch(request) { + const url = new URL(request.url); + + // Authenticate the caller. The plugin's TCP listener is exposed on + // 127.0.0.1, so any local process could otherwise invoke hooks/routes + // directly. Only the Node runner has the per-startup invoke token. + // Use constant-time comparison: workerd doesn't expose timingSafeEqual, + // so we XOR character codes manually. Same length always required. + const authHeader = request.headers.get("authorization") || ""; + const expected = "Bearer " + INVOKE_TOKEN; + if (!constantTimeEqual(authHeader, expected)) { + return new Response("Unauthorized", { status: 401 }); + } + + if (url.pathname === "/__ready") { + return new Response("ok", { status: 200 }); + } + + // Hook invocation: POST /hook/{hookName} + if (url.pathname.startsWith("/hook/")) { + const hookName = url.pathname.slice(6); // Remove "/hook/" + const { event } = await request.json(); + const ctx = createContext(); + + const hookDef = hooks[hookName]; + if (!hookDef) { + return Response.json({ value: undefined }); + } + + const handler = typeof hookDef === "function" ? hookDef : hookDef.handler; + if (typeof handler !== "function") { + return new Response("Hook " + hookName + " handler is not a function", { status: 500 }); + } + + try { + const result = await handler(event, ctx); + return Response.json({ value: result }); + } catch (err) { + return new Response(err.message || "Hook error", { status: 500 }); + } + } + + // Route invocation: POST /route/{routeName} + if (url.pathname.startsWith("/route/")) { + const routeName = url.pathname.slice(7); // Remove "/route/" + const { input, request: serializedRequest } = await request.json(); + const ctx = createContext(); + + const route = routes[routeName]; + if (!route) { + return new Response("Route not found: " + routeName, { status: 404 }); + } + + const handler = typeof route === "function" ? route : route.handler; + if (typeof handler !== "function") { + return new Response("Route " + routeName + " handler is not a function", { status: 500 }); + } + + try { + const result = await handler( + { input, request: serializedRequest, requestMeta: serializedRequest?.meta }, + ctx, + ); + return Response.json(result); + } catch (err) { + return new Response(err.message || "Route error", { status: 500 }); + } + } + + return new Response("Not found", { status: 404 }); + } +}; +`; +} + +function sanitizeComment(s: string): string { + return s.replace(NEWLINE_RE, " ").replace(COMMENT_CLOSE_RE, "* /"); +} diff --git a/packages/workerd/test/bridge-handler.test.ts b/packages/workerd/test/bridge-handler.test.ts new file mode 100644 index 0000000000..191c025050 --- /dev/null +++ b/packages/workerd/test/bridge-handler.test.ts @@ -0,0 +1,535 @@ +/** + * Bridge Handler Conformance Tests + * + * Tests the shared bridge handler that both the production (workerd) + * and dev (miniflare) runners use. This is the conformance test suite + * that ensures identical behavior across all sandbox runners. + * + * These tests exercise capability enforcement, KV isolation, and + * error handling at the bridge level. + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { createBridgeHandler } from "../src/sandbox/bridge-handler.js"; + +// Set up an in-memory SQLite database with the minimum tables needed +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + return { db, sqlite }; +} + +async function setupTables(db: Kysely) { + // Plugin storage table (used for both KV and document storage) + await db.schema + .createTable("_plugin_storage") + .addColumn("plugin_id", "text", (col) => col.notNull()) + .addColumn("collection", "text", (col) => col.notNull()) + .addColumn("id", "text", (col) => col.notNull()) + .addColumn("data", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"]) + .execute(); + + // Users table (matches migration 001) + await db.schema + .createTable("users") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("email", "text", (col) => col.notNull()) + .addColumn("name", "text") + .addColumn("role", "integer", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + + // Insert a test user + await db + .insertInto("users" as any) + .values({ + id: "user-1", + email: "test@example.com", + name: "Test User", + role: 50, + created_at: new Date().toISOString(), + }) + .execute(); +} + +describe("Bridge Handler Conformance", () => { + let db: Kysely; + let sqlite: Database.Database; + + beforeEach(async () => { + const ctx = createTestDb(); + db = ctx.db; + sqlite = ctx.sqlite; + await setupTables(db); + }); + + afterEach(async () => { + await db.destroy(); + sqlite.close(); + }); + + function makeHandler(opts: { + capabilities?: string[]; + allowedHosts?: string[]; + storageCollections?: string[]; + }) { + return createBridgeHandler({ + pluginId: "test-plugin", + version: "1.0.0", + capabilities: opts.capabilities ?? [], + allowedHosts: opts.allowedHosts ?? [], + storageCollections: opts.storageCollections ?? [], + db, + emailSend: () => null, + }); + } + + async function call( + handler: ReturnType, + method: string, + body: Record = {}, + ) { + const request = new Request(`http://bridge/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const response = await handler(request); + return response.json() as Promise<{ result?: unknown; error?: string }>; + } + + // ── KV Operations ──────────────────────────────────────────────────── + + describe("KV operations", () => { + it("set and get a value", async () => { + const handler = makeHandler({}); + await call(handler, "kv/set", { key: "test", value: "hello" }); + const result = await call(handler, "kv/get", { key: "test" }); + expect(result.result).toBe("hello"); + }); + + it("get returns null for non-existent key", async () => { + const handler = makeHandler({}); + const result = await call(handler, "kv/get", { key: "missing" }); + expect(result.result).toBeNull(); + }); + + it("delete removes a key", async () => { + const handler = makeHandler({}); + await call(handler, "kv/set", { key: "to-delete", value: "bye" }); + await call(handler, "kv/delete", { key: "to-delete" }); + const result = await call(handler, "kv/get", { key: "to-delete" }); + expect(result.result).toBeNull(); + }); + + it("list returns keys with prefix", async () => { + const handler = makeHandler({}); + await call(handler, "kv/set", { key: "settings:theme", value: "dark" }); + await call(handler, "kv/set", { key: "settings:lang", value: "en" }); + await call(handler, "kv/set", { key: "state:count", value: 42 }); + + const result = await call(handler, "kv/list", { prefix: "settings:" }); + const items = result.result as Array<{ key: string; value: unknown }>; + expect(items).toHaveLength(2); + expect(items.map((i) => i.key).toSorted()).toEqual(["settings:lang", "settings:theme"]); + }); + + it("KV is scoped per plugin (isolation)", async () => { + const handlerA = createBridgeHandler({ + pluginId: "plugin-a", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + const handlerB = createBridgeHandler({ + pluginId: "plugin-b", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + + // Plugin A sets a value + await call(handlerA, "kv/set", { key: "secret", value: "a-data" }); + + // Plugin B cannot see it + const resultB = await call(handlerB, "kv/get", { key: "secret" }); + expect(resultB.result).toBeNull(); + + // Plugin A can see it + const resultA = await call(handlerA, "kv/get", { key: "secret" }); + expect(resultA.result).toBe("a-data"); + }); + }); + + // ── Capability Enforcement ──────────────────────────────────────────── + + describe("capability enforcement", () => { + it("rejects content read without read:content capability", async () => { + const handler = makeHandler({ capabilities: [] }); + const result = await call(handler, "content/get", { + collection: "posts", + id: "123", + }); + expect(result.error).toContain("Missing capability: read:content"); + }); + + it("allows content read with read:content", async () => { + // Create a content table first + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("deleted_at", "text") + .addColumn("title", "text") + .execute(); + + const handler = makeHandler({ capabilities: ["read:content"] }); + const result = await call(handler, "content/get", { + collection: "posts", + id: "123", + }); + // No error, returns null (post doesn't exist) + expect(result.error).toBeUndefined(); + expect(result.result).toBeNull(); + }); + + it("write:content does NOT imply read:content (matches Cloudflare bridge)", async () => { + // The bridge enforces capabilities strictly: a plugin that declares + // only write:content cannot call ctx.content.get/list. This matches + // the Cloudflare PluginBridge behavior. The plugin must declare + // read:content explicitly to read. + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("deleted_at", "text") + .addColumn("title", "text") + .execute(); + + const handler = makeHandler({ capabilities: ["write:content"] }); + const result = await call(handler, "content/get", { + collection: "posts", + id: "123", + }); + expect(result.error).toContain("Missing capability: read:content"); + }); + + it("rejects user read without read:users capability", async () => { + const handler = makeHandler({ capabilities: [] }); + const result = await call(handler, "users/get", { id: "user-1" }); + expect(result.error).toContain("Missing capability: read:users"); + }); + + it("allows user read with read:users", async () => { + const handler = makeHandler({ capabilities: ["read:users"] }); + const result = await call(handler, "users/get", { id: "user-1" }); + expect(result.error).toBeUndefined(); + const user = result.result as { id: string; email: string }; + expect(user.id).toBe("user-1"); + expect(user.email).toBe("test@example.com"); + }); + + it("rejects network fetch without network:fetch capability", async () => { + const handler = makeHandler({ capabilities: [] }); + const result = await call(handler, "http/fetch", { + url: "https://example.com", + }); + expect(result.error).toContain("Missing capability: network:fetch"); + }); + + it("rejects email send without email:send capability", async () => { + const handler = makeHandler({ capabilities: [] }); + const result = await call(handler, "email/send", { + message: { to: "a@b.com", subject: "hi", text: "hello" }, + }); + expect(result.error).toContain("Missing capability: email:send"); + }); + }); + + // ── Storage (document store) ────────────────────────────────────────── + + describe("plugin storage", () => { + it("rejects access to undeclared storage collection", async () => { + const handler = makeHandler({ storageCollections: ["logs"] }); + const result = await call(handler, "storage/get", { + collection: "secrets", + id: "1", + }); + expect(result.error).toContain("Storage collection not declared: secrets"); + }); + + it("allows access to declared storage collection", async () => { + const handler = makeHandler({ storageCollections: ["logs"] }); + const result = await call(handler, "storage/get", { + collection: "logs", + id: "1", + }); + expect(result.error).toBeUndefined(); + expect(result.result).toBeNull(); + }); + + it("put and get storage document", async () => { + const handler = makeHandler({ storageCollections: ["logs"] }); + await call(handler, "storage/put", { + collection: "logs", + id: "log-1", + data: { message: "hello", level: "info" }, + }); + const result = await call(handler, "storage/get", { + collection: "logs", + id: "log-1", + }); + expect(result.result).toEqual({ message: "hello", level: "info" }); + }); + + it("storage is scoped per plugin", async () => { + const handlerA = createBridgeHandler({ + pluginId: "plugin-a", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: ["data"], + db, + emailSend: () => null, + }); + const handlerB = createBridgeHandler({ + pluginId: "plugin-b", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: ["data"], + db, + emailSend: () => null, + }); + + await call(handlerA, "storage/put", { + collection: "data", + id: "item-1", + data: { owner: "a" }, + }); + + // Plugin B cannot see plugin A's data + const resultB = await call(handlerB, "storage/get", { + collection: "data", + id: "item-1", + }); + expect(resultB.result).toBeNull(); + }); + }); + + // ── Error Handling ──────────────────────────────────────────────────── + + describe("error handling", () => { + it("returns error for unknown bridge method", async () => { + const handler = makeHandler({}); + const result = await call(handler, "unknown/method"); + expect(result.error).toContain("Unknown bridge method: unknown/method"); + }); + + it("returns error for missing required parameters", async () => { + const handler = makeHandler({ capabilities: ["read:content"] }); + const result = await call(handler, "content/get", {}); + expect(result.error).toContain("Missing required string parameter"); + }); + }); + + // ── Limit clamping ──────────────────────────────────────────────────── + + describe("list endpoints clamp negative limit", () => { + it("content/list clamps negative limit to 1", async () => { + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("deleted_at", "text") + .addColumn("title", "text") + .execute(); + for (const id of ["post-1", "post-2", "post-3"]) { + await db + .insertInto("ec_posts" as any) + .values({ id, deleted_at: null, title: `Title ${id}` }) + .execute(); + } + + const handler = makeHandler({ capabilities: ["read:content"] }); + const result = await call(handler, "content/list", { + collection: "posts", + limit: -5, + }); + expect(result.error).toBeUndefined(); + const list = result.result as { items: unknown[] }; + expect(list.items.length).toBeGreaterThanOrEqual(1); + expect(list.items.length).toBeLessThanOrEqual(1); + }); + + it("media/list clamps negative limit to 1", async () => { + await db.schema + .createTable("media") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("filename", "text", (col) => col.notNull()) + .addColumn("mime_type", "text", (col) => col.notNull()) + .addColumn("size", "integer") + .addColumn("storage_key", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull().defaultTo("ready")) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + for (const id of ["m-1", "m-2", "m-3"]) { + await db + .insertInto("media" as any) + .values({ + id, + filename: `${id}.png`, + mime_type: "image/png", + size: 100, + storage_key: `keys/${id}`, + status: "ready", + created_at: new Date().toISOString(), + }) + .execute(); + } + + const handler = makeHandler({ capabilities: ["read:media"] }); + const result = await call(handler, "media/list", { limit: -5 }); + expect(result.error).toBeUndefined(); + const list = result.result as { items: unknown[] }; + expect(list.items.length).toBeGreaterThanOrEqual(1); + expect(list.items.length).toBeLessThanOrEqual(1); + }); + + it("storage/query clamps negative limit to 1", async () => { + const handler = makeHandler({ storageCollections: ["logs"] }); + for (const id of ["log-1", "log-2", "log-3"]) { + await call(handler, "storage/put", { + collection: "logs", + id, + data: { message: id }, + }); + } + + const result = await call(handler, "storage/query", { + collection: "logs", + where: {}, + limit: -5, + }); + expect(result.error).toBeUndefined(); + const list = result.result as { items: unknown[] }; + expect(list.items.length).toBeGreaterThanOrEqual(1); + expect(list.items.length).toBeLessThanOrEqual(1); + }); + + it("storage/query without limit returns all rows (undefined passthrough)", async () => { + const handler = makeHandler({ storageCollections: ["logs"] }); + for (const id of ["log-1", "log-2", "log-3"]) { + await call(handler, "storage/put", { + collection: "logs", + id, + data: { message: id }, + }); + } + + const result = await call(handler, "storage/query", { + collection: "logs", + where: {}, + }); + expect(result.error).toBeUndefined(); + const list = result.result as { items: unknown[] }; + expect(list.items.length).toBe(3); + }); + }); + + // ── Logging ─────────────────────────────────────────────────────────── + + describe("logging", () => { + it("log call succeeds without capabilities", async () => { + const handler = makeHandler({}); + const result = await call(handler, "log", { + level: "info", + msg: "test message", + }); + expect(result.error).toBeUndefined(); + expect(result.result).toBeNull(); + }); + }); + + // ── Batch transactionality ──────────────────────────────────────────── + + describe("batch operations are transactional", () => { + beforeEach(async () => { + await db.schema + .createTable("ec_atomic_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("slug", "text", (col) => col.unique()) + .addColumn("status", "text", (col) => col.defaultTo("draft")) + .addColumn("title", "text") + .addColumn("created_at", "text") + .addColumn("updated_at", "text") + .addColumn("deleted_at", "text") + .addColumn("version", "integer", (col) => col.defaultTo(1)) + .addColumn("author_id", "text") + .execute(); + }); + + it("contentCreateMany rolls back when a mid-batch insert fails", async () => { + const handler = makeHandler({ capabilities: ["write:content"] }); + // Pre-insert a row that will collide with item index 2's slug. + await call(handler, "content/create", { + collection: "atomic_posts", + data: { slug: "conflict", title: "existing" }, + }); + + const before = await db + .selectFrom("ec_atomic_posts" as any) + .selectAll() + .execute(); + expect(before).toHaveLength(1); + + const result = await call(handler, "content/createMany", { + collection: "atomic_posts", + items: [ + { slug: "a", title: "ok 1" }, + { slug: "b", title: "ok 2" }, + { slug: "conflict", title: "should fail" }, + { slug: "d", title: "would be ok" }, + ], + }); + expect(result.error).toBeDefined(); + + // After the failed batch, only the pre-existing row should remain. + const after = await db + .selectFrom("ec_atomic_posts" as any) + .selectAll() + .execute(); + expect(after).toHaveLength(1); + expect((after[0] as any).slug).toBe("conflict"); + }); + + it("contentCreateMany commits all when no item fails", async () => { + const handler = makeHandler({ capabilities: ["write:content"] }); + const result = await call(handler, "content/createMany", { + collection: "atomic_posts", + items: [ + { slug: "x1", title: "1" }, + { slug: "x2", title: "2" }, + { slug: "x3", title: "3" }, + ], + }); + expect(result.result).toBeDefined(); + const rows = await db + .selectFrom("ec_atomic_posts" as any) + .selectAll() + .execute(); + expect(rows).toHaveLength(3); + }); + }); +}); diff --git a/packages/workerd/test/miniflare-isolation.test.ts b/packages/workerd/test/miniflare-isolation.test.ts new file mode 100644 index 0000000000..2eaa8ccc4f --- /dev/null +++ b/packages/workerd/test/miniflare-isolation.test.ts @@ -0,0 +1,342 @@ +/** + * Miniflare Isolation Tests + * + * Integration tests verifying that miniflare (wrapping workerd) provides + * the isolation primitives needed for the MiniflareDevRunner: + * + * - Service bindings scope capabilities per plugin + * - External service bindings route calls to Node handler functions + * - Plugin code loads from strings (bundles from DB/R2) + * - KV namespace bindings provide per-plugin isolated storage + * - Plugins without bindings cannot access unavailable capabilities + * - Worker reconfiguration supports plugin install/uninstall + */ + +import { Miniflare } from "miniflare"; +import { afterEach, describe, expect, it } from "vitest"; + +describe("miniflare plugin isolation", () => { + let mf: Miniflare | undefined; + + afterEach(async () => { + if (mf) { + await mf.dispose(); + mf = undefined; + } + }); + + it("can create an isolated plugin worker with scoped service bindings", async () => { + // This test creates: + // 1. A "bridge" worker that simulates the backing service (content API) + // 2. A "plugin" worker that calls the bridge via service binding + // 3. Verifies the plugin can only access what the binding exposes + // + // dispatchFetch always hits the first worker in the array. + // To invoke a specific worker, we put the plugin first and use + // service bindings to connect it to the bridge. + + mf = new Miniflare({ + workers: [ + { + // Plugin is first so dispatchFetch targets it + name: "plugin-test", + modules: true, + serviceBindings: { + BRIDGE: "bridge", + }, + script: ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + + if (url.pathname === "/hook/afterSave") { + const res = await env.BRIDGE.fetch("http://bridge/content/get", { + method: "POST", + body: JSON.stringify({ collection: "posts", id: "123" }), + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + return Response.json({ + hookResult: "processed", + contentFromBridge: data, + }); + } + + return new Response("Unknown hook", { status: 404 }); + } + }; + `, + }, + { + name: "bridge", + modules: true, + script: ` + export default { + async fetch(request) { + const url = new URL(request.url); + if (url.pathname === "/content/get") { + const { collection, id } = await request.json(); + return Response.json({ + success: true, + data: { id, type: collection, slug: "test-post", data: { title: "Hello" } } + }); + } + return new Response("Not found", { status: 404 }); + } + }; + `, + }, + ], + }); + + // dispatchFetch hits the first worker (plugin-test) + const response = await mf.dispatchFetch("http://localhost/hook/afterSave"); + const result = (await response.json()) as { + hookResult: string; + contentFromBridge: { + success: boolean; + data: { id: string; type: string; slug: string }; + }; + }; + + expect(result.hookResult).toBe("processed"); + expect(result.contentFromBridge.success).toBe(true); + expect(result.contentFromBridge.data.id).toBe("123"); + expect(result.contentFromBridge.data.type).toBe("posts"); + }); + + it("plugins are isolated from each other", async () => { + // Two plugins with different service bindings. + // Plugin A has BRIDGE binding (read:content). + // Plugin B has NO bridge binding (no capabilities). + // Use separate Miniflare instances to test isolation, + // since dispatchFetch always hits the first worker. + + // Test Plugin A: has BRIDGE binding + mf = new Miniflare({ + workers: [ + { + name: "plugin-a", + modules: true, + serviceBindings: { + BRIDGE: async () => { + return Response.json({ success: true, data: { secret: "bridge-data" } }); + }, + }, + script: ` + export default { + async fetch(request, env) { + const res = await env.BRIDGE.fetch("http://bridge/"); + const data = await res.json(); + return Response.json({ hasAccess: true, data }); + } + }; + `, + }, + ], + }); + + const resA = await mf.dispatchFetch("http://localhost/"); + const dataA = (await resA.json()) as { hasAccess: boolean }; + expect(dataA.hasAccess).toBe(true); + await mf.dispose(); + + // Test Plugin B: NO bridge binding + mf = new Miniflare({ + workers: [ + { + name: "plugin-b", + modules: true, + // NO service bindings - this plugin has no capabilities + script: ` + export default { + async fetch(request, env) { + const hasBridge = "BRIDGE" in env; + return Response.json({ hasBridge }); + } + }; + `, + }, + ], + }); + + const resB = await mf.dispatchFetch("http://localhost/"); + const dataB = (await resB.json()) as { hasBridge: boolean }; + expect(dataB.hasBridge).toBe(false); + }); + + it("can load plugin code dynamically from a string", async () => { + // Test that we can pass plugin code as a string (not a file path). + // This is critical for the runtime: plugin bundles come from the DB/R2, + // not from the filesystem. + + const pluginCode = ` + export default { + async fetch(request, env) { + return Response.json({ + pluginId: "dynamic-plugin", + version: "1.0.0", + message: "I was loaded from a string!", + }); + } + }; + `; + + mf = new Miniflare({ + workers: [ + { + name: "dynamic-plugin", + modules: true, + script: pluginCode, + }, + ], + }); + + const response = await mf.dispatchFetch("http://dynamic-plugin/"); + const result = (await response.json()) as { pluginId: string; message: string }; + expect(result.pluginId).toBe("dynamic-plugin"); + expect(result.message).toBe("I was loaded from a string!"); + }); + + it("can use KV namespace bindings per plugin", async () => { + // Plugin with KV namespace binding + mf = new Miniflare({ + kvNamespaces: ["PLUGIN_KV"], + modules: true, + script: ` + export default { + async fetch(request, env) { + const url = new URL(request.url); + if (url.pathname === "/set") { + await env.PLUGIN_KV.put("test-key", "test-value"); + return new Response("set"); + } + if (url.pathname === "/get") { + const val = await env.PLUGIN_KV.get("test-key"); + return Response.json({ value: val }); + } + return new Response("unknown", { status: 404 }); + } + }; + `, + }); + + // Set and get + await mf.dispatchFetch("http://localhost/set"); + const getRes = await mf.dispatchFetch("http://localhost/get"); + const getData = (await getRes.json()) as { value: string }; + expect(getData.value).toBe("test-value"); + await mf.dispose(); + + // Plugin without KV has no access + mf = new Miniflare({ + modules: true, + script: ` + export default { + async fetch(request, env) { + const hasKv = "PLUGIN_KV" in env; + return Response.json({ hasKv }); + } + }; + `, + }); + + const noKvRes = await mf.dispatchFetch("http://localhost/"); + const noKvData = (await noKvRes.json()) as { hasKv: boolean }; + expect(noKvData.hasKv).toBe(false); + }); + + it("can reconfigure workers without full restart (add/remove plugins)", async () => { + // Test that we can dispose and recreate miniflare with different workers. + // This simulates plugin install/uninstall. + + // Start with one plugin + mf = new Miniflare({ + modules: true, + script: ` + export default { + async fetch() { return Response.json({ id: "original" }); } + }; + `, + }); + + const res1 = await mf.dispatchFetch("http://localhost/"); + const data1 = (await res1.json()) as { id: string }; + expect(data1.id).toBe("original"); + + // Dispose and recreate with a different plugin + await mf.dispose(); + + mf = new Miniflare({ + modules: true, + script: ` + export default { + async fetch() { return Response.json({ id: "new-plugin" }); } + }; + `, + }); + + const res2 = await mf.dispatchFetch("http://localhost/"); + const data2 = (await res2.json()) as { id: string }; + expect(data2.id).toBe("new-plugin"); + }); + + it("external service binding to Node HTTP server works", async () => { + // Critical test: can a plugin worker call an EXTERNAL HTTP service + // (simulating the Node backing service) via a service binding? + // + // Miniflare supports `serviceBindings` with custom handler functions. + // This maps to how the Node process would expose backing services. + + mf = new Miniflare({ + workers: [ + { + name: "plugin-with-external-bridge", + modules: true, + serviceBindings: { + BRIDGE: async (request: Request) => { + // This function runs in Node, not in workerd. + // It simulates the backing service HTTP handler. + const url = new URL(request.url); + if (url.pathname === "/content/get") { + const body = (await request.json()) as { collection: string; id: string }; + return Response.json({ + success: true, + data: { + id: body.id, + type: body.collection, + data: { title: "From Node backing service" }, + }, + }); + } + return new Response("Not found", { status: 404 }); + }, + }, + script: ` + export default { + async fetch(request, env) { + const res = await env.BRIDGE.fetch("http://bridge/content/get", { + method: "POST", + body: JSON.stringify({ collection: "posts", id: "from-plugin" }), + headers: { "Content-Type": "application/json" }, + }); + const data = await res.json(); + return Response.json(data); + } + }; + `, + }, + ], + }); + + const response = await mf.dispatchFetch("http://plugin-with-external-bridge/"); + const result = (await response.json()) as { + success: boolean; + data: { id: string; data: { title: string } }; + }; + + expect(result.success).toBe(true); + expect(result.data.id).toBe("from-plugin"); + expect(result.data.data.title).toBe("From Node backing service"); + }); +}); diff --git a/packages/workerd/test/plugin-integration.test.ts b/packages/workerd/test/plugin-integration.test.ts new file mode 100644 index 0000000000..63faeb1e90 --- /dev/null +++ b/packages/workerd/test/plugin-integration.test.ts @@ -0,0 +1,465 @@ +/** + * Plugin Integration Tests + * + * Exercises the bridge handler with the same operations that EmDash's + * shipped plugins perform. Uses a real SQLite database with migrations + * to test against the actual schema, not hand-rolled test tables. + * + * This validates that the workerd bridge handler produces the same + * results as the Cloudflare PluginBridge for real plugin workloads. + * + * Tests are modeled after the sandboxed-test plugin's routes: + * - kv/test: set, get, delete a KV entry + * - storage/test: put, get, count in a declared storage collection + * - content/list: list content with read:content capability + * - content lifecycle: create, read, update, soft-delete + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { createBridgeHandler } from "../src/sandbox/bridge-handler.js"; + +/** + * Create a test database with the minimum schema needed for plugin operations. + * Matches the real migration schema (001_initial + 004_plugins). + */ +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + return { db, sqlite }; +} + +async function runMigrations(db: Kysely) { + // Plugin storage (migration 004) + await db.schema + .createTable("_plugin_storage") + .addColumn("plugin_id", "text", (col) => col.notNull()) + .addColumn("collection", "text", (col) => col.notNull()) + .addColumn("id", "text", (col) => col.notNull()) + .addColumn("data", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"]) + .execute(); + + // Users (migration 001) + await db.schema + .createTable("users") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("email", "text", (col) => col.notNull()) + .addColumn("name", "text") + .addColumn("role", "integer", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + + // Media (migration 001) + await db.schema + .createTable("media") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("filename", "text", (col) => col.notNull()) + .addColumn("mime_type", "text", (col) => col.notNull()) + .addColumn("size", "integer") + .addColumn("storage_key", "text", (col) => col.notNull()) + .addColumn("status", "text", (col) => col.notNull().defaultTo("pending")) + .addColumn("created_at", "text", (col) => col.notNull()) + .execute(); + + // Content table for posts (created by SchemaRegistry in real code) + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("slug", "text") + .addColumn("status", "text", (col) => col.notNull().defaultTo("draft")) + .addColumn("author_id", "text") + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addColumn("published_at", "text") + .addColumn("deleted_at", "text") + .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("title", "text") + .addColumn("body", "text") + .execute(); +} + +describe("Plugin integration: sandboxed-test plugin operations", () => { + let db: Kysely; + let sqlite: Database.Database; + + beforeEach(async () => { + const ctx = createTestDb(); + db = ctx.db; + sqlite = ctx.sqlite; + await runMigrations(db); + }); + + afterEach(async () => { + await db.destroy(); + sqlite.close(); + }); + + /** + * Create a bridge handler matching the sandboxed-test plugin's capabilities: + * read:content, network:fetch with allowedHosts: ["httpbin.org"] + * storage: { events: { indexes: ["timestamp", "type"] } } + */ + function makePluginHandler() { + return createBridgeHandler({ + pluginId: "sandboxed-test", + version: "0.0.1", + capabilities: ["read:content", "network:fetch"], + allowedHosts: ["httpbin.org"], + storageCollections: ["events"], + db, + emailSend: () => null, + }); + } + + async function call( + handler: ReturnType, + method: string, + body: Record = {}, + ) { + const request = new Request(`http://bridge/${method}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const response = await handler(request); + return response.json() as Promise<{ result?: unknown; error?: string }>; + } + + // ── Mirrors sandboxed-test plugin's kv/test route ──────────────────── + + it("KV round-trip: set, get, delete", async () => { + const handler = makePluginHandler(); + + // Set + await call(handler, "kv/set", { + key: "sandbox-test-key", + value: { tested: true, time: 12345 }, + }); + + // Get + const getResult = await call(handler, "kv/get", { key: "sandbox-test-key" }); + expect(getResult.result).toEqual({ tested: true, time: 12345 }); + + // Delete + const deleteResult = await call(handler, "kv/delete", { key: "sandbox-test-key" }); + expect(deleteResult.result).toBe(true); + + // Verify deleted + const afterDelete = await call(handler, "kv/get", { key: "sandbox-test-key" }); + expect(afterDelete.result).toBeNull(); + }); + + // ── Mirrors sandboxed-test plugin's storage/test route ─────────────── + + it("Storage round-trip: put, get, count", async () => { + const handler = makePluginHandler(); + + // Put + await call(handler, "storage/put", { + collection: "events", + id: "event-1", + data: { + timestamp: "2025-01-01T00:00:00Z", + type: "test", + message: "Sandboxed plugin storage test", + }, + }); + + // Get + const getResult = await call(handler, "storage/get", { + collection: "events", + id: "event-1", + }); + expect(getResult.result).toEqual({ + timestamp: "2025-01-01T00:00:00Z", + type: "test", + message: "Sandboxed plugin storage test", + }); + + // Count + const countResult = await call(handler, "storage/count", { collection: "events" }); + expect(countResult.result).toBe(1); + }); + + // ── Mirrors sandboxed-test plugin's content/list route ─────────────── + + it("Content list with read:content capability", async () => { + const handler = makePluginHandler(); + + // Seed some content + const now = new Date().toISOString(); + await db + .insertInto("ec_posts" as any) + .values([ + { + id: "post-1", + slug: "hello", + status: "published", + title: "Hello World", + created_at: now, + updated_at: now, + version: 1, + }, + { + id: "post-2", + slug: "second", + status: "draft", + title: "Second Post", + created_at: now, + updated_at: now, + version: 1, + }, + ]) + .execute(); + + const result = await call(handler, "content/list", { collection: "posts", limit: 5 }); + expect(result.error).toBeUndefined(); + + const data = result.result as { + items: Array<{ id: string; type: string; data: Record }>; + hasMore: boolean; + }; + expect(data.items).toHaveLength(2); + expect(data.hasMore).toBe(false); + // Items should be transformed via rowToContentItem + expect(data.items[0]!.type).toBe("posts"); + expect(data.items[0]!.data.title).toBeDefined(); + }); + + // ── Content lifecycle: create, read, update, soft-delete ───────────── + + describe("content lifecycle (requires read:content + write:content)", () => { + function makeWriteHandler() { + // Bridge enforces capabilities strictly: write:content does NOT + // imply read:content. Plugins that need both must declare both. + return createBridgeHandler({ + pluginId: "sandboxed-test", + version: "0.0.1", + capabilities: ["read:content", "write:content"], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + } + + it("create, read, update, delete", async () => { + const handler = makeWriteHandler(); + + // Create + const createResult = await call(handler, "content/create", { + collection: "posts", + data: { title: "New Post", body: "Content here", slug: "new-post", status: "draft" }, + }); + expect(createResult.error).toBeUndefined(); + const created = createResult.result as { + id: string; + type: string; + data: Record; + }; + expect(created.type).toBe("posts"); + expect(created.data.title).toBe("New Post"); + expect(created.id).toBeTruthy(); + + // Read + const readResult = await call(handler, "content/get", { + collection: "posts", + id: created.id, + }); + expect(readResult.error).toBeUndefined(); + const read = readResult.result as { id: string; data: Record }; + expect(read.data.title).toBe("New Post"); + + // Update + const updateResult = await call(handler, "content/update", { + collection: "posts", + id: created.id, + data: { title: "Updated Post" }, + }); + expect(updateResult.error).toBeUndefined(); + const updated = updateResult.result as { id: string; data: Record }; + expect(updated.data.title).toBe("Updated Post"); + + // Delete (soft-delete) + const deleteResult = await call(handler, "content/delete", { + collection: "posts", + id: created.id, + }); + expect(deleteResult.result).toBe(true); + + // Verify soft-deleted: get returns null + const afterDelete = await call(handler, "content/get", { + collection: "posts", + id: created.id, + }); + expect(afterDelete.result).toBeNull(); + }); + }); + + // ── Capability enforcement matches real plugin config ───────────────── + + it("sandboxed-test plugin cannot write content (only has read:content)", async () => { + const handler = makePluginHandler(); + const result = await call(handler, "content/create", { + collection: "posts", + data: { title: "Should fail" }, + }); + expect(result.error).toContain("Missing capability: write:content"); + }); + + it("write-only plugin cannot read content (no implicit upgrade)", async () => { + // Plugins with only write:content cannot call ctx.content.get/list. + // This matches the Cloudflare PluginBridge: capabilities are enforced + // strictly as declared in the manifest. A plugin that needs both + // reads and writes must declare both capabilities. + await db.schema + .createTable("ec_pages") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("slug", "text") + .addColumn("status", "text", (col) => col.notNull().defaultTo("draft")) + .addColumn("author_id", "text") + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addColumn("deleted_at", "text") + .addColumn("version", "integer", (col) => col.notNull().defaultTo(1)) + .addColumn("title", "text") + .execute(); + + const writeOnlyHandler = createBridgeHandler({ + pluginId: "write-only-plugin", + version: "1.0.0", + capabilities: ["write:content"], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + + // content/get should fail + const getResult = await call(writeOnlyHandler, "content/get", { + collection: "pages", + id: "any", + }); + expect(getResult.error).toContain("Missing capability: read:content"); + + // content/list should also fail + const listResult = await call(writeOnlyHandler, "content/list", { + collection: "pages", + }); + expect(listResult.error).toContain("Missing capability: read:content"); + + // content/create should still succeed (has write:content) + const createResult = await call(writeOnlyHandler, "content/create", { + collection: "pages", + data: { title: "Allowed" }, + }); + expect(createResult.error).toBeUndefined(); + }); + + it("write-only media plugin cannot read media", async () => { + // Same enforcement for media: write:media does NOT imply read:media. + const writeOnlyHandler = createBridgeHandler({ + pluginId: "write-only-media", + version: "1.0.0", + capabilities: ["write:media"], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + + const getResult = await call(writeOnlyHandler, "media/get", { id: "any" }); + expect(getResult.error).toContain("Missing capability: read:media"); + + const listResult = await call(writeOnlyHandler, "media/list", {}); + expect(listResult.error).toContain("Missing capability: read:media"); + }); + + it("sandboxed-test plugin cannot send email (not in capabilities)", async () => { + const handler = makePluginHandler(); + const result = await call(handler, "email/send", { + message: { to: "a@b.com", subject: "hi", text: "hello" }, + }); + expect(result.error).toContain("Missing capability: email:send"); + }); + + it("sandboxed-test plugin cannot access undeclared storage collections", async () => { + const handler = makePluginHandler(); + const result = await call(handler, "storage/get", { + collection: "secrets", + id: "1", + }); + expect(result.error).toContain("Storage collection not declared: secrets"); + }); + + // ── Cross-plugin isolation ──────────────────────────────────────────── + + it("two plugins cannot see each other's KV data", async () => { + const pluginA = createBridgeHandler({ + pluginId: "plugin-a", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + const pluginB = createBridgeHandler({ + pluginId: "plugin-b", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: [], + db, + emailSend: () => null, + }); + + await call(pluginA, "kv/set", { key: "secret", value: "a-only" }); + + const fromA = await call(pluginA, "kv/get", { key: "secret" }); + expect(fromA.result).toBe("a-only"); + + const fromB = await call(pluginB, "kv/get", { key: "secret" }); + expect(fromB.result).toBeNull(); + }); + + it("two plugins cannot see each other's storage documents", async () => { + const pluginA = createBridgeHandler({ + pluginId: "plugin-a", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: ["shared-name"], + db, + emailSend: () => null, + }); + const pluginB = createBridgeHandler({ + pluginId: "plugin-b", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storageCollections: ["shared-name"], + db, + emailSend: () => null, + }); + + await call(pluginA, "storage/put", { + collection: "shared-name", + id: "doc-1", + data: { owner: "a" }, + }); + + const fromA = await call(pluginA, "storage/get", { collection: "shared-name", id: "doc-1" }); + expect((fromA.result as Record).owner).toBe("a"); + + const fromB = await call(pluginB, "storage/get", { collection: "shared-name", id: "doc-1" }); + expect(fromB.result).toBeNull(); + }); +}); diff --git a/packages/workerd/test/runner-eager-start.test.ts b/packages/workerd/test/runner-eager-start.test.ts new file mode 100644 index 0000000000..5d7f43e4ee --- /dev/null +++ b/packages/workerd/test/runner-eager-start.test.ts @@ -0,0 +1,74 @@ +/** + * Eager-Start Error Handling Tests + * + * Regression coverage for an unhandled-rejection bug in + * `WorkerdSandboxRunner.scheduleEagerStart()`: the debounced timer body + * used to do `void this.ensureRunning();`. If startup rejected (spawn + * failure, ENOENT, capnp parse error, waitForReady timeout) the rejection + * was silently swallowed -- bypassing the crashCount / scheduleRestart + * accounting that handles post-spawn crashes, so the runner would stay + * unhealthy with no automatic retry. + * + * The fix replaces the void with a `.catch()` that logs the error and + * calls `scheduleRestart()` to engage the existing backoff machinery. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WorkerdSandboxRunner } from "../src/sandbox/runner.js"; + +describe("scheduleEagerStart error handling", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("logs and triggers scheduleRestart when ensureRunning rejects", async () => { + const runner = new WorkerdSandboxRunner({ db: null as any }); + const startError = new Error("simulated startup failure"); + + // Stub ensureRunning to reject. The eager-start timer body calls + // `this.ensureRunning()`, so monkey-patching is enough. + vi.spyOn(runner as any, "ensureRunning").mockRejectedValue(startError); + const scheduleRestartSpy = vi.spyOn(runner as any, "scheduleRestart").mockImplementation(() => { + // no-op -- we just want to observe the call + }); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + // Trigger scheduleEagerStart via the public load() path. + await runner.load( + { + id: "test-plugin", + name: "test", + version: "1.0.0", + capabilities: [], + storage: {}, + } as any, + "export default {};", + ); + + // Advance past the 50ms debounce; the async variant flushes microtasks + // between ticks, so the `.catch` actually runs. + await vi.advanceTimersByTimeAsync(60); + + expect(errorSpy).toHaveBeenCalled(); + const firstCall = errorSpy.mock.calls[0]; + expect(String(firstCall?.[0])).toContain("[emdash:workerd] eager start failed"); + expect(firstCall?.[1]).toBe(startError); + + expect(scheduleRestartSpy).toHaveBeenCalledTimes(1); + + // Cleanup. ensureRunning is stubbed so workerd was never spawned -- + // terminateAll() should be quick. Guard with try/catch in case stubbing + // breaks a downstream assumption. + try { + await runner.terminateAll(); + } catch { + // best-effort cleanup + } + }); +}); diff --git a/packages/workerd/test/runner-env-isolation.test.ts b/packages/workerd/test/runner-env-isolation.test.ts new file mode 100644 index 0000000000..0a806dc853 --- /dev/null +++ b/packages/workerd/test/runner-env-isolation.test.ts @@ -0,0 +1,63 @@ +/** + * Regression tests for env isolation when spawning workerd. + * + * workerd is spawned with a curated minimal env (see `minimalWorkerdEnv`) + * rather than the parent's full `process.env`. This is defense in depth + * against host secrets (DATABASE_URL, API keys, etc.) leaking into plugin + * code via workerd's `nodejs_compat` `process.env` polyfill. + * + * Empirical check (run at PR #426 review time, workerd 1.x) showed the + * polyfill currently exposes an empty `process.env` to plugins regardless + * of the child env. These tests pin the minimal-env behavior so a future + * polyfill change can't silently leak secrets. + */ + +import { afterEach, describe, expect, it } from "vitest"; + +import { minimalWorkerdEnv } from "../src/sandbox/runner.js"; + +describe("minimalWorkerdEnv", () => { + afterEach(() => { + delete process.env.EMDASH_TEST_SECRET; + delete process.env.EMDASH_WORKERD_PASSTHROUGH_ENV; + }); + + it("does not pass arbitrary host env vars to workerd", () => { + process.env.EMDASH_TEST_SECRET = "shouldnotleak"; + + const env = minimalWorkerdEnv(); + + expect(env.EMDASH_TEST_SECRET).toBeUndefined(); + // Sanity: it should not be empty — PATH is in the allowlist and + // is essentially always set in test environments. + if (process.env.PATH !== undefined) { + expect(env.PATH).toBe(process.env.PATH); + } + }); + + it("passes through vars listed in EMDASH_WORKERD_PASSTHROUGH_ENV", () => { + process.env.EMDASH_TEST_SECRET = "needed-by-plugin"; + process.env.EMDASH_WORKERD_PASSTHROUGH_ENV = "EMDASH_TEST_SECRET"; + + const env = minimalWorkerdEnv(); + + expect(env.EMDASH_TEST_SECRET).toBe("needed-by-plugin"); + }); + + it("trims whitespace and ignores empty entries in the passthrough list", () => { + process.env.EMDASH_TEST_SECRET = "value"; + process.env.EMDASH_WORKERD_PASSTHROUGH_ENV = " , EMDASH_TEST_SECRET , "; + + const env = minimalWorkerdEnv(); + + expect(env.EMDASH_TEST_SECRET).toBe("value"); + }); + + it("skips passthrough names that are not set on the host", () => { + process.env.EMDASH_WORKERD_PASSTHROUGH_ENV = "EMDASH_DOES_NOT_EXIST_XYZ"; + + const env = minimalWorkerdEnv(); + + expect(env.EMDASH_DOES_NOT_EXIST_XYZ).toBeUndefined(); + }); +}); diff --git a/packages/workerd/test/runner-exit-handler.test.ts b/packages/workerd/test/runner-exit-handler.test.ts new file mode 100644 index 0000000000..62f5143bcb --- /dev/null +++ b/packages/workerd/test/runner-exit-handler.test.ts @@ -0,0 +1,130 @@ +/** + * Exit Handler Tests + * + * Regression coverage for a race in WorkerdSandboxRunner.restart(): the + * exit handler used to capture `this` and unconditionally mutate + * `this.workerdProcess` / `this.healthy` whenever it fired. A late exit + * from a previously-spawned workerd could therefore null out the handle + * of a freshly-spawned workerd and mark it unhealthy. + * + * The fix is an identity guard inside `makeWorkerdExitHandler`: if + * `host.workerdProcess` no longer points at the proc the handler was + * bound to, the handler is a stale survivor from a prior workerd and + * must short-circuit. + */ + +import type { ChildProcess } from "node:child_process"; + +import { describe, it, expect } from "vitest"; + +import { makeWorkerdExitHandler } from "../src/sandbox/runner.js"; +import type { ExitHandlerHost } from "../src/sandbox/runner.js"; + +interface FakeHost extends ExitHandlerHost { + scheduleRestartCalls: number; +} + +function makeHost(overrides: Partial = {}): FakeHost { + const host: FakeHost = { + workerdProcess: null, + healthy: false, + shuttingDown: false, + intentionalStop: false, + scheduleRestartCalls: 0, + scheduleRestart() { + host.scheduleRestartCalls++; + }, + ...overrides, + }; + return host; +} + +// The handler only ever uses `proc` as an identity reference (=== +// comparison against host.workerdProcess), so any object will do. +function fakeProc(label: string): ChildProcess { + return { __label: label } as unknown as ChildProcess; +} + +describe("makeWorkerdExitHandler", () => { + it("is a no-op when host has moved on to a newer workerd", () => { + const procA = fakeProc("A"); + const procB = fakeProc("B"); + const host = makeHost({ workerdProcess: procB, healthy: true }); + + makeWorkerdExitHandler(host, procA)(1, null); + + // procB is still installed and untouched -- the stale handler + // must not null it out or mark it unhealthy. + expect(host.workerdProcess).toBe(procB); + expect(host.healthy).toBe(true); + expect(host.scheduleRestartCalls).toBe(0); + }); + + it("nulls out the handle and schedules a restart on crash exit", () => { + const procA = fakeProc("A"); + const host = makeHost({ workerdProcess: procA, healthy: true }); + + makeWorkerdExitHandler(host, procA)(1, null); + + expect(host.workerdProcess).toBeNull(); + expect(host.healthy).toBe(false); + expect(host.scheduleRestartCalls).toBe(1); + }); + + it("clears intentionalStop and skips restart for intentional stops", () => { + const procA = fakeProc("A"); + const host = makeHost({ + workerdProcess: procA, + healthy: true, + intentionalStop: true, + }); + + makeWorkerdExitHandler(host, procA)(0, null); + + // Handle/health still get cleared (the process is gone), but + // the intentional-stop flag must be consumed and no restart + // must be scheduled -- otherwise every plugin reload would + // trigger a phantom crash-restart cycle. + expect(host.workerdProcess).toBeNull(); + expect(host.healthy).toBe(false); + expect(host.intentionalStop).toBe(false); + expect(host.scheduleRestartCalls).toBe(0); + }); + + it("skips restart while the runner is shutting down", () => { + const procA = fakeProc("A"); + const host = makeHost({ + workerdProcess: procA, + healthy: true, + shuttingDown: true, + }); + + makeWorkerdExitHandler(host, procA)(1, "SIGTERM"); + + expect(host.workerdProcess).toBeNull(); + expect(host.healthy).toBe(false); + expect(host.scheduleRestartCalls).toBe(0); + }); + + it("schedules a restart when killed by a signal even if code is null", () => { + const procA = fakeProc("A"); + const host = makeHost({ workerdProcess: procA, healthy: true }); + + makeWorkerdExitHandler(host, procA)(null, "SIGKILL"); + + expect(host.workerdProcess).toBeNull(); + expect(host.healthy).toBe(false); + expect(host.scheduleRestartCalls).toBe(1); + }); + + it("does not schedule a restart on a clean exit (code 0, no signal)", () => { + const procA = fakeProc("A"); + const host = makeHost({ workerdProcess: procA, healthy: true }); + + makeWorkerdExitHandler(host, procA)(0, null); + + expect(host.workerdProcess).toBeNull(); + expect(host.healthy).toBe(false); + expect(host.scheduleRestartCalls).toBe(0); + }); +}); diff --git a/packages/workerd/test/runner-port-reclaim.test.ts b/packages/workerd/test/runner-port-reclaim.test.ts new file mode 100644 index 0000000000..c97046a5c4 --- /dev/null +++ b/packages/workerd/test/runner-port-reclaim.test.ts @@ -0,0 +1,86 @@ +/** + * Plugin Port Reclaim Tests + * + * Regression coverage for an unbounded-port-growth bug in + * `WorkerdSandboxRunner`: `load()` allocated plugin ports via + * `this.nextPluginPort++`, but `unloadPlugin()` only deleted the entry + * from the map without ever returning the port. A long-running site + * with frequent marketplace install/uninstall (or dev watcher reloads) + * would keep climbing toward 65535 and could collide with whatever + * else the host happened to be listening on. + * + * The fix maintains a `freePorts` pool: `unloadPlugin()` pushes the + * port back, and `load()` prefers a recycled port over allocating a + * fresh one. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WorkerdSandboxRunner } from "../src/sandbox/runner.js"; + +function stubManifest(id: string) { + return { + id, + name: id, + version: "1.0.0", + capabilities: [], + storage: {}, + } as any; +} + +describe("plugin port reclaim", () => { + let runner: WorkerdSandboxRunner; + + beforeEach(() => { + vi.useFakeTimers(); + runner = new WorkerdSandboxRunner({ db: null as any }); + // Stub ensureRunning so the debounced eager-start timer body (if it + // ever fires) doesn't try to actually spawn workerd. + vi.spyOn(runner as any, "ensureRunning").mockResolvedValue(undefined); + }); + + afterEach(async () => { + try { + await runner.terminateAll(); + } catch { + // best-effort cleanup -- ensureRunning is stubbed + } + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("reuses ports freed by unload", async () => { + const p1 = await runner.load(stubManifest("a"), "export default {};"); + const p2 = await runner.load(stubManifest("b"), "export default {};"); + const port1 = (p1 as any).port; + const port2 = (p2 as any).port; + expect(port2).toBeGreaterThan(port1); + + runner.unloadPlugin("a:1.0.0"); + + const p3 = await runner.load(stubManifest("c"), "export default {};"); + const port3 = (p3 as any).port; + // p3 should reuse the port freed by p1, not climb past port2. + expect(port3).toBe(port1); + }); + + it("does not grow nextPluginPort across repeated load/unload cycles", async () => { + await runner.load(stubManifest("x"), "export default {};"); + await runner.load(stubManifest("y"), "export default {};"); + runner.unloadPlugin("x:1.0.0"); + runner.unloadPlugin("y:1.0.0"); + + // Capture nextPluginPort after the initial allocations. + const before = (runner as any).nextPluginPort; + + for (let i = 0; i < 10; i++) { + const p = await runner.load(stubManifest(`p${i}`), "export default {};"); + runner.unloadPlugin(`p${i}:1.0.0`); + void p; // keep tsc happy + } + + const after = (runner as any).nextPluginPort; + // Ten load/unload cycles should have entirely reused freed ports. + expect(after).toBe(before); + }); +}); diff --git a/packages/workerd/test/runner-ready-probe.test.ts b/packages/workerd/test/runner-ready-probe.test.ts new file mode 100644 index 0000000000..655054ac4a --- /dev/null +++ b/packages/workerd/test/runner-ready-probe.test.ts @@ -0,0 +1,89 @@ +/** + * Readiness Probe Tests + * + * Regression coverage for the multi-plugin readiness probe. The original + * implementation only probed the first plugin, so a downstream plugin + * failing to bind would still be reported as ready, leaving subsequent + * hook invocations to hang or hit ECONNREFUSED. + */ + +import http from "node:http"; +import type { AddressInfo } from "node:net"; + +import { describe, it, expect } from "vitest"; + +import { probeAllReady } from "../src/sandbox/runner.js"; + +type Behavior = "ready" | "not-ready" | "down"; + +interface FakeServer { + port: number; + close: () => Promise; +} + +function makeServer(behavior: Behavior): Promise { + return new Promise((resolve) => { + const server = http.createServer((req, res) => { + if (behavior === "ready" && req.url === "/__ready") { + res.writeHead(200, { "Content-Type": "text/plain" }).end("ok"); + } else if (behavior === "not-ready" && req.url === "/__ready") { + res.writeHead(503).end(); + } else { + res.writeHead(404).end(); + } + }); + if (behavior === "down") { + // Listen briefly to get a free port, then close so nothing is on it. + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + server.close(() => resolve({ port, close: async () => {} })); + }); + return; + } + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as AddressInfo).port; + resolve({ + port, + close: () => new Promise((r) => server.close(() => r())), + }); + }); + }); +} + +describe("probeAllReady", () => { + it("returns true when all plugins are ready", async () => { + const a = await makeServer("ready"); + const b = await makeServer("ready"); + try { + expect(await probeAllReady([{ port: a.port }, { port: b.port }], "tok")).toBe(true); + } finally { + await a.close(); + await b.close(); + } + }); + + it("returns false when any plugin is not ready", async () => { + const a = await makeServer("ready"); + const b = await makeServer("not-ready"); + try { + expect(await probeAllReady([{ port: a.port }, { port: b.port }], "tok")).toBe(false); + } finally { + await a.close(); + await b.close(); + } + }); + + it("returns false when any plugin is unreachable", async () => { + const a = await makeServer("ready"); + const b = await makeServer("down"); + try { + expect(await probeAllReady([{ port: a.port }, { port: b.port }], "tok", 200)).toBe(false); + } finally { + await a.close(); + } + }); + + it("returns true when plugin list is empty", async () => { + expect(await probeAllReady([], "tok")).toBe(true); + }); +}); diff --git a/packages/workerd/test/runner-stop-workerd.test.ts b/packages/workerd/test/runner-stop-workerd.test.ts new file mode 100644 index 0000000000..a95ccd6166 --- /dev/null +++ b/packages/workerd/test/runner-stop-workerd.test.ts @@ -0,0 +1,137 @@ +/** + * stopWorkerd Timer Cleanup Tests + * + * Regression coverage for a hygiene bug in WorkerdSandboxRunner.stopWorkerd(): + * the SIGTERM-then-SIGKILL fallback timer was never cleared when the process + * exited cleanly. The `if (!exited)` guard prevented an actual signal from + * being sent, but the timer itself still held the Node event loop alive for + * up to 5 seconds past clean termination -- delaying terminateAll() and + * process shutdown. + * + * The fix extracted the exit/timer dance into `waitForProcessExit()` and + * clears the timer inside the exit listener. + */ + +import type { ChildProcess } from "node:child_process"; +import { EventEmitter } from "node:events"; + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { WorkerdSandboxRunner, waitForProcessExit } from "../src/sandbox/runner.js"; + +interface FakeProc extends EventEmitter { + exitCode: number | null; + kill: ReturnType; +} + +function makeFakeProc(): FakeProc { + const proc = new EventEmitter() as FakeProc; + proc.exitCode = null; + proc.kill = vi.fn(); + return proc; +} + +describe("waitForProcessExit", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("clears the SIGKILL timer when the process exits cleanly", async () => { + const fake = makeFakeProc(); + + const stopPromise = waitForProcessExit(fake as unknown as ChildProcess); + + // SIGTERM was queued synchronously. + expect(fake.kill).toHaveBeenCalledWith("SIGTERM"); + + // Process exits well before the 5s timeout. + await vi.advanceTimersByTimeAsync(100); + fake.emit("exit"); + await stopPromise; + + // The kill timer must have been cleared -- nothing pending in the + // fake timer queue. This is the load-bearing assertion: without + // clearTimeout(), getTimerCount() would be 1 here and the timer + // would keep the real event loop alive for ~5s past termination. + expect(vi.getTimerCount()).toBe(0); + + // Jump past the original 5s mark. SIGKILL must NOT be sent. + await vi.advanceTimersByTimeAsync(5_000); + expect(fake.kill).toHaveBeenCalledTimes(1); + expect(fake.kill).not.toHaveBeenCalledWith("SIGKILL"); + }); + + it("sends SIGKILL when the process does not exit within the timeout", async () => { + const fake = makeFakeProc(); + + const stopPromise = waitForProcessExit(fake as unknown as ChildProcess); + expect(fake.kill).toHaveBeenCalledWith("SIGTERM"); + + // 5s elapses with no exit -- timer fires SIGKILL. + await vi.advanceTimersByTimeAsync(5_000); + expect(fake.kill).toHaveBeenCalledWith("SIGKILL"); + + // Now exit; stopPromise resolves. + fake.emit("exit"); + await stopPromise; + }); + + it("respects a custom timeout", async () => { + const fake = makeFakeProc(); + const stopPromise = waitForProcessExit(fake as unknown as ChildProcess, 1_000); + + // At 999ms no SIGKILL. + await vi.advanceTimersByTimeAsync(999); + expect(fake.kill).not.toHaveBeenCalledWith("SIGKILL"); + + // At 1000ms SIGKILL fires. + await vi.advanceTimersByTimeAsync(1); + expect(fake.kill).toHaveBeenCalledWith("SIGKILL"); + + fake.emit("exit"); + await stopPromise; + }); +}); + +describe("stopWorkerd timer cleanup", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("clears the SIGKILL timer when the process exits cleanly", async () => { + const runner = new WorkerdSandboxRunner({ db: null as any }); + const fake = makeFakeProc(); + (runner as any).workerdProcess = fake; + + const stopPromise = (runner as any).stopWorkerd() as Promise; + expect(fake.kill).toHaveBeenCalledWith("SIGTERM"); + + await vi.advanceTimersByTimeAsync(100); + fake.emit("exit"); + await stopPromise; + + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(5_000); + expect(fake.kill).not.toHaveBeenCalledWith("SIGKILL"); + }); + + it("fast-paths when exitCode is already set", async () => { + const runner = new WorkerdSandboxRunner({ db: null as any }); + const fake = makeFakeProc(); + fake.exitCode = 0; + (runner as any).workerdProcess = fake; + + await (runner as any).stopWorkerd(); + + expect(fake.kill).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + }); +}); diff --git a/packages/workerd/test/workerd-integration.test.ts b/packages/workerd/test/workerd-integration.test.ts new file mode 100644 index 0000000000..ffa52e62a6 --- /dev/null +++ b/packages/workerd/test/workerd-integration.test.ts @@ -0,0 +1,346 @@ +/** + * Workerd Integration Tests + * + * These tests spawn a real workerd process and exercise the full plugin + * lifecycle: load, invoke hooks/routes, unload, and error handling. + * + * Skipped if the workerd binary is not available (e.g., in CI without + * the workerd package installed). + */ + +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; + +import { WorkerdSandboxRunner } from "../src/sandbox/runner.js"; + +// Check at module level so describe.skipIf works +let workerdAvailable = false; +try { + const testRunner = new WorkerdSandboxRunner({ db: null as any }); + workerdAvailable = testRunner.isAvailable(); +} catch { + // workerd not available +} + +function createTestDb() { + const sqlite = new Database(":memory:"); + const db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + }); + return { db, sqlite }; +} + +async function setupTables(db: Kysely) { + await db.schema + .createTable("_plugin_storage") + .addColumn("plugin_id", "text", (col) => col.notNull()) + .addColumn("collection", "text", (col) => col.notNull()) + .addColumn("id", "text", (col) => col.notNull()) + .addColumn("data", "text", (col) => col.notNull()) + .addColumn("created_at", "text", (col) => col.notNull()) + .addColumn("updated_at", "text", (col) => col.notNull()) + .addPrimaryKeyConstraint("pk_plugin_storage", ["plugin_id", "collection", "id"]) + .execute(); + + await db.schema + .createTable("ec_posts") + .addColumn("id", "text", (col) => col.primaryKey()) + .addColumn("slug", "text") + .addColumn("status", "text", (col) => col.defaultTo("draft")) + .addColumn("title", "text") + .addColumn("author_id", "text") + .addColumn("created_at", "text") + .addColumn("updated_at", "text") + .addColumn("published_at", "text") + .addColumn("scheduled_at", "text") + .addColumn("deleted_at", "text") + .addColumn("version", "integer", (col) => col.defaultTo(1)) + .addColumn("live_revision_id", "text") + .addColumn("draft_revision_id", "text") + .execute(); +} + +/** Minimal plugin code that echoes back hook/route calls. + * Route handlers receive { input, request, requestMeta } as first arg. */ +const ECHO_PLUGIN = ` +export default { + hooks: { + "content:beforeSave": { + handler: async (event, ctx) => { + await ctx.kv.set("last-hook", JSON.stringify({ hook: "content:beforeSave", event })); + return event; + } + } + }, + routes: { + "echo": { + handler: async (routeCtx, ctx) => { + const kvValue = await ctx.kv.get("last-hook"); + return { input: routeCtx.input, kvValue }; + } + }, + "kv-test": { + handler: async (routeCtx, ctx) => { + await ctx.kv.set("test-key", routeCtx.input.value); + const result = await ctx.kv.get("test-key"); + return { stored: result }; + } + } + } +}; +`; + +/** Plugin that sleeps longer than the wall-time limit */ +const SLOW_PLUGIN = ` +export default { + hooks: {}, + routes: { + "slow": { + handler: async () => { + await new Promise(r => setTimeout(r, 60000)); + return { done: true }; + } + } + } +}; +`; + +describe.skipIf(!workerdAvailable)("WorkerdSandboxRunner integration", () => { + let db: Kysely; + let sqlite: Database.Database; + let runner: WorkerdSandboxRunner; + + beforeEach(async () => { + const testDb = createTestDb(); + db = testDb.db; + sqlite = testDb.sqlite; + await setupTables(db); + + runner = new WorkerdSandboxRunner({ db }); + }); + + afterEach(async () => { + await runner.terminateAll(); + await db.destroy(); + sqlite.close(); + }); + + it("loads a plugin and invokes a route", async () => { + const plugin = await runner.load( + { + id: "test-echo", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const result = (await plugin.invokeRoute( + "echo", + { hello: "world" }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + )) as any; + + expect(result).toBeDefined(); + expect(result.input).toEqual({ hello: "world" }); + }, 30_000); + + it("loads a plugin and invokes a hook", async () => { + const plugin = await runner.load( + { + id: "test-echo", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const result = await plugin.invokeHook("content:beforeSave", { + content: { title: "Test" }, + }); + + expect(result).toBeDefined(); + + // Verify KV was written via the hook + const kvResult = (await plugin.invokeRoute( + "echo", + {}, + { + method: "GET", + url: "/api/test", + headers: {}, + }, + )) as any; + + expect(kvResult.kvValue).toBeTruthy(); + const parsed = JSON.parse(kvResult.kvValue); + expect(parsed.hook).toBe("content:beforeSave"); + }, 30_000); + + it("enforces KV isolation between plugins via routes", async () => { + const plugin = await runner.load( + { + id: "test-kv", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const result = (await plugin.invokeRoute( + "kv-test", + { value: "hello" }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + )) as any; + + expect(result.stored).toBe("hello"); + }, 30_000); + + it("handles plugin unload and reload", async () => { + const plugin1 = await runner.load( + { + id: "test-reload", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + // Invoke to verify it works + const result1 = (await plugin1.invokeRoute( + "echo", + { v: 1 }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + )) as any; + expect(result1.input.v).toBe(1); + + // Unload + await plugin1.terminate(); + + // Reload with new version + const plugin2 = await runner.load( + { + id: "test-reload", + version: "2.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const result2 = (await plugin2.invokeRoute( + "echo", + { v: 2 }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + )) as any; + expect(result2.input.v).toBe(2); + }, 60_000); + + it("enforces wall-time limit", async () => { + const slowRunner = new WorkerdSandboxRunner({ + db, + limits: { wallTimeMs: 2000 }, + }); + + try { + const plugin = await slowRunner.load( + { + id: "test-slow", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + SLOW_PLUGIN, + ); + + await expect( + plugin.invokeRoute( + "slow", + {}, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + ), + ).rejects.toThrow(/exceeded wall-time limit/); + } finally { + await slowRunner.terminateAll(); + } + }, 30_000); + + it("loads multiple plugins simultaneously", async () => { + const plugin1 = await runner.load( + { + id: "test-multi-a", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const plugin2 = await runner.load( + { + id: "test-multi-b", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + }, + ECHO_PLUGIN, + ); + + const [r1, r2] = (await Promise.all([ + plugin1.invokeRoute( + "echo", + { from: "a" }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + ), + plugin2.invokeRoute( + "echo", + { from: "b" }, + { + method: "POST", + url: "/api/test", + headers: {}, + }, + ), + ])) as any[]; + + expect(r1.input.from).toBe("a"); + expect(r2.input.from).toBe("b"); + }, 30_000); +}); diff --git a/packages/workerd/test/wrapper-marshal.test.ts b/packages/workerd/test/wrapper-marshal.test.ts new file mode 100644 index 0000000000..b6fe0f73a5 --- /dev/null +++ b/packages/workerd/test/wrapper-marshal.test.ts @@ -0,0 +1,106 @@ +/** + * Wrapper marshalRequestInit Tests + * + * Verifies that marshalRequestInit (generated inside the plugin wrapper + * template) correctly serializes RequestInit objects into a JSON-safe + * shape. Regression coverage for the URLSearchParams branch which + * previously assigned content-type as an object property on what is + * actually an array, silently dropping the header from POSTs. + */ + +import { describe, it, expect } from "vitest"; + +import { generatePluginWrapper } from "../src/sandbox/wrapper.js"; + +function extractMarshalRequestInit(): (init: unknown) => Promise { + const src = generatePluginWrapper( + { + id: "test-plugin", + name: "test", + version: "1.0.0", + capabilities: [], + storage: [], + } as any, + { + backingServiceUrl: "http://127.0.0.1:1", + authToken: "x", + invokeToken: "y", + }, + ); + // The wrapper renders marshalRequestInit (with its helpers nested + // inside) as a standalone async function literal. Slice it out by + // brace-counting from the function declaration so we can evaluate it. + const marker = "async function marshalRequestInit(init) {"; + const startIdx = src.indexOf(marker); + if (startIdx === -1) { + throw new Error("marshalRequestInit not found in wrapper output"); + } + const openBraceIdx = src.indexOf("{", startIdx); + let depth = 0; + let endIdx = -1; + for (let i = openBraceIdx; i < src.length; i++) { + const ch = src[i]; + if (ch === "{") depth++; + else if (ch === "}") { + depth--; + if (depth === 0) { + endIdx = i; + break; + } + } + } + if (endIdx === -1) { + throw new Error("Could not find matching closing brace for marshalRequestInit"); + } + const body = src.slice(startIdx, endIdx + 1); + // Intentional: marshalRequestInit lives inside a template literal that + // produces the wrapper module. To exercise it directly we evaluate the + // extracted function definition in an isolated scope. + // eslint-disable-next-line no-implied-eval + const factory = new Function(`${body}\nreturn marshalRequestInit;`); + return factory(); +} + +describe("marshalRequestInit: URLSearchParams body", () => { + it("sets content-type as an array pair (not an object property)", async () => { + const marshal = extractMarshalRequestInit(); + const result = await marshal({ + method: "POST", + body: new URLSearchParams({ a: "1", b: "2" }), + }); + expect(result.bodyType).toBe("string"); + expect(result.body).toBe("a=1&b=2"); + expect(Array.isArray(result.headers)).toBe(true); + expect(result.headers).toContainEqual(["content-type", "application/x-www-form-urlencoded"]); + // The previous bug set out.headers["content-type"] on an array; + // JSON.stringify of arrays drops non-index properties. Verify the + // header survives a JSON round-trip (which is how it's sent over + // the bridge to the Node backing service). + const roundtripped = JSON.parse(JSON.stringify(result)); + expect(roundtripped.headers).toContainEqual([ + "content-type", + "application/x-www-form-urlencoded", + ]); + }); + + it("preserves caller-provided content-type instead of overwriting", async () => { + const marshal = extractMarshalRequestInit(); + const result = await marshal({ + method: "POST", + headers: { "Content-Type": "text/plain" }, + body: new URLSearchParams({ x: "1" }), + }); + const ctEntries = result.headers.filter( + ([k]: [string, string]) => k.toLowerCase() === "content-type", + ); + expect(ctEntries).toHaveLength(1); + expect(ctEntries[0][1]).toBe("text/plain"); + }); + + it("works when no headers were provided by the caller", async () => { + const marshal = extractMarshalRequestInit(); + const result = await marshal({ body: new URLSearchParams({ x: "1" }) }); + expect(Array.isArray(result.headers)).toBe(true); + expect(result.headers).toContainEqual(["content-type", "application/x-www-form-urlencoded"]); + }); +}); diff --git a/packages/workerd/tsconfig.json b/packages/workerd/tsconfig.json new file mode 100644 index 0000000000..7d1576d394 --- /dev/null +++ b/packages/workerd/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "preserve", + "moduleResolution": "bundler", + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist" + }, + "include": ["src"] +} diff --git a/packages/workerd/tsdown.config.ts b/packages/workerd/tsdown.config.ts new file mode 100644 index 0000000000..600f9569eb --- /dev/null +++ b/packages/workerd/tsdown.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts", "src/sandbox/index.ts"], + format: ["esm"], + dts: true, + clean: true, + external: [ + // Native Node modules + "better-sqlite3", + // miniflare is a devDependency, dynamically imported at runtime + "miniflare", + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4ad1bc945..a530ce5cb6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -192,6 +192,9 @@ catalogs: '@tiptap/suggestion': specifier: ^3.20.0 version: 3.20.0 + '@types/better-sqlite3': + specifier: ^7.6.12 + version: 7.6.13 '@types/node': specifier: 24.10.13 version: 24.10.13 @@ -1623,7 +1626,7 @@ importers: version: 3.25.76 devDependencies: '@types/better-sqlite3': - specifier: ^7.6.13 + specifier: 'catalog:' version: 7.6.13 '@types/node': specifier: 'catalog:' @@ -2010,6 +2013,41 @@ importers: specifier: 'catalog:' version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) + packages/workerd: + dependencies: + emdash: + specifier: workspace:* + version: link:../core + ulidx: + specifier: ^2.4.1 + version: 2.4.1 + workerd: + specifier: '>=1.0.0' + version: 1.20260507.1 + devDependencies: + '@types/better-sqlite3': + specifier: 'catalog:' + version: 7.6.13 + better-sqlite3: + specifier: 'catalog:' + version: 12.8.0 + kysely: + specifier: ^0.27.0 + version: 0.27.6 + tsdown: + specifier: 'catalog:' + version: 0.20.3(@arethetypeswrong/core@0.18.2)(@typescript/native-preview@7.0.0-dev.20260421.2)(oxc-resolver@11.16.4)(publint@0.3.17)(typescript@6.0.3) + typescript: + specifier: 'catalog:' + version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.5(@opentelemetry/api@1.9.0)(@types/node@24.10.13)(@vitest/browser-playwright@4.1.5)(@vitest/ui@4.1.5)(jsdom@26.1.0)(vite@8.0.11(@types/node@24.10.13)(esbuild@0.27.3)(jiti@2.6.1)(yaml@2.8.2)) + optionalDependencies: + miniflare: + specifier: ^4.20250408.0 + version: 4.20260507.1 + packages/x402: dependencies: '@x402/core': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index e5c71d86c4..140bfd9568 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -122,6 +122,7 @@ catalog: "@tiptap/starter-kit": ^3.20.0 "@tiptap/suggestion": ^3.20.0 "@types/node": 24.10.13 + "@types/better-sqlite3": ^7.6.12 "@types/react": 19.2.14 "@types/react-dom": 19.2.3 astro: ^6.3.0