feat(api): add plugins marketplace install API (#6752) (#9445)

Merge-train validated (tip 6ce4effef8). Vitest failures confirmed as base-red (#9679).
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-07 20:53:18 -03:00
committed by GitHub
parent 348102a114
commit 2f5df569b5
4 changed files with 126 additions and 0 deletions

View File

@@ -0,0 +1 @@
- **feat(api):** add plugins marketplace install endpoint with checksum verification ([#6752](https://github.com/diegosouzapw/OmniRoute/issues/6752))

View File

@@ -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,
});
}
}

View File

@@ -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<MarketplaceEntr
export function isMarketplaceAvailable(): boolean {
return true; // Always available (falls back to seed)
}
/**
* Install a plugin from the marketplace by name.
* Downloads the plugin archive, verifies checksum if present, and installs via PluginManager.
*/
export async function installMarketplacePlugin(name: string): Promise<{ name: string; version: string }> {
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(() => {});
}
}

View File

@@ -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");
});
});