diff --git a/changelog.d/features/6752-plugins-marketplace-install-api.md b/changelog.d/features/6752-plugins-marketplace-install-api.md new file mode 100644 index 0000000000..4b53508688 --- /dev/null +++ b/changelog.d/features/6752-plugins-marketplace-install-api.md @@ -0,0 +1 @@ +- **feat(api):** add plugins marketplace install endpoint with checksum verification ([#6752](https://github.com/diegosouzapw/OmniRoute/issues/6752)) diff --git a/src/app/api/plugins/marketplace/install/route.ts b/src/app/api/plugins/marketplace/install/route.ts new file mode 100644 index 0000000000..f2c1b24e5b --- /dev/null +++ b/src/app/api/plugins/marketplace/install/route.ts @@ -0,0 +1,36 @@ +import { NextRequest, NextResponse } from "next/server"; +import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors"; +import { buildErrorBody } from "@omniroute/open-sse/utils/error"; +import { installMarketplacePlugin } from "@/lib/plugins/marketplace"; +import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; + +export async function OPTIONS() { + return handleCorsOptions(); +} + +/** + * POST /api/plugins/marketplace/install — Install a plugin from marketplace by name + */ +export async function POST(request: NextRequest) { + const authError = await requireManagementAuth(request); + if (authError) return authError; + try { + const body = await request.json(); + const { name } = body as { name?: string }; + if (!name || typeof name !== "string") { + return NextResponse.json(buildErrorBody(400, "Missing or invalid 'name' field"), { + status: 400, + headers: CORS_HEADERS, + }); + } + const result = await installMarketplacePlugin(name); + return NextResponse.json(result, { status: 201, headers: CORS_HEADERS }); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : "Failed to install marketplace plugin"; + console.error("[plugins/marketplace] Install error:", msg); + return NextResponse.json(buildErrorBody(400, msg), { + status: 400, + headers: CORS_HEADERS, + }); + } +} diff --git a/src/lib/plugins/marketplace.ts b/src/lib/plugins/marketplace.ts index beca410668..39d0f7b1e7 100644 --- a/src/lib/plugins/marketplace.ts +++ b/src/lib/plugins/marketplace.ts @@ -2,6 +2,11 @@ import { getSettings } from "../db/settings"; import dns from "node:dns/promises"; import { isPrivateHost } from "@/shared/network/outboundUrlGuard"; import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; +import { pluginManager } from "./manager"; +import { createHash } from "node:crypto"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; /** * Plugin Marketplace — browse, search, install plugins from a registry. * @@ -69,6 +74,7 @@ export interface MarketplaceEntry { author: string; license: string; downloadUrl: string; + checksum?: string; // SHA-256 hex (optional — verified when present) repository?: string; tags: string[]; downloads: number; @@ -200,3 +206,48 @@ export async function getMarketplaceEntry(name: string): Promise { + const plugins = await listMarketplacePlugins(); + const entry = plugins.find((p) => p.name === name); + if (!entry) { + throw new Error(`Plugin '${name}' not found in marketplace`); + } + + // Create temp dir for download + const tmpDir = await mkdtemp(join(tmpdir(), "plugin-mp-")); + const tmpFile = join(tmpDir, "plugin.tar.gz"); + + try { + // Download the plugin archive + const response = await safeOutboundFetch(entry.downloadUrl, { guard: "public-only" }); + if (!response.ok) { + throw new Error(`Failed to download plugin '${name}': ${response.status}`); + } + const buffer = Buffer.from(await response.arrayBuffer()); + + // Verify SHA-256 checksum if provided + if (entry.checksum) { + const actual = createHash("sha256").update(buffer).digest("hex"); + if (actual !== entry.checksum) { + throw new Error( + `Checksum mismatch for plugin '${name}': expected ${entry.checksum}, got ${actual}` + ); + } + } + + // Write to temp file + await writeFile(tmpFile, buffer); + + // Delegate to pluginManager.install + const result = await pluginManager.install(tmpDir); + return { name: result.name, version: result.version }; + } finally { + // Cleanup temp dir + await rm(tmpDir, { recursive: true, force: true }).catch(() => {}); + } +} diff --git a/tests/unit/plugins-marketplace-install.test.ts b/tests/unit/plugins-marketplace-install.test.ts new file mode 100644 index 0000000000..3e35781841 --- /dev/null +++ b/tests/unit/plugins-marketplace-install.test.ts @@ -0,0 +1,38 @@ +import { describe, it } from "node:test"; +import { ok, equal } from "node:assert/strict"; + +describe("Plugins marketplace install (#6752)", () => { + it("MarketplaceEntry supports optional checksum field", async () => { + const { searchMarketplace } = await import("@/lib/plugins/marketplace"); + const results = await searchMarketplace("prompt"); + ok(Array.isArray(results)); + for (const entry of results) { + ok(typeof entry.name === "string"); + ok(typeof entry.downloadUrl === "string"); + // The checksum field exists in the type (may be undefined) + if (entry.checksum !== undefined) { + ok(typeof entry.checksum === "string"); + } + } + }); + + it("installMarketplacePlugin throws for unknown plugin", async () => { + const { installMarketplacePlugin } = await import("@/lib/plugins/marketplace"); + try { + await installMarketplacePlugin("nonexistent-plugin"); + ok(false, "should have thrown"); + } catch (e: unknown) { + ok((e as Error).message.includes("not found")); + } + }); + + it("checksum verification logic works", async () => { + const crypto = await import("node:crypto"); + const data = Buffer.from("test-plugin-data"); + const hash = crypto.createHash("sha256").update(data).digest("hex"); + const hash2 = crypto.createHash("sha256").update(data).digest("hex"); + equal(hash, hash2, "same data should produce same hash"); + const hash3 = crypto.createHash("sha256").update(Buffer.from("different-data")).digest("hex"); + ok(hash !== hash3, "different data should produce different hash"); + }); +});