From d7cfb1d1d8d1b34187c2ae9305445702ed54bc47 Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Tue, 7 Apr 2026 10:46:09 -0600
Subject: [PATCH 1/8] Add admin-configurable marketplace settings
---
.../admin/src/components/PluginManager.tsx | 2 +-
packages/admin/src/components/Settings.tsx | 7 +
packages/admin/src/components/Sidebar.tsx | 2 +-
.../settings/MarketplaceSettings.tsx | 238 ++++++++++++++++++
packages/admin/src/lib/api/client.ts | 5 +-
packages/admin/src/lib/api/settings.ts | 10 +
packages/admin/src/router.tsx | 9 +
.../tests/components/PluginManager.test.tsx | 8 +-
.../admin/tests/components/Settings.test.tsx | 1 +
.../settings/MarketplaceSettings.test.tsx | 118 +++++++++
packages/core/src/api/schemas/settings.ts | 29 +++
.../routes/api/admin/plugins/[id]/index.ts | 9 +-
.../routes/api/admin/plugins/[id]/update.ts | 4 +-
.../astro/routes/api/admin/plugins/index.ts | 8 +-
.../admin/plugins/marketplace/[id]/icon.ts | 3 +-
.../admin/plugins/marketplace/[id]/index.ts | 4 +-
.../admin/plugins/marketplace/[id]/install.ts | 4 +-
.../api/admin/plugins/marketplace/index.ts | 4 +-
.../astro/routes/api/admin/plugins/updates.ts | 4 +-
.../admin/themes/marketplace/[id]/index.ts | 4 +-
.../themes/marketplace/[id]/thumbnail.ts | 3 +-
.../api/admin/themes/marketplace/index.ts | 4 +-
packages/core/src/emdash-runtime.ts | 8 +-
packages/core/src/settings/marketplace.ts | 81 ++++++
packages/core/src/settings/types.ts | 21 ++
packages/core/tests/unit/api/schemas.test.ts | 50 +++-
.../tests/unit/settings/marketplace.test.ts | 84 +++++++
27 files changed, 689 insertions(+), 35 deletions(-)
create mode 100644 packages/admin/src/components/settings/MarketplaceSettings.tsx
create mode 100644 packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
create mode 100644 packages/core/src/settings/marketplace.ts
create mode 100644 packages/core/tests/unit/settings/marketplace.test.ts
diff --git a/packages/admin/src/components/PluginManager.tsx b/packages/admin/src/components/PluginManager.tsx
index 937dfb9d6..13850e2ac 100644
--- a/packages/admin/src/components/PluginManager.tsx
+++ b/packages/admin/src/components/PluginManager.tsx
@@ -44,7 +44,7 @@ import { CapabilityConsentDialog } from "./CapabilityConsentDialog.js";
import { DialogError, getMutationError } from "./DialogError.js";
export interface PluginManagerProps {
- /** Admin manifest — used to check if marketplace is configured */
+ /** Admin manifest — used to check if marketplace features are enabled */
manifest?: AdminManifest;
}
diff --git a/packages/admin/src/components/Settings.tsx b/packages/admin/src/components/Settings.tsx
index 165ec83ab..8c88c4ae8 100644
--- a/packages/admin/src/components/Settings.tsx
+++ b/packages/admin/src/components/Settings.tsx
@@ -6,6 +6,7 @@ import {
Globe,
Key,
Envelope,
+ Storefront,
CaretRight,
} from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
@@ -107,6 +108,12 @@ export function Settings() {
title="Email"
description="View email provider status and send test emails"
/>
+ }
+ title="Marketplace"
+ description="Manage plugin/theme marketplace registries and active source"
+ />
);
diff --git a/packages/admin/src/components/Sidebar.tsx b/packages/admin/src/components/Sidebar.tsx
index 4779b6ea8..cbb221881 100644
--- a/packages/admin/src/components/Sidebar.tsx
+++ b/packages/admin/src/components/Sidebar.tsx
@@ -51,7 +51,7 @@ export interface SidebarNavProps {
}
>;
version?: string;
- marketplace?: string;
+ marketplace?: boolean;
};
}
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
new file mode 100644
index 000000000..5d00f4b4b
--- /dev/null
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -0,0 +1,238 @@
+import { Button, Input } from "@cloudflare/kumo";
+import { ArrowLeft, CheckCircle, Plus, Storefront, Trash } from "@phosphor-icons/react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { Link } from "@tanstack/react-router";
+import * as React from "react";
+
+import { fetchSettings, updateSettings, type SiteSettings } from "../../lib/api";
+import { DialogError, getMutationError } from "../DialogError.js";
+
+function createRegistryId(): string {
+ return `registry_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
+}
+
+interface MarketplaceRegistryForm {
+ id: string;
+ label: string;
+ url: string;
+}
+
+function isValidMarketplaceUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "https:") return true;
+ const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
+ return parsed.protocol === "http:" && isLocalhost;
+ } catch {
+ return false;
+ }
+}
+
+export function MarketplaceSettings() {
+ const queryClient = useQueryClient();
+ const [saveStatus, setSaveStatus] = React.useState(null);
+ const [localError, setLocalError] = React.useState(null);
+ const [newLabel, setNewLabel] = React.useState("");
+ const [newUrl, setNewUrl] = React.useState("");
+ const [registries, setRegistries] = React.useState([]);
+ const [activeRegistryId, setActiveRegistryId] = React.useState(undefined);
+
+ const { data: settings, isLoading } = useQuery({
+ queryKey: ["settings"],
+ queryFn: fetchSettings,
+ staleTime: Infinity,
+ });
+
+ React.useEffect(() => {
+ if (!settings?.marketplace) return;
+ setRegistries(settings.marketplace.registries ?? []);
+ setActiveRegistryId(settings.marketplace.activeRegistryId);
+ }, [settings]);
+
+ const saveMutation = useMutation({
+ mutationFn: (data: Partial) => updateSettings(data),
+ onSuccess: () => {
+ setSaveStatus("Marketplace settings saved");
+ setLocalError(null);
+ void queryClient.invalidateQueries({ queryKey: ["settings"] });
+ void queryClient.invalidateQueries({ queryKey: ["manifest"] });
+ },
+ });
+
+ React.useEffect(() => {
+ if (!saveStatus) return;
+ const timer = setTimeout(setSaveStatus, 3000, null);
+ return () => clearTimeout(timer);
+ }, [saveStatus]);
+
+ const addRegistry = () => {
+ setLocalError(null);
+ const label = newLabel.trim();
+ const url = newUrl.trim();
+ if (!label) {
+ setLocalError("Registry label is required");
+ return;
+ }
+ if (!url) {
+ setLocalError("Registry URL is required");
+ return;
+ }
+ if (!isValidMarketplaceUrl(url)) {
+ setLocalError("Marketplace URL must use HTTPS or localhost HTTP");
+ return;
+ }
+ const id = createRegistryId();
+ setRegistries((prev) => [...prev, { id, label, url }]);
+ if (!activeRegistryId) setActiveRegistryId(id);
+ setNewLabel("");
+ setNewUrl("");
+ };
+
+ const removeRegistry = (id: string) => {
+ setRegistries((prev) => {
+ const nextRegistries = prev.filter((registry) => registry.id !== id);
+ if (activeRegistryId === id) {
+ setActiveRegistryId(nextRegistries[0]?.id);
+ }
+ return nextRegistries;
+ });
+ };
+
+ const submit = (event: React.FormEvent) => {
+ event.preventDefault();
+ setLocalError(null);
+ for (const registry of registries) {
+ if (!registry.label.trim()) {
+ setLocalError("Registry label cannot be empty");
+ return;
+ }
+ if (!isValidMarketplaceUrl(registry.url)) {
+ setLocalError(`Invalid registry URL: ${registry.url}`);
+ return;
+ }
+ }
+ if (registries.length > 0 && !activeRegistryId) {
+ setLocalError("Select an active registry");
+ return;
+ }
+ if (activeRegistryId && !registries.some((registry) => registry.id === activeRegistryId)) {
+ setLocalError("Active registry must match an existing entry");
+ return;
+ }
+
+ saveMutation.mutate({
+ marketplace: {
+ registries,
+ activeRegistryId,
+ },
+ });
+ };
+
+ return (
+
+
+
+
+
+
+
+
Marketplace Settings
+
+
+
+ Configure one or more marketplace registries and choose which one is active for plugin and
+ theme browsing.
+
+
+ {saveStatus && (
+
+
+ {saveStatus}
+
+ )}
+
+
+
+ {isLoading ? (
+
+ Loading settings...
+
+ ) : (
+
+ )}
+
+ );
+}
+
+export default MarketplaceSettings;
diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts
index 721b113c0..40a4ec985 100644
--- a/packages/admin/src/lib/api/client.ts
+++ b/packages/admin/src/lib/api/client.ts
@@ -130,10 +130,9 @@ export interface AdminManifest {
locales: string[];
};
/**
- * Marketplace registry URL. Present when `marketplace` is configured
- * in the EmDash integration. Enables marketplace features in the UI.
+ * Whether marketplace browsing/install features are enabled.
*/
- marketplace?: string;
+ marketplace?: boolean;
}
/**
diff --git a/packages/admin/src/lib/api/settings.ts b/packages/admin/src/lib/api/settings.ts
index 67822ee4c..dbdf2c66d 100644
--- a/packages/admin/src/lib/api/settings.ts
+++ b/packages/admin/src/lib/api/settings.ts
@@ -5,6 +5,16 @@
import { API_BASE, apiFetch, parseApiResponse } from "./client.js";
export interface SiteSettings {
+ // Marketplace
+ marketplace?: {
+ registries: Array<{
+ id: string;
+ label: string;
+ url: string;
+ }>;
+ activeRegistryId?: string;
+ };
+
// Identity
title: string;
tagline?: string;
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index bf8258425..f4b5b6e96 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -43,6 +43,7 @@ import { AllowedDomainsSettings } from "./components/settings/AllowedDomainsSett
import { ApiTokenSettings } from "./components/settings/ApiTokenSettings";
import { EmailSettings } from "./components/settings/EmailSettings";
import { GeneralSettings } from "./components/settings/GeneralSettings";
+import { MarketplaceSettings } from "./components/settings/MarketplaceSettings";
import { SecuritySettings } from "./components/settings/SecuritySettings";
import { SeoSettings } from "./components/settings/SeoSettings";
import { SocialSettings } from "./components/settings/SocialSettings";
@@ -1088,6 +1089,13 @@ const emailSettingsRoute = createRoute({
component: EmailSettings,
});
+// Marketplace settings route
+const marketplaceSettingsRoute = createRoute({
+ getParentRoute: () => adminLayoutRoute,
+ path: "/settings/marketplace",
+ component: MarketplaceSettings,
+});
+
// General settings route
const generalSettingsRoute = createRoute({
getParentRoute: () => adminLayoutRoute,
@@ -1525,6 +1533,7 @@ const adminRoutes = adminLayoutRoute.addChildren([
allowedDomainsSettingsRoute,
apiTokenSettingsRoute,
emailSettingsRoute,
+ marketplaceSettingsRoute,
wordpressImportRoute,
notFoundRoute,
]);
diff --git a/packages/admin/tests/components/PluginManager.test.tsx b/packages/admin/tests/components/PluginManager.test.tsx
index 7781637b7..30b4edd80 100644
--- a/packages/admin/tests/components/PluginManager.test.tsx
+++ b/packages/admin/tests/components/PluginManager.test.tsx
@@ -203,9 +203,7 @@ describe("PluginManager", () => {
it("shows Marketplace link when manifest has marketplace URL", async () => {
const screen = await render(
-
+
,
);
await expect.element(screen.getByText("Audit Log")).toBeInTheDocument();
@@ -346,9 +344,7 @@ describe("PluginManager", () => {
mockFetchPlugins.mockResolvedValue([]);
const screen = await render(
-
+
,
);
await expect.element(screen.getByText("No plugins configured")).toBeInTheDocument();
diff --git a/packages/admin/tests/components/Settings.test.tsx b/packages/admin/tests/components/Settings.test.tsx
index 40c0b73f9..6173fd57f 100644
--- a/packages/admin/tests/components/Settings.test.tsx
+++ b/packages/admin/tests/components/Settings.test.tsx
@@ -81,6 +81,7 @@ describe("Settings", () => {
);
await expect.element(screen.getByText("API Tokens")).toBeInTheDocument();
await expect.element(screen.getByText("Email", { exact: true })).toBeInTheDocument();
+ await expect.element(screen.getByText("Marketplace", { exact: true })).toBeInTheDocument();
});
it("security link shown when authMode is passkey", async () => {
diff --git a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
new file mode 100644
index 000000000..55a4ca28f
--- /dev/null
+++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
@@ -0,0 +1,118 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import * as React from "react";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { render } from "vitest-browser-react";
+
+import type { SiteSettings } from "../../../src/lib/api/settings";
+
+vi.mock("@tanstack/react-router", async () => {
+ const actual = await vi.importActual("@tanstack/react-router");
+ return {
+ ...actual,
+ Link: ({ children, to, ...props }: any) => (
+
+ {children}
+
+ ),
+ useNavigate: () => vi.fn(),
+ };
+});
+
+const mockFetchSettings = vi.fn<() => Promise>>();
+const mockUpdateSettings = vi.fn<() => Promise>>();
+
+vi.mock("../../../src/lib/api", async () => {
+ const actual = await vi.importActual("../../../src/lib/api");
+ return {
+ ...actual,
+ fetchSettings: (...args: unknown[]) => mockFetchSettings(...(args as [])),
+ updateSettings: (...args: unknown[]) => mockUpdateSettings(...(args as [])),
+ };
+});
+
+const { MarketplaceSettings } =
+ await import("../../../src/components/settings/MarketplaceSettings");
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ });
+ return {children} ;
+}
+
+describe("MarketplaceSettings", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetchSettings.mockResolvedValue({
+ marketplace: {
+ registries: [
+ {
+ id: "official",
+ label: "Official",
+ url: "https://marketplace.emdashcms.com",
+ },
+ ],
+ activeRegistryId: "official",
+ },
+ });
+ mockUpdateSettings.mockResolvedValue({});
+ });
+
+ it("renders saved registries", async () => {
+ const screen = await render(
+
+
+ ,
+ );
+ await expect.element(screen.getByText("Marketplace Settings")).toBeInTheDocument();
+ await expect.element(screen.getByText("Official")).toBeInTheDocument();
+ await expect.element(screen.getByText("https://marketplace.emdashcms.com")).toBeInTheDocument();
+ });
+
+ it("shows validation error for invalid registry URL", async () => {
+ mockFetchSettings.mockResolvedValue({
+ marketplace: { registries: [], activeRegistryId: undefined },
+ });
+ const screen = await render(
+
+
+ ,
+ );
+
+ await expect.element(screen.getByText("Marketplace Settings")).toBeInTheDocument();
+ const label = screen.getByLabelText("Label");
+ const url = screen.getByLabelText("URL");
+ await label.fill("My Registry");
+ await url.fill("http://example.com");
+ await screen.getByRole("button", { name: "Add" }).click();
+
+ await expect
+ .element(screen.getByText("Marketplace URL must use HTTPS or localhost HTTP"))
+ .toBeInTheDocument();
+ });
+
+ it("saves registries and active selection", async () => {
+ const screen = await render(
+
+
+ ,
+ );
+ await expect.element(screen.getByText("Marketplace Settings")).toBeInTheDocument();
+ await screen.getByRole("button", { name: "Save Marketplace Settings" }).click();
+
+ await vi.waitFor(() => {
+ expect(mockUpdateSettings).toHaveBeenCalledWith({
+ marketplace: {
+ registries: [
+ {
+ id: "official",
+ label: "Official",
+ url: "https://marketplace.emdashcms.com",
+ },
+ ],
+ activeRegistryId: "official",
+ },
+ });
+ });
+ });
+});
diff --git a/packages/core/src/api/schemas/settings.ts b/packages/core/src/api/schemas/settings.ts
index f469ca445..0848cf5af 100644
--- a/packages/core/src/api/schemas/settings.ts
+++ b/packages/core/src/api/schemas/settings.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { isValidMarketplaceUrl } from "../../settings/marketplace.js";
import { httpUrl } from "./common.js";
// ---------------------------------------------------------------------------
@@ -28,6 +29,32 @@ const seoSettings = z.object({
bingVerification: z.string().max(100).optional(),
});
+const marketplaceRegistry = z.object({
+ id: z.string().min(1),
+ label: z.string().min(1),
+ url: z
+ .string()
+ .url()
+ .refine((url) => isValidMarketplaceUrl(url), {
+ message: "Marketplace URL must use HTTPS or localhost HTTP",
+ }),
+});
+
+const marketplaceSettings = z
+ .object({
+ registries: z.array(marketplaceRegistry).max(20),
+ activeRegistryId: z.string().optional(),
+ })
+ .refine(
+ (data) =>
+ !data.activeRegistryId ||
+ data.registries.some((registry) => registry.id === data.activeRegistryId),
+ {
+ message: "activeRegistryId must match a registry id",
+ path: ["activeRegistryId"],
+ },
+ );
+
export const settingsUpdateBody = z
.object({
title: z.string().optional(),
@@ -40,6 +67,7 @@ export const settingsUpdateBody = z
timezone: z.string().optional(),
social: socialSettings.optional(),
seo: seoSettings.optional(),
+ marketplace: marketplaceSettings.optional(),
})
.meta({ id: "SettingsUpdateBody" });
@@ -59,5 +87,6 @@ export const siteSettingsSchema = z
timezone: z.string().optional(),
social: socialSettings.optional(),
seo: seoSettings.optional(),
+ marketplace: marketplaceSettings.optional(),
})
.meta({ id: "SiteSettings" });
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..b3b06ceda 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
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handlePluginGet } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -26,13 +27,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
if (!id) {
return apiError("INVALID_REQUEST", "Plugin ID required", 400);
}
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handlePluginGet(
- emdash.db,
- emdash.configuredPlugins,
- id,
- emdash.config.marketplace,
- );
+ const result = await handlePluginGet(emdash.db, emdash.configuredPlugins, id, marketplaceUrl);
return unwrapResult(result);
};
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 9e2747399..4fe218d8b 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
@@ -11,6 +11,7 @@ import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleMarketplaceUpdate } from "#api/index.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -37,12 +38,13 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
const body = await parseOptionalBody(request, updateBodySchema, {});
if (isParseError(body)) return body;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
const result = await handleMarketplaceUpdate(
emdash.db,
emdash.storage,
emdash.getSandboxRunner(),
- emdash.config.marketplace,
+ marketplaceUrl,
id,
{
version: body.version,
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..6b53a4935 100644
--- a/packages/core/src/astro/routes/api/admin/plugins/index.ts
+++ b/packages/core/src/astro/routes/api/admin/plugins/index.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handlePluginList } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -21,12 +22,9 @@ export const GET: APIRoute = async ({ locals }) => {
const denied = requirePerm(user, "plugins:read");
if (denied) return denied;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handlePluginList(
- emdash.db,
- emdash.configuredPlugins,
- emdash.config.marketplace,
- );
+ const result = await handlePluginList(emdash.db, emdash.configuredPlugins, marketplaceUrl);
return unwrapResult(result);
};
diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts
index 23888b8a2..37c8f3148 100644
--- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts
+++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/icon.ts
@@ -11,6 +11,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError } from "#api/error.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
const denied = requirePerm(user, "plugins:read");
if (denied) return denied;
- const marketplaceUrl = emdash.config.marketplace;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
if (!marketplaceUrl || !id) {
return apiError("NOT_CONFIGURED", "Marketplace not configured", 400);
}
diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts
index 693f5f807..6abd57a76 100644
--- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts
+++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/[id]/index.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleMarketplaceGetPlugin } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -26,8 +27,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
if (!id) {
return apiError("INVALID_REQUEST", "Plugin ID required", 400);
}
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handleMarketplaceGetPlugin(emdash.config.marketplace, id);
+ const result = await handleMarketplaceGetPlugin(marketplaceUrl, id);
return unwrapResult(result);
};
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 48a0db5e6..93f7e2651 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
@@ -11,6 +11,7 @@ import { requirePerm } from "#api/authorize.js";
import { apiError, handleError, unwrapResult } from "#api/error.js";
import { handleMarketplaceInstall } from "#api/index.js";
import { isParseError, parseOptionalBody } from "#api/parse.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -40,12 +41,13 @@ export const POST: APIRoute = async ({ params, request, locals }) => {
const configuredPluginIds = new Set(
emdash.configuredPlugins.map((p: { id: string }) => p.id),
);
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
const result = await handleMarketplaceInstall(
emdash.db,
emdash.storage,
emdash.getSandboxRunner(),
- emdash.config.marketplace,
+ marketplaceUrl,
id,
{ version: body.version, configuredPluginIds },
);
diff --git a/packages/core/src/astro/routes/api/admin/plugins/marketplace/index.ts b/packages/core/src/astro/routes/api/admin/plugins/marketplace/index.ts
index 78fa37d9c..ad60a3ce6 100644
--- a/packages/core/src/astro/routes/api/admin/plugins/marketplace/index.ts
+++ b/packages/core/src/astro/routes/api/admin/plugins/marketplace/index.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleMarketplaceSearch } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -27,8 +28,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
const cursor = url.searchParams.get("cursor") ?? undefined;
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(1, parseInt(limitParam, 10) || 50), 100) : undefined;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handleMarketplaceSearch(emdash.config.marketplace, query, {
+ const result = await handleMarketplaceSearch(marketplaceUrl, query, {
category,
cursor,
limit,
diff --git a/packages/core/src/astro/routes/api/admin/plugins/updates.ts b/packages/core/src/astro/routes/api/admin/plugins/updates.ts
index 8f70e76b2..af6f90cb1 100644
--- a/packages/core/src/astro/routes/api/admin/plugins/updates.ts
+++ b/packages/core/src/astro/routes/api/admin/plugins/updates.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleMarketplaceUpdateCheck } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -21,8 +22,9 @@ export const GET: APIRoute = async ({ locals }) => {
const denied = requirePerm(user, "plugins:read");
if (denied) return denied;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handleMarketplaceUpdateCheck(emdash.db, emdash.config.marketplace);
+ const result = await handleMarketplaceUpdateCheck(emdash.db, marketplaceUrl);
return unwrapResult(result);
};
diff --git a/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts b/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts
index 87936d70b..0c534850a 100644
--- a/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts
+++ b/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/index.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleThemeGetDetail } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -26,8 +27,9 @@ export const GET: APIRoute = async ({ params, locals }) => {
if (!id) {
return apiError("INVALID_REQUEST", "Theme ID required", 400);
}
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handleThemeGetDetail(emdash.config.marketplace, id);
+ const result = await handleThemeGetDetail(marketplaceUrl, id);
return unwrapResult(result);
};
diff --git a/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts b/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts
index aa9b34859..7d8536a8d 100644
--- a/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts
+++ b/packages/core/src/astro/routes/api/admin/themes/marketplace/[id]/thumbnail.ts
@@ -11,6 +11,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError } from "#api/error.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -25,7 +26,7 @@ export const GET: APIRoute = async ({ params, url, locals }) => {
const denied = requirePerm(user, "plugins:read");
if (denied) return denied;
- const marketplaceUrl = emdash.config.marketplace;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
if (!marketplaceUrl || !id) {
return apiError("NOT_CONFIGURED", "Marketplace not configured", 400);
}
diff --git a/packages/core/src/astro/routes/api/admin/themes/marketplace/index.ts b/packages/core/src/astro/routes/api/admin/themes/marketplace/index.ts
index 9262acaf3..7bdecc8df 100644
--- a/packages/core/src/astro/routes/api/admin/themes/marketplace/index.ts
+++ b/packages/core/src/astro/routes/api/admin/themes/marketplace/index.ts
@@ -9,6 +9,7 @@ import type { APIRoute } from "astro";
import { requirePerm } from "#api/authorize.js";
import { apiError, unwrapResult } from "#api/error.js";
import { handleThemeSearch } from "#api/index.js";
+import { resolveMarketplaceUrl } from "#settings/marketplace.js";
export const prerender = false;
@@ -33,8 +34,9 @@ export const GET: APIRoute = async ({ url, locals }) => {
const cursor = url.searchParams.get("cursor") ?? undefined;
const limitParam = url.searchParams.get("limit");
const limit = limitParam ? Math.min(Math.max(1, parseInt(limitParam, 10) || 50), 100) : undefined;
+ const marketplaceUrl = await resolveMarketplaceUrl(emdash.db, emdash.config.marketplace);
- const result = await handleThemeSearch(emdash.config.marketplace, query, {
+ const result = await handleThemeSearch(marketplaceUrl, query, {
keyword,
sort,
cursor,
diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts
index e5a74116c..9abd45061 100644
--- a/packages/core/src/emdash-runtime.ts
+++ b/packages/core/src/emdash-runtime.ts
@@ -141,6 +141,7 @@ import type { CronScheduler } from "./plugins/scheduler/types.js";
import { PluginStateRepository } from "./plugins/state.js";
import { getRequestContext } from "./request-context.js";
import { FTSManager } from "./search/fts-manager.js";
+import { resolveMarketplaceUrl } from "./settings/marketplace.js";
/**
* Map schema field types to editor field kinds
@@ -454,7 +455,7 @@ export class EmDashRuntime {
* current worker: loads newly active plugins and removes uninstalled ones.
*/
async syncMarketplacePlugins(): Promise {
- if (!this.config.marketplace || !this.storage) return;
+ if (!this.storage) return;
if (!sandboxRunner || !sandboxRunner.isAvailable()) return;
try {
@@ -679,7 +680,7 @@ export class EmDashRuntime {
const sandboxedPlugins = await EmDashRuntime.loadSandboxedPlugins(deps, db);
// Cold-start: load marketplace-installed plugins from site R2
- if (deps.config.marketplace && storage) {
+ if (storage) {
await EmDashRuntime.loadMarketplacePlugins(db, storage, deps, sandboxedPlugins);
}
@@ -1305,6 +1306,7 @@ export class EmDashRuntime {
isI18nEnabled() && i18nConfig
? { defaultLocale: i18nConfig.defaultLocale, locales: i18nConfig.locales }
: undefined;
+ const marketplaceUrl = await resolveMarketplaceUrl(this.db, this.config.marketplace);
return {
version: "0.1.0",
@@ -1313,7 +1315,7 @@ export class EmDashRuntime {
plugins: manifestPlugins,
authMode: authModeValue,
i18n,
- marketplace: !!this.config.marketplace,
+ marketplace: !!marketplaceUrl,
};
}
diff --git a/packages/core/src/settings/marketplace.ts b/packages/core/src/settings/marketplace.ts
new file mode 100644
index 000000000..c8b1013f8
--- /dev/null
+++ b/packages/core/src/settings/marketplace.ts
@@ -0,0 +1,81 @@
+import type { Kysely } from "kysely";
+
+import type { Database } from "../database/types.js";
+import { getSiteSettingWithDb } from "./index.js";
+import type { MarketplaceRegistry, MarketplaceSettings } from "./types.js";
+
+function isLocalMarketplaceHost(hostname: string): boolean {
+ return hostname === "localhost" || hostname === "127.0.0.1";
+}
+
+/**
+ * Validate a marketplace URL using the same policy as integration config:
+ * - HTTPS required in general
+ * - HTTP allowed only for localhost/127.0.0.1
+ */
+export function isValidMarketplaceUrl(url: string): boolean {
+ try {
+ const parsed = new URL(url);
+ if (parsed.protocol === "https:") return true;
+ return parsed.protocol === "http:" && isLocalMarketplaceHost(parsed.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function findRegistryById(
+ settings: MarketplaceSettings | undefined,
+ id: string | undefined,
+): MarketplaceRegistry | undefined {
+ if (!settings?.registries?.length || !id) return undefined;
+ return settings.registries.find((registry) => registry.id === id);
+}
+
+/**
+ * Resolve the active marketplace URL from marketplace settings.
+ *
+ * Resolution strategy:
+ * 1) activeRegistryId match
+ * 2) first configured registry
+ */
+export function resolveMarketplaceUrlFromSettings(
+ settings: MarketplaceSettings | undefined,
+): string | undefined {
+ if (!settings?.registries?.length) return undefined;
+
+ const byId = findRegistryById(settings, settings.activeRegistryId);
+ if (byId?.url && isValidMarketplaceUrl(byId.url)) {
+ return byId.url;
+ }
+
+ for (const registry of settings.registries) {
+ if (registry.url && isValidMarketplaceUrl(registry.url)) {
+ return registry.url;
+ }
+ }
+
+ return undefined;
+}
+
+/**
+ * Resolve the effective marketplace URL with precedence:
+ * admin site setting > integration config fallback.
+ */
+export async function resolveMarketplaceUrl(
+ db: Kysely,
+ configMarketplaceUrl?: string,
+): Promise {
+ try {
+ const marketplaceSettings = await getSiteSettingWithDb("marketplace", db);
+ const settingsUrl = resolveMarketplaceUrlFromSettings(marketplaceSettings);
+ if (settingsUrl) return settingsUrl;
+ } catch {
+ // Fallback to integration config if settings cannot be read.
+ }
+
+ if (configMarketplaceUrl && isValidMarketplaceUrl(configMarketplaceUrl)) {
+ return configMarketplaceUrl;
+ }
+
+ return undefined;
+}
diff --git a/packages/core/src/settings/types.ts b/packages/core/src/settings/types.ts
index 5ee8c72e7..78f68c592 100644
--- a/packages/core/src/settings/types.ts
+++ b/packages/core/src/settings/types.ts
@@ -24,6 +24,24 @@ export interface SeoSettings {
bingVerification?: string;
}
+/** Marketplace registry entry */
+export interface MarketplaceRegistry {
+ /** Stable identifier for selecting this registry as active */
+ id: string;
+ /** Human-readable label shown in admin UI */
+ label: string;
+ /** Marketplace base URL (e.g. https://marketplace.emdashcms.com) */
+ url: string;
+}
+
+/** Marketplace configuration stored in site settings */
+export interface MarketplaceSettings {
+ /** Available registry entries */
+ registries: MarketplaceRegistry[];
+ /** Selected active registry ID */
+ activeRegistryId?: string;
+}
+
/** Site settings schema */
export interface SiteSettings {
// Identity
@@ -52,6 +70,9 @@ export interface SiteSettings {
// SEO
seo?: SeoSettings;
+
+ // Marketplace
+ marketplace?: MarketplaceSettings;
}
/** Keys that are valid site settings */
diff --git a/packages/core/tests/unit/api/schemas.test.ts b/packages/core/tests/unit/api/schemas.test.ts
index 430693f73..f8cfb3c51 100644
--- a/packages/core/tests/unit/api/schemas.test.ts
+++ b/packages/core/tests/unit/api/schemas.test.ts
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
-import { contentUpdateBody, httpUrl } from "../../../src/api/schemas/index.js";
+import { contentUpdateBody, httpUrl, settingsUpdateBody } from "../../../src/api/schemas/index.js";
describe("contentUpdateBody schema", () => {
it("should pass through skipRevision when present", () => {
@@ -54,3 +54,51 @@ describe("httpUrl validator", () => {
expect(httpUrl.parse("HTTPS://EXAMPLE.COM")).toBe("HTTPS://EXAMPLE.COM");
});
});
+
+describe("settingsUpdateBody marketplace validation", () => {
+ it("accepts marketplace registries with https URLs", () => {
+ const parsed = settingsUpdateBody.parse({
+ marketplace: {
+ registries: [
+ {
+ id: "official",
+ label: "Official",
+ url: "https://marketplace.emdashcms.com",
+ },
+ ],
+ activeRegistryId: "official",
+ },
+ });
+ expect(parsed.marketplace?.registries[0]?.url).toBe("https://marketplace.emdashcms.com");
+ });
+
+ it("accepts localhost http URLs for development", () => {
+ const parsed = settingsUpdateBody.parse({
+ marketplace: {
+ registries: [{ id: "local", label: "Local", url: "http://localhost:8787" }],
+ },
+ });
+ expect(parsed.marketplace?.registries[0]?.url).toBe("http://localhost:8787");
+ });
+
+ it("rejects non-localhost http URLs", () => {
+ expect(() =>
+ settingsUpdateBody.parse({
+ marketplace: {
+ registries: [{ id: "bad", label: "Bad", url: "http://example.com" }],
+ },
+ }),
+ ).toThrow();
+ });
+
+ it("rejects activeRegistryId that does not exist in registries", () => {
+ expect(() =>
+ settingsUpdateBody.parse({
+ marketplace: {
+ registries: [{ id: "official", label: "Official", url: "https://example.com" }],
+ activeRegistryId: "missing",
+ },
+ }),
+ ).toThrow();
+ });
+});
diff --git a/packages/core/tests/unit/settings/marketplace.test.ts b/packages/core/tests/unit/settings/marketplace.test.ts
new file mode 100644
index 000000000..9cd92ffaa
--- /dev/null
+++ b/packages/core/tests/unit/settings/marketplace.test.ts
@@ -0,0 +1,84 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import type { MarketplaceSettings } from "../../../src/settings/types.js";
+
+const mockGetSiteSettingWithDb = vi.fn();
+
+vi.mock("../../../src/settings/index.js", () => ({
+ getSiteSettingWithDb: (...args: unknown[]) => mockGetSiteSettingWithDb(...args),
+}));
+
+const { isValidMarketplaceUrl, resolveMarketplaceUrl, resolveMarketplaceUrlFromSettings } =
+ await import("../../../src/settings/marketplace.js");
+
+describe("marketplace settings resolution", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("isValidMarketplaceUrl", () => {
+ it("accepts https URLs", () => {
+ expect(isValidMarketplaceUrl("https://marketplace.emdashcms.com")).toBe(true);
+ });
+
+ it("accepts localhost http URLs", () => {
+ expect(isValidMarketplaceUrl("http://localhost:8787")).toBe(true);
+ expect(isValidMarketplaceUrl("http://127.0.0.1:8787")).toBe(true);
+ });
+
+ it("rejects non-localhost http URLs", () => {
+ expect(isValidMarketplaceUrl("http://example.com")).toBe(false);
+ });
+ });
+
+ describe("resolveMarketplaceUrlFromSettings", () => {
+ it("uses active registry URL when present", () => {
+ const settings: MarketplaceSettings = {
+ registries: [
+ { id: "a", label: "A", url: "https://a.example.com" },
+ { id: "b", label: "B", url: "https://b.example.com" },
+ ],
+ activeRegistryId: "b",
+ };
+
+ expect(resolveMarketplaceUrlFromSettings(settings)).toBe("https://b.example.com");
+ });
+
+ it("falls back to first valid registry when active id is missing", () => {
+ const settings: MarketplaceSettings = {
+ registries: [
+ { id: "a", label: "A", url: "https://a.example.com" },
+ { id: "b", label: "B", url: "https://b.example.com" },
+ ],
+ };
+
+ expect(resolveMarketplaceUrlFromSettings(settings)).toBe("https://a.example.com");
+ });
+ });
+
+ describe("resolveMarketplaceUrl", () => {
+ it("prefers admin settings over config fallback", async () => {
+ mockGetSiteSettingWithDb.mockResolvedValue({
+ registries: [{ id: "custom", label: "Custom", url: "https://custom.example.com" }],
+ activeRegistryId: "custom",
+ });
+
+ const url = await resolveMarketplaceUrl({} as never, "https://marketplace.emdashcms.com");
+ expect(url).toBe("https://custom.example.com");
+ });
+
+ it("falls back to config URL when admin settings are absent", async () => {
+ mockGetSiteSettingWithDb.mockResolvedValue(undefined);
+
+ const url = await resolveMarketplaceUrl({} as never, "https://marketplace.emdashcms.com");
+ expect(url).toBe("https://marketplace.emdashcms.com");
+ });
+
+ it("returns undefined when neither settings nor fallback are valid", async () => {
+ mockGetSiteSettingWithDb.mockResolvedValue(undefined);
+
+ const url = await resolveMarketplaceUrl({} as never, "http://example.com");
+ expect(url).toBeUndefined();
+ });
+ });
+});
From 21f6f0fb056cc2e9d89799cf334725ededd140df Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Tue, 7 Apr 2026 11:15:36 -0600
Subject: [PATCH 2/8] Harden marketplace URL validation
---
.../settings/MarketplaceSettings.tsx | 24 ++++++++++++++++---
.../settings/MarketplaceSettings.test.tsx | 7 +++++-
packages/core/src/api/schemas/settings.ts | 2 +-
packages/core/src/settings/marketplace.ts | 6 ++---
.../tests/unit/settings/marketplace.test.ts | 10 +++++---
5 files changed, 38 insertions(+), 11 deletions(-)
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
index 5d00f4b4b..a04db385d 100644
--- a/packages/admin/src/components/settings/MarketplaceSettings.tsx
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -11,18 +11,23 @@ function createRegistryId(): string {
return `registry_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
+function isLocalEnvironment(): boolean {
+ if (typeof window === "undefined") return false;
+ return window.location.hostname === "localhost" || window.location.hostname === "127.0.0.1";
+}
+
interface MarketplaceRegistryForm {
id: string;
label: string;
url: string;
}
-function isValidMarketplaceUrl(url: string): boolean {
+function isValidMarketplaceUrl(url: string, allowLocalhost = isLocalEnvironment()): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol === "https:") return true;
const isLocalhost = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
- return parsed.protocol === "http:" && isLocalhost;
+ return allowLocalhost && parsed.protocol === "http:" && isLocalhost;
} catch {
return false;
}
@@ -78,7 +83,7 @@ export function MarketplaceSettings() {
return;
}
if (!isValidMarketplaceUrl(url)) {
- setLocalError("Marketplace URL must use HTTPS or localhost HTTP");
+ setLocalError("Marketplace URL must use HTTPS, or localhost HTTP during local development");
return;
}
const id = createRegistryId();
@@ -144,6 +149,19 @@ export function MarketplaceSettings() {
theme browsing.
+
+
Security notice
+
+ Only use the official EmDash marketplace URL or registries you fully trust. Marketplace
+ requests are made by your server, so an untrusted registry can control metadata, downloads,
+ and update responses.
+
+
+ Use HTTPS for production registries. Localhost HTTP URLs are intended only for local
+ development.
+
+
+
{saveStatus && (
diff --git a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
index 55a4ca28f..60d4f8217 100644
--- a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
+++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
@@ -65,6 +65,7 @@ describe("MarketplaceSettings", () => {
,
);
await expect.element(screen.getByText("Marketplace Settings")).toBeInTheDocument();
+ await expect.element(screen.getByText("Security notice")).toBeInTheDocument();
await expect.element(screen.getByText("Official")).toBeInTheDocument();
await expect.element(screen.getByText("https://marketplace.emdashcms.com")).toBeInTheDocument();
});
@@ -87,7 +88,11 @@ describe("MarketplaceSettings", () => {
await screen.getByRole("button", { name: "Add" }).click();
await expect
- .element(screen.getByText("Marketplace URL must use HTTPS or localhost HTTP"))
+ .element(
+ screen.getByText(
+ "Marketplace URL must use HTTPS, or localhost HTTP during local development",
+ ),
+ )
.toBeInTheDocument();
});
diff --git a/packages/core/src/api/schemas/settings.ts b/packages/core/src/api/schemas/settings.ts
index 0848cf5af..9a57258e4 100644
--- a/packages/core/src/api/schemas/settings.ts
+++ b/packages/core/src/api/schemas/settings.ts
@@ -36,7 +36,7 @@ const marketplaceRegistry = z.object({
.string()
.url()
.refine((url) => isValidMarketplaceUrl(url), {
- message: "Marketplace URL must use HTTPS or localhost HTTP",
+ message: "Marketplace URL must use HTTPS, or localhost HTTP during local development",
}),
});
diff --git a/packages/core/src/settings/marketplace.ts b/packages/core/src/settings/marketplace.ts
index c8b1013f8..4fc7720a9 100644
--- a/packages/core/src/settings/marketplace.ts
+++ b/packages/core/src/settings/marketplace.ts
@@ -11,13 +11,13 @@ function isLocalMarketplaceHost(hostname: string): boolean {
/**
* Validate a marketplace URL using the same policy as integration config:
* - HTTPS required in general
- * - HTTP allowed only for localhost/127.0.0.1
+ * - HTTP allowed only for localhost/127.0.0.1 during development
*/
-export function isValidMarketplaceUrl(url: string): boolean {
+export function isValidMarketplaceUrl(url: string, allowLocalhost = import.meta.env.DEV): boolean {
try {
const parsed = new URL(url);
if (parsed.protocol === "https:") return true;
- return parsed.protocol === "http:" && isLocalMarketplaceHost(parsed.hostname);
+ return allowLocalhost && parsed.protocol === "http:" && isLocalMarketplaceHost(parsed.hostname);
} catch {
return false;
}
diff --git a/packages/core/tests/unit/settings/marketplace.test.ts b/packages/core/tests/unit/settings/marketplace.test.ts
index 9cd92ffaa..11819be1b 100644
--- a/packages/core/tests/unit/settings/marketplace.test.ts
+++ b/packages/core/tests/unit/settings/marketplace.test.ts
@@ -21,14 +21,18 @@ describe("marketplace settings resolution", () => {
expect(isValidMarketplaceUrl("https://marketplace.emdashcms.com")).toBe(true);
});
- it("accepts localhost http URLs", () => {
- expect(isValidMarketplaceUrl("http://localhost:8787")).toBe(true);
- expect(isValidMarketplaceUrl("http://127.0.0.1:8787")).toBe(true);
+ it("accepts localhost http URLs only when explicitly allowed", () => {
+ expect(isValidMarketplaceUrl("http://localhost:8787", true)).toBe(true);
+ expect(isValidMarketplaceUrl("http://127.0.0.1:8787", true)).toBe(true);
});
it("rejects non-localhost http URLs", () => {
expect(isValidMarketplaceUrl("http://example.com")).toBe(false);
});
+
+ it("rejects localhost http URLs when localhost is not allowed", () => {
+ expect(isValidMarketplaceUrl("http://localhost:8787", false)).toBe(false);
+ });
});
describe("resolveMarketplaceUrlFromSettings", () => {
From 65af66bc9837cd1e9a81f8aaaa4b9284fb9aa3b8 Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Tue, 7 Apr 2026 12:06:39 -0600
Subject: [PATCH 3/8] Clarify active marketplace selection UX
---
.../settings/MarketplaceSettings.tsx | 25 ++++++++++++++++++-
.../settings/MarketplaceSettings.test.tsx | 3 +++
2 files changed, 27 insertions(+), 1 deletion(-)
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
index a04db385d..23f8a8b3c 100644
--- a/packages/admin/src/components/settings/MarketplaceSettings.tsx
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -149,6 +149,14 @@ export function MarketplaceSettings() {
theme browsing.
+
+
How registry selection works
+
+ Only one marketplace is active at a time. EmDash fetches plugin and theme listings from
+ the selected registry only, and does not merge results across multiple registries.
+
+
+
Security notice
@@ -210,6 +218,14 @@ export function MarketplaceSettings() {
No registries configured yet. Add one to enable marketplace browsing.
)}
+ {registries.length > 0 && (
+
+
Select the active marketplace
+
+ The selected registry is the only one used for plugin and theme browsing.
+
+
+ )}
{registries.map((registry) => (
-
{registry.label}
+
+
{registry.label}
+ {activeRegistryId === registry.id && (
+
+ Active
+
+ )}
+
{registry.url}
diff --git a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
index 60d4f8217..6d6a8f2ce 100644
--- a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
+++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
@@ -65,7 +65,10 @@ describe("MarketplaceSettings", () => {
,
);
await expect.element(screen.getByText("Marketplace Settings")).toBeInTheDocument();
+ await expect.element(screen.getByText("How registry selection works")).toBeInTheDocument();
await expect.element(screen.getByText("Security notice")).toBeInTheDocument();
+ await expect.element(screen.getByText("Select the active marketplace")).toBeInTheDocument();
+ await expect.element(screen.getByText("Active")).toBeInTheDocument();
await expect.element(screen.getByText("Official")).toBeInTheDocument();
await expect.element(screen.getByText("https://marketplace.emdashcms.com")).toBeInTheDocument();
});
From 9eff9fa91cc3287b58d0351955c2320f2176b96f Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Tue, 7 Apr 2026 12:25:37 -0600
Subject: [PATCH 4/8] Match marketplace settings styling to admin theme
---
.../admin/src/components/settings/MarketplaceSettings.tsx | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
index 23f8a8b3c..7917918a0 100644
--- a/packages/admin/src/components/settings/MarketplaceSettings.tsx
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -157,7 +157,7 @@ export function MarketplaceSettings() {
-
+
Security notice
Only use the official EmDash marketplace URL or registries you fully trust. Marketplace
@@ -171,7 +171,7 @@ export function MarketplaceSettings() {
{saveStatus && (
-
+
{saveStatus}
@@ -243,7 +243,7 @@ export function MarketplaceSettings() {
{registry.label}
{activeRegistryId === registry.id && (
-
+
Active
)}
From e06371c855e5b5882ad9c64da6c0e2d3fa56419e Mon Sep 17 00:00:00 2001
From: "emdashbot[bot]"
Date: Wed, 8 Apr 2026 14:19:42 +0000
Subject: [PATCH 5/8] style: format
---
.../admin/src/components/settings/MarketplaceSettings.tsx | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
index a04db385d..9fa76880b 100644
--- a/packages/admin/src/components/settings/MarketplaceSettings.tsx
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -153,8 +153,8 @@ export function MarketplaceSettings() {
Security notice
Only use the official EmDash marketplace URL or registries you fully trust. Marketplace
- requests are made by your server, so an untrusted registry can control metadata, downloads,
- and update responses.
+ requests are made by your server, so an untrusted registry can control metadata,
+ downloads, and update responses.
Use HTTPS for production registries. Localhost HTTP URLs are intended only for local
From cb220e8d2c596920c9484b2513424644eb6f7d74 Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Wed, 8 Apr 2026 08:41:47 -0600
Subject: [PATCH 6/8] Fix marketplace admin test coverage
---
packages/admin/src/router.tsx | 2 +-
.../tests/components/settings/MarketplaceSettings.test.tsx | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx
index 94eab6020..0cddf6c48 100644
--- a/packages/admin/src/router.tsx
+++ b/packages/admin/src/router.tsx
@@ -243,7 +243,7 @@ function ContentListPage() {
queryFn: ({ pageParam }) =>
fetchContentList(collection, {
locale: activeLocale,
- cursor: pageParam as string | undefined,
+ cursor: pageParam,
limit: 100,
}),
initialPageParam: undefined as string | undefined,
diff --git a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
index 6d6a8f2ce..82c190d38 100644
--- a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
+++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
@@ -68,8 +68,8 @@ describe("MarketplaceSettings", () => {
await expect.element(screen.getByText("How registry selection works")).toBeInTheDocument();
await expect.element(screen.getByText("Security notice")).toBeInTheDocument();
await expect.element(screen.getByText("Select the active marketplace")).toBeInTheDocument();
- await expect.element(screen.getByText("Active")).toBeInTheDocument();
- await expect.element(screen.getByText("Official")).toBeInTheDocument();
+ await expect.element(screen.getByText(/^Active$/)).toBeInTheDocument();
+ await expect.element(screen.getByText(/^Official$/)).toBeInTheDocument();
await expect.element(screen.getByText("https://marketplace.emdashcms.com")).toBeInTheDocument();
});
From ea1d54c7b6845c73fef495f6c2460d19f3d20d4d Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Wed, 8 Apr 2026 08:50:14 -0600
Subject: [PATCH 7/8] Tighten marketplace settings test selectors
---
.../admin/src/components/settings/MarketplaceSettings.tsx | 4 ++--
.../tests/components/settings/MarketplaceSettings.test.tsx | 7 +++++--
2 files changed, 7 insertions(+), 4 deletions(-)
diff --git a/packages/admin/src/components/settings/MarketplaceSettings.tsx b/packages/admin/src/components/settings/MarketplaceSettings.tsx
index 7917918a0..7aa5b67f2 100644
--- a/packages/admin/src/components/settings/MarketplaceSettings.tsx
+++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx
@@ -161,8 +161,8 @@ export function MarketplaceSettings() {
Security notice
Only use the official EmDash marketplace URL or registries you fully trust. Marketplace
- requests are made by your server, so an untrusted registry can control metadata, downloads,
- and update responses.
+ requests are made by your server, so an untrusted registry can control metadata,
+ downloads, and update responses.
Use HTTPS for production registries. Localhost HTTP URLs are intended only for local
diff --git a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
index 82c190d38..8acc5e8aa 100644
--- a/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
+++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx
@@ -33,6 +33,9 @@ vi.mock("../../../src/lib/api", async () => {
const { MarketplaceSettings } =
await import("../../../src/components/settings/MarketplaceSettings");
+const ACTIVE_BADGE_TEXT = /^Active$/;
+const OFFICIAL_LABEL_TEXT = /^Official$/;
+
function Wrapper({ children }: { children: React.ReactNode }) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
@@ -68,8 +71,8 @@ describe("MarketplaceSettings", () => {
await expect.element(screen.getByText("How registry selection works")).toBeInTheDocument();
await expect.element(screen.getByText("Security notice")).toBeInTheDocument();
await expect.element(screen.getByText("Select the active marketplace")).toBeInTheDocument();
- await expect.element(screen.getByText(/^Active$/)).toBeInTheDocument();
- await expect.element(screen.getByText(/^Official$/)).toBeInTheDocument();
+ await expect.element(screen.getByText(ACTIVE_BADGE_TEXT)).toBeInTheDocument();
+ await expect.element(screen.getByText(OFFICIAL_LABEL_TEXT)).toBeInTheDocument();
await expect.element(screen.getByText("https://marketplace.emdashcms.com")).toBeInTheDocument();
});
From 83fc01b98cdcc8e37628aec6eecde7d02e88211e Mon Sep 17 00:00:00 2001
From: Alan Hurtarte
Date: Wed, 8 Apr 2026 09:17:07 -0600
Subject: [PATCH 8/8] Add changeset for marketplace settings
---
.changeset/admin-marketplace-settings.md | 6 ++++++
1 file changed, 6 insertions(+)
create mode 100644 .changeset/admin-marketplace-settings.md
diff --git a/.changeset/admin-marketplace-settings.md b/.changeset/admin-marketplace-settings.md
new file mode 100644
index 000000000..667bd29df
--- /dev/null
+++ b/.changeset/admin-marketplace-settings.md
@@ -0,0 +1,6 @@
+---
+"emdash": minor
+"@emdash-cms/admin": minor
+---
+
+Add admin-configurable marketplace settings with active registry selection, validation, and safety guidance.