diff --git a/.changeset/admin-list-sandboxed-plugins.md b/.changeset/admin-list-sandboxed-plugins.md new file mode 100644 index 000000000..feaca108e --- /dev/null +++ b/.changeset/admin-list-sandboxed-plugins.md @@ -0,0 +1,5 @@ +--- +"emdash": patch +--- + +Fixes statically-sandboxed plugins (registered via `sandboxed: []` in `astro.config.mjs`) being absent from the admin Plugins screen. They are now listed alongside trusted and marketplace plugins, and can be fetched, enabled, and disabled through the same plugin management API. diff --git a/packages/core/src/api/handlers/plugins.ts b/packages/core/src/api/handlers/plugins.ts index c029404df..24e1736b5 100644 --- a/packages/core/src/api/handlers/plugins.ts +++ b/packages/core/src/api/handlers/plugins.ts @@ -5,6 +5,7 @@ import type { Kysely } from "kysely"; import type { Database } from "../../database/types.js"; +import type { SandboxedPluginEntry } from "../../emdash-runtime.js"; import { PluginStateRepository, type PluginState, type PluginStatus } from "../../plugins/state.js"; import type { ResolvedPlugin } from "../../plugins/types.js"; import type { ApiResult } from "../types.js"; @@ -17,6 +18,8 @@ export interface PluginInfo { enabled: boolean; status: PluginStatus; source?: "config" | "marketplace" | "registry"; + /** True for statically-sandboxed plugins (registered via `sandboxed: []`) */ + sandboxed?: boolean; marketplaceVersion?: string; /** Publisher DID, for registry-source plugins */ registryPublisherDid?: string; @@ -84,12 +87,43 @@ function buildPluginInfo( }; } +/** + * Build plugin info for a statically-sandboxed plugin entry + */ +function buildSandboxedPluginInfo( + entry: SandboxedPluginEntry, + state: PluginState | null, +): PluginInfo { + const status = state?.status ?? "active"; + const enabled = status === "active"; + + return { + id: entry.id, + name: state?.displayName || entry.id, + version: entry.version, + package: undefined, // v2 doesn't have package field + enabled, + status, + source: "config", + sandboxed: true, + capabilities: entry.capabilities, + hasAdminPages: (entry.adminPages?.length ?? 0) > 0, + hasDashboardWidgets: (entry.adminWidgets?.length ?? 0) > 0, + hasHooks: false, + installedAt: state?.installedAt?.toISOString(), + activatedAt: state?.activatedAt?.toISOString() ?? undefined, + deactivatedAt: state?.deactivatedAt?.toISOString() ?? undefined, + description: state?.description ?? undefined, + }; +} + /** * List all configured plugins with their state */ export async function handlePluginList( db: Kysely, configuredPlugins: ResolvedPlugin[], + sandboxedPluginEntries: SandboxedPluginEntry[], marketplaceUrl?: string, ): Promise> { try { @@ -104,6 +138,14 @@ export async function handlePluginList( return buildPluginInfo(plugin, state, marketplaceUrl); }); + // Include statically-sandboxed plugins (registered via `sandboxed: []` + // in astro.config.mjs). + for (const entry of sandboxedPluginEntries) { + if (configuredIds.has(entry.id)) continue; + configuredIds.add(entry.id); + items.push(buildSandboxedPluginInfo(entry, stateMap.get(entry.id) ?? null)); + } + // Include runtime-installed plugins (marketplace or registry) that // aren't in the configured plugins list. for (const state of allStates) { @@ -156,27 +198,37 @@ export async function handlePluginList( export async function handlePluginGet( db: Kysely, configuredPlugins: ResolvedPlugin[], + sandboxedPluginEntries: SandboxedPluginEntry[], pluginId: string, marketplaceUrl?: string, ): Promise> { try { + const stateRepo = new PluginStateRepository(db); const plugin = configuredPlugins.find((p) => p.id === pluginId); - if (!plugin) { + + if (plugin) { + const state = await stateRepo.get(pluginId); return { - success: false, - error: { - code: "NOT_FOUND", - message: `Plugin not found: ${pluginId}`, - }, + success: true, + data: { item: buildPluginInfo(plugin, state, marketplaceUrl) }, }; } - const stateRepo = new PluginStateRepository(db); - const state = await stateRepo.get(pluginId); + const sandboxed = sandboxedPluginEntries.find((e) => e.id === pluginId); + if (sandboxed) { + const state = await stateRepo.get(pluginId); + return { + success: true, + data: { item: buildSandboxedPluginInfo(sandboxed, state) }, + }; + } return { - success: true, - data: { item: buildPluginInfo(plugin, state, marketplaceUrl) }, + success: false, + error: { + code: "NOT_FOUND", + message: `Plugin not found: ${pluginId}`, + }, }; } catch { return { @@ -227,6 +279,7 @@ function buildStateOnlyPluginInfo( export async function handlePluginEnable( db: Kysely, configuredPlugins: ResolvedPlugin[], + sandboxedPluginEntries: SandboxedPluginEntry[], pluginId: string, ): Promise> { try { @@ -239,6 +292,13 @@ export async function handlePluginEnable( return { success: true, data: { item: buildPluginInfo(plugin, state) } }; } + // Statically-sandboxed plugin: addressable via its build-time entry. + const sandboxed = sandboxedPluginEntries.find((e) => e.id === pluginId); + if (sandboxed) { + const state = await stateRepo.enable(pluginId, sandboxed.version); + return { success: true, data: { item: buildSandboxedPluginInfo(sandboxed, state) } }; + } + // Runtime-installed plugin (marketplace or registry): only // addressable through the state row. Fall back to the existing // version recorded there. @@ -268,6 +328,7 @@ export async function handlePluginEnable( export async function handlePluginDisable( db: Kysely, configuredPlugins: ResolvedPlugin[], + sandboxedPluginEntries: SandboxedPluginEntry[], pluginId: string, ): Promise> { try { @@ -279,6 +340,12 @@ export async function handlePluginDisable( return { success: true, data: { item: buildPluginInfo(plugin, state) } }; } + const sandboxed = sandboxedPluginEntries.find((e) => e.id === pluginId); + if (sandboxed) { + const state = await stateRepo.disable(pluginId, sandboxed.version); + return { success: true, data: { item: buildSandboxedPluginInfo(sandboxed, state) } }; + } + const existing = await stateRepo.get(pluginId); if (!existing || (existing.source !== "marketplace" && existing.source !== "registry")) { return { diff --git a/packages/core/src/astro/middleware.ts b/packages/core/src/astro/middleware.ts index 0ec49008d..684186436 100644 --- a/packages/core/src/astro/middleware.ts +++ b/packages/core/src/astro/middleware.ts @@ -522,6 +522,7 @@ export const onRequest = defineMiddleware(async (context, next) => { hooks: runtime.hooks, email: runtime.email, configuredPlugins: runtime.configuredPlugins, + sandboxedPluginEntries: runtime.sandboxedPluginEntries, // Configuration (for checking database type, auth mode, etc.) config, diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts index 06ede013d..9013c7f1a 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/disable.ts @@ -28,7 +28,12 @@ export const POST: APIRoute = async ({ params, locals }) => { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } - const result = await handlePluginDisable(emdash.db, emdash.configuredPlugins, id); + const result = await handlePluginDisable( + emdash.db, + emdash.configuredPlugins, + emdash.sandboxedPluginEntries, + id, + ); if (!result.success) return unwrapResult(result); diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts index 5b9b1994e..957b91911 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/enable.ts @@ -28,7 +28,12 @@ export const POST: APIRoute = async ({ params, locals }) => { return apiError("INVALID_REQUEST", "Plugin ID required", 400); } - const result = await handlePluginEnable(emdash.db, emdash.configuredPlugins, id); + const result = await handlePluginEnable( + emdash.db, + emdash.configuredPlugins, + emdash.sandboxedPluginEntries, + id, + ); if (!result.success) return unwrapResult(result); diff --git a/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts b/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts index 3a1b6fb56..06c6a7f96 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/[id]/index.ts @@ -30,6 +30,7 @@ export const GET: APIRoute = async ({ params, locals }) => { const result = await handlePluginGet( emdash.db, emdash.configuredPlugins, + emdash.sandboxedPluginEntries, id, emdash.config.marketplace, ); diff --git a/packages/core/src/astro/routes/api/admin/plugins/index.ts b/packages/core/src/astro/routes/api/admin/plugins/index.ts index 08b13f94f..a4a4d90ed 100644 --- a/packages/core/src/astro/routes/api/admin/plugins/index.ts +++ b/packages/core/src/astro/routes/api/admin/plugins/index.ts @@ -25,6 +25,7 @@ export const GET: APIRoute = async ({ locals }) => { const result = await handlePluginList( emdash.db, emdash.configuredPlugins, + emdash.sandboxedPluginEntries, emdash.config.marketplace, ); diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index 3a2f51a8d..e26dc8f87 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -425,6 +425,10 @@ export interface EmDashHandlers { // Configured plugins (for plugin management) configuredPlugins: import("../plugins/types.js").ResolvedPlugin[]; + // Statically-sandboxed plugin entries (registered via `sandboxed: []`), + // surfaced through the admin plugin management API alongside configured plugins. + sandboxedPluginEntries: import("../emdash-runtime.js").SandboxedPluginEntry[]; + // Configuration (for checking database type, auth mode, etc.) config: import("./integration/runtime.js").EmDashConfig; diff --git a/packages/core/tests/integration/api/plugins.test.ts b/packages/core/tests/integration/api/plugins.test.ts new file mode 100644 index 000000000..8cb8bfb97 --- /dev/null +++ b/packages/core/tests/integration/api/plugins.test.ts @@ -0,0 +1,77 @@ +/** + * Plugin admin list handler: a statically-sandboxed entry surfaces flagged + * sandboxed, and a configured plugin shadows a sandboxed entry with the same id. + */ + +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { handlePluginList } from "../../../src/api/handlers/plugins.js"; +import type { Database } from "../../../src/database/types.js"; +import type { SandboxedPluginEntry } from "../../../src/emdash-runtime.js"; +import type { ResolvedPlugin } from "../../../src/plugins/types.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function createTestPlugin(overrides: Partial = {}): ResolvedPlugin { + return { + id: "trusted-plugin", + version: "1.0.0", + capabilities: [], + allowedHosts: [], + storage: {}, + admin: { pages: [], widgets: [], fieldWidgets: {} }, + hooks: {}, + routes: {}, + settings: undefined, + ...overrides, + } as ResolvedPlugin; +} + +function createSandboxedEntry(overrides: Partial = {}): SandboxedPluginEntry { + return { + id: "sandboxed-plugin", + version: "2.1.0", + options: {}, + code: "", + capabilities: ["read:content"], + allowedHosts: [], + storage: {}, + adminPages: [{ path: "settings" }], + adminWidgets: [{ id: "status" }], + ...overrides, + }; +} + +describe("plugin admin handlers: sandboxed plugins", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("surfaces a sandboxed entry, and a configured plugin shadows one with the same id", async () => { + const result = await handlePluginList( + db, + [createTestPlugin({ id: "shared-id", version: "1.0.0" })], + [createSandboxedEntry({ id: "sandboxed-only" }), createSandboxedEntry({ id: "shared-id" })], + ); + + expect(result.success).toBe(true); + if (!result.success) return; + + // A sandboxed-only entry surfaces, flagged. + const surfaced = result.data.items.filter((p) => p.id === "sandboxed-only"); + expect(surfaced).toHaveLength(1); + expect(surfaced[0]).toMatchObject({ source: "config", sandboxed: true }); + + // A configured plugin with the same id wins; the sandboxed entry is not listed twice. + const shared = result.data.items.filter((p) => p.id === "shared-id"); + expect(shared).toHaveLength(1); + expect(shared[0]?.version).toBe("1.0.0"); + expect(shared[0]?.sandboxed).toBeUndefined(); + }); +}); diff --git a/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts new file mode 100644 index 000000000..d2c698f64 --- /dev/null +++ b/packages/core/tests/integration/astro/admin-plugins-sandboxed.test.ts @@ -0,0 +1,138 @@ +/** + * Statically-sandboxed plugins through the real admin plugin routes and a real + * EmDashRuntime: the list route surfaces them, and the enable/disable routes + * toggle them without error. + */ + +import type { APIContext } from "astro"; +import type { Kysely } from "kysely"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import type { EmDashConfig } from "../../../src/astro/integration/runtime.js"; +import { POST as disablePlugin } from "../../../src/astro/routes/api/admin/plugins/[id]/disable.js"; +import { POST as enablePlugin } from "../../../src/astro/routes/api/admin/plugins/[id]/enable.js"; +import { GET as listPlugins } from "../../../src/astro/routes/api/admin/plugins/index.js"; +import type { Database } from "../../../src/database/types.js"; +import { EmDashRuntime, type SandboxedPluginEntry } from "../../../src/emdash-runtime.js"; +import { createHookPipeline } from "../../../src/plugins/hooks.js"; +import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js"; + +function buildRuntime(db: Kysely, entries: SandboxedPluginEntry[]): EmDashRuntime { + const config: EmDashConfig = {}; + const pipelineFactoryOptions = { db } as const; + const hooks = createHookPipeline([], pipelineFactoryOptions); + const pipelineRef = { current: hooks }; + const runtimeDeps = { + config, + plugins: [], + // eslint-disable-next-line typescript/no-explicit-any -- match RuntimeDependencies signature + createDialect: (() => { + throw new Error("createDialect not used in this test"); + }) as any, + createStorage: null, + sandboxEnabled: false, + sandboxedPluginEntries: entries, + createSandboxRunner: null, + }; + + return new EmDashRuntime({ + db, + storage: null, + configuredPlugins: [], + sandboxedPlugins: new Map(), + sandboxedPluginEntries: entries, + hooks, + enabledPlugins: new Set(), + pluginStates: new Map(), + config, + mediaProviders: new Map(), + mediaProviderEntries: [], + cronExecutor: null, + cronScheduler: null, + emailPipeline: null, + allPipelinePlugins: [], + pipelineFactoryOptions, + runtimeDeps, + pipelineRef, + }); +} + +// Role.ADMIN is 50 in @emdash-cms/auth; plugins:read / plugins:manage require it. +const admin = { id: "admin-1", role: 50 }; + +// Mirror the subset of the middleware's `locals.emdash` facade that the plugin +// routes read. The facade once dropped `sandboxedPluginEntries` (not compile-checked +// here), which a raw-runtime fixture would hide, so this drives the real field through. +function facade(runtime: EmDashRuntime) { + return { + db: runtime.db, + configuredPlugins: runtime.configuredPlugins, + sandboxedPluginEntries: runtime.sandboxedPluginEntries, + config: runtime.config, + setPluginStatus: runtime.setPluginStatus.bind(runtime), + }; +} + +function ctx(runtime: EmDashRuntime, params: Record = {}): APIContext { + return { + locals: { emdash: facade(runtime), user: admin }, + params, + request: new Request("http://test.local/_emdash/api/admin/plugins"), + } as unknown as APIContext; +} + +function sandboxedEntry(overrides: Partial = {}): SandboxedPluginEntry { + return { + id: "webhook-notifier", + version: "0.1.0", + options: {}, + code: "", + capabilities: ["network:fetch"], + allowedHosts: ["*"], + storage: {}, + adminPages: [], + adminWidgets: [], + ...overrides, + }; +} + +async function listIds(runtime: EmDashRuntime) { + const res = await listPlugins(ctx(runtime)); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { items: Array<{ id: string; source?: string; sandboxed?: boolean; enabled: boolean }> }; + }; + return body.data; +} + +describe("admin plugin routes: statically-sandboxed plugins (real runtime)", () => { + let db: Kysely; + let runtime: EmDashRuntime; + + beforeEach(async () => { + db = await setupTestDatabase(); + runtime = buildRuntime(db, [sandboxedEntry()]); + }); + + afterEach(async () => { + await teardownTestDatabase(db); + }); + + it("the list route surfaces a sandboxed plugin from the runtime", async () => { + const body = await listIds(runtime); + const plugin = body.items.find((p) => p.id === "webhook-notifier"); + expect(plugin).toMatchObject({ source: "config", sandboxed: true, enabled: true }); + }); + + it("the enable and disable routes toggle a sandboxed plugin end to end", async () => { + const off = await disablePlugin(ctx(runtime, { id: "webhook-notifier" })); + expect(off.status).toBe(200); + let body = await listIds(runtime); + expect(body.items.find((p) => p.id === "webhook-notifier")?.enabled).toBe(false); + + const on = await enablePlugin(ctx(runtime, { id: "webhook-notifier" })); + expect(on.status).toBe(200); + body = await listIds(runtime); + expect(body.items.find((p) => p.id === "webhook-notifier")?.enabled).toBe(true); + }); +}); diff --git a/packages/core/tests/utils/mcp-runtime.ts b/packages/core/tests/utils/mcp-runtime.ts index eedacf3b8..fe0169c3c 100644 --- a/packages/core/tests/utils/mcp-runtime.ts +++ b/packages/core/tests/utils/mcp-runtime.ts @@ -202,6 +202,7 @@ export function handlersFromRuntime(runtime: EmDashRuntime): EmDashHandlers { hooks: runtime.hooks, email: runtime.email, configuredPlugins: runtime.configuredPlugins, + sandboxedPluginEntries: runtime.sandboxedPluginEntries, config: runtime.config, getManifest: runtime.getManifest.bind(runtime), invalidateUrlPatternCache,