Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/admin-list-sandboxed-plugins.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 77 additions & 10 deletions packages/core/src/api/handlers/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -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<Database>,
configuredPlugins: ResolvedPlugin[],
sandboxedPluginEntries: SandboxedPluginEntry[],
marketplaceUrl?: string,
): Promise<ApiResult<PluginListResponse>> {
try {
Expand All @@ -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) {
Expand Down Expand Up @@ -156,27 +198,37 @@ export async function handlePluginList(
export async function handlePluginGet(
db: Kysely<Database>,
configuredPlugins: ResolvedPlugin[],
sandboxedPluginEntries: SandboxedPluginEntry[],
pluginId: string,
marketplaceUrl?: string,
): Promise<ApiResult<PluginResponse>> {
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) },
};
}
Comment on lines +217 to +224

return {
success: true,
data: { item: buildPluginInfo(plugin, state, marketplaceUrl) },
success: false,
error: {
code: "NOT_FOUND",
message: `Plugin not found: ${pluginId}`,
},
};
} catch {
return {
Expand Down Expand Up @@ -227,6 +279,7 @@ function buildStateOnlyPluginInfo(
export async function handlePluginEnable(
db: Kysely<Database>,
configuredPlugins: ResolvedPlugin[],
sandboxedPluginEntries: SandboxedPluginEntry[],
pluginId: string,
): Promise<ApiResult<PluginResponse>> {
try {
Expand All @@ -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.
Expand Down Expand Up @@ -268,6 +328,7 @@ export async function handlePluginEnable(
export async function handlePluginDisable(
db: Kysely<Database>,
configuredPlugins: ResolvedPlugin[],
sandboxedPluginEntries: SandboxedPluginEntry[],
pluginId: string,
): Promise<ApiResult<PluginResponse>> {
try {
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/astro/routes/api/admin/plugins/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const GET: APIRoute = async ({ locals }) => {
const result = await handlePluginList(
emdash.db,
emdash.configuredPlugins,
emdash.sandboxedPluginEntries,
emdash.config.marketplace,
);

Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/astro/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
77 changes: 77 additions & 0 deletions packages/core/tests/integration/api/plugins.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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> = {}): 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<Database>;

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();
});
});
Loading
Loading