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. 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 63592f6e3..0fef33742 100644 --- a/packages/admin/src/components/Sidebar.tsx +++ b/packages/admin/src/components/Sidebar.tsx @@ -52,7 +52,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..7aa5b67f2 --- /dev/null +++ b/packages/admin/src/components/settings/MarketplaceSettings.tsx @@ -0,0 +1,279 @@ +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)}`; +} + +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, 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 allowLocalhost && 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 during local development"); + 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. +

+ +
+

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

+

+ 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 && ( +
+ + {saveStatus} +
+ )} + + + + {isLoading ? ( +
+ Loading settings... +
+ ) : ( +
+
+

+ + Registries +

+ +
+ setNewLabel(event.target.value)} + /> + setNewUrl(event.target.value)} + /> +
+ +
+
+ +
+ {registries.length === 0 && ( +

+ 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) => ( +
+ + +
+ ))} +
+
+ +
+ +
+
+ )} +
+ ); +} + +export default MarketplaceSettings; diff --git a/packages/admin/src/lib/api/client.ts b/packages/admin/src/lib/api/client.ts index b46a84964..b07282b45 100644 --- a/packages/admin/src/lib/api/client.ts +++ b/packages/admin/src/lib/api/client.ts @@ -131,10 +131,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 63e85f064..e9649cfc7 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"; @@ -1118,6 +1119,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, @@ -1566,6 +1574,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..8acc5e8aa --- /dev/null +++ b/packages/admin/tests/components/settings/MarketplaceSettings.test.tsx @@ -0,0 +1,129 @@ +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"); + +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 } }, + }); + 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("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_BADGE_TEXT)).toBeInTheDocument(); + await expect.element(screen.getByText(OFFICIAL_LABEL_TEXT)).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 during local development", + ), + ) + .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..9a57258e4 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 during local development", + }), +}); + +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 33b329875..cf4056ac1 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,6 +41,7 @@ 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 siteOrigin = new URL(request.url).origin; @@ -47,7 +49,7 @@ export const POST: APIRoute = async ({ params, request, locals }) => { emdash.db, emdash.storage, emdash.getSandboxRunner(), - emdash.config.marketplace, + marketplaceUrl, id, { version: body.version, configuredPluginIds, siteOrigin }, ); 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 810b0bbae..b8e73fb93 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); } @@ -1306,6 +1307,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", @@ -1314,7 +1316,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..4fc7720a9 --- /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 during development + */ +export function isValidMarketplaceUrl(url: string, allowLocalhost = import.meta.env.DEV): boolean { + try { + const parsed = new URL(url); + if (parsed.protocol === "https:") return true; + return allowLocalhost && 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..11819be1b --- /dev/null +++ b/packages/core/tests/unit/settings/marketplace.test.ts @@ -0,0 +1,88 @@ +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 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", () => { + 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(); + }); + }); +});