+ {ext.iconUrl ? (
+

+ ) : isPlugin ? (
) : (
diff --git a/app/api/admin/marketplace/[slug]/route.ts b/app/api/admin/marketplace/[slug]/route.ts
index d747c632..ead554ae 100644
--- a/app/api/admin/marketplace/[slug]/route.ts
+++ b/app/api/admin/marketplace/[slug]/route.ts
@@ -168,6 +168,11 @@ export async function GET(
}))
: [];
+ const fileUrl = (path: unknown): string | null =>
+ typeof path === 'string' && path
+ ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
+ : null;
+
return NextResponse.json(
{
extension: {
@@ -184,6 +189,8 @@ export async function GET(
githubRepo: extension.githubRepo ?? null,
license: extension.license ?? null,
minAppVersion: extension.minAppVersion ?? null,
+ iconUrl: fileUrl(extension.iconPath),
+ bannerUrl: fileUrl(extension.bannerPath),
author: extension.author ?? null,
latestVersion,
versions,
diff --git a/app/api/admin/marketplace/route.ts b/app/api/admin/marketplace/route.ts
index 98445226..5f0254f1 100644
--- a/app/api/admin/marketplace/route.ts
+++ b/app/api/admin/marketplace/route.ts
@@ -60,9 +60,16 @@ export async function GET(request: NextRequest) {
const installedPlugins = new Set(pluginRegistry.plugins.map(p => p.id));
const installedThemes = new Set(themeRegistry.themes.map(t => t.id));
+ const fileUrl = (path: unknown): string | null =>
+ typeof path === 'string' && path
+ ? new URL(`/api/v1/files/${path}`, DIRECTORY_URL).toString()
+ : null;
+
if (data.data) {
data.data = data.data.map((ext: Record
) => ({
...ext,
+ iconUrl: fileUrl(ext.iconPath),
+ bannerUrl: fileUrl(ext.bannerPath),
installed: ext.type === 'theme'
? installedThemes.has(ext.slug as string)
: installedPlugins.has(ext.slug as string),
diff --git a/lib/plugin-types.ts b/lib/plugin-types.ts
index 780ec021..a1a58de3 100644
--- a/lib/plugin-types.ts
+++ b/lib/plugin-types.ts
@@ -53,7 +53,23 @@ export interface ThemeManifest {
author: string;
description: string;
type: 'theme';
+ /** @deprecated kept as alias for `banner` so existing themes still work. */
preview?: string;
+ /**
+ * Path inside the source repo (relative to manifest.json) to a square
+ * brand icon shown in marketplace cards and the host's theme picker.
+ */
+ icon?: string;
+ /**
+ * Path to a wide promo image shown as the hero on the theme detail
+ * page. PNG/JPG/WebP, ≤512 KB.
+ */
+ banner?: string;
+ /**
+ * Up to 6 screenshot paths shown in the gallery on the detail page.
+ * Themes typically use this to show light + dark variants.
+ */
+ screenshots?: string[];
variants: ThemeVariant[];
minAppVersion?: string;
@@ -100,6 +116,25 @@ export interface PluginManifest {
* Validated at install time and merged into the host CSP `frame-src`.
*/
frameOrigins?: string[];
+
+ // ─── Marketplace media (NOT shipped in the runtime zip) ──────
+ /**
+ * Path inside the source repo (relative to manifest.json) to a square
+ * brand icon. PNG/SVG/WebP, ≤256 KB, 128×128 or larger recommended.
+ * The extension directory ingests this from git and serves it on
+ * marketplace cards and the host's plugin admin UI.
+ */
+ icon?: string;
+ /**
+ * Path to a wide promo image (16:9 recommended), shown as the hero on
+ * the extension detail page. PNG/JPG/WebP, ≤512 KB.
+ */
+ banner?: string;
+ /**
+ * Up to 6 screenshot paths shown in the gallery on the detail page.
+ * Each ≤512 KB; total ≤2 MB. Order is preserved.
+ */
+ screenshots?: string[];
}
export interface SettingFieldSchema {