diff --git a/apps/cursor/src/actions/rescan-own-plugin.ts b/apps/cursor/src/actions/rescan-own-plugin.ts
new file mode 100644
index 00000000..888fcd49
--- /dev/null
+++ b/apps/cursor/src/actions/rescan-own-plugin.ts
@@ -0,0 +1,80 @@
+"use server";
+
+import { z } from "zod";
+import {
+ ModerationError,
+ rescanPlugin,
+ webModerationContext,
+} from "@/lib/plugins/moderation";
+import { pluginScanLimit } from "@/lib/rate-limit";
+import { createClient } from "@/utils/supabase/server";
+import { ActionError, authActionClient } from "./safe-action";
+
+/**
+ * Scan states an owner may re-run themselves. `pending` and `scanning` are
+ * already in flight; `safe` has nothing to fix; `permanently_blocked` rows are
+ * excluded below regardless of status.
+ */
+const OWNER_RESCANNABLE = new Set(["flagged", "error", "unscanned"]);
+
+/**
+ * Owner-initiated re-scan. Same path as the admin "Rescan" (status reset,
+ * queue, audit event with the owner as actor), gated by ownership, the
+ * permanent block, and the per-user scan rate limit that already caps
+ * create/update scans.
+ */
+export const rescanOwnPluginAction = authActionClient
+ .metadata({ actionName: "rescan-own-plugin" })
+ .schema(z.object({ pluginId: z.string().uuid() }))
+ .action(async ({ parsedInput: { pluginId }, ctx: { userId } }) => {
+ const supabase = await createClient();
+
+ const { data: existing, error: fetchError } = await supabase
+ .from("plugins")
+ .select("id, owner_id, scan_status, permanently_blocked")
+ .eq("id", pluginId)
+ .single();
+
+ if (fetchError || !existing) {
+ throw new ActionError("Plugin not found.");
+ }
+
+ if (existing.owner_id !== userId) {
+ throw new ActionError(
+ "You do not have permission to update this plugin.",
+ );
+ }
+
+ if (existing.permanently_blocked) {
+ throw new ActionError(
+ "This plugin has been blocked and cannot be re-scanned. Contact support if you believe this is a mistake.",
+ );
+ }
+
+ if (!OWNER_RESCANNABLE.has(existing.scan_status)) {
+ throw new ActionError(
+ existing.scan_status === "safe"
+ ? "This plugin already passed its last scan."
+ : "A scan is already in progress.",
+ );
+ }
+
+ const { success } = await pluginScanLimit(userId);
+ if (!success) {
+ throw new ActionError(
+ "Too many scans in the last hour. Please try again later.",
+ );
+ }
+
+ try {
+ await rescanPlugin(webModerationContext(userId), pluginId);
+ } catch (err) {
+ throw new ActionError(
+ err instanceof ModerationError
+ ? err.message
+ : "Failed to start the scan.",
+ );
+ }
+
+ return { success: true };
+ });
diff --git a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx
index 0dfd589b..ee257c8f 100644
--- a/apps/cursor/src/components/plugins/plugin-owner-menu.tsx
+++ b/apps/cursor/src/components/plugins/plugin-owner-menu.tsx
@@ -7,6 +7,7 @@ import {
Loader2,
MoreHorizontal,
Pencil,
+ RefreshCw,
Trash2,
} from "lucide-react";
import Link from "next/link";
@@ -16,6 +17,7 @@ import { useEffect, useState } from "react";
import { toast } from "sonner";
import { deletePluginAction } from "@/actions/delete-plugin";
import { requestPluginVerificationAction } from "@/actions/request-plugin-verification";
+import { rescanOwnPluginAction } from "@/actions/rescan-own-plugin";
import { togglePluginListingAction } from "@/actions/toggle-plugin-listing";
import {
AlertDialog,
@@ -106,6 +108,19 @@ export function PluginOwnerMenu({ plugin }: Props) {
},
);
+ const { execute: rescanPlugin, isExecuting: isRescanning } = useAction(
+ rescanOwnPluginAction,
+ {
+ onSuccess: () => {
+ toast.success("Scan queued. The listing updates when it finishes.");
+ router.refresh();
+ },
+ onError: ({ error }) => {
+ toast.error(error.serverError ?? "Failed to start the scan.");
+ },
+ },
+ );
+
const { execute: deletePlugin, isExecuting: isDeleting } = useAction(
deletePluginAction,
{
@@ -124,9 +139,15 @@ export function PluginOwnerMenu({ plugin }: Props) {
return null;
}
- const busy = isRequesting || isToggling || isDeleting;
+ const busy = isRequesting || isToggling || isRescanning || isDeleting;
const canVerify = !plugin.verified && !requestPending;
const canPublish = !active && !plugin.permanently_blocked;
+ // Mirrors the server-side gate in rescanOwnPluginAction.
+ const canRescan =
+ !plugin.permanently_blocked &&
+ (plugin.scan_status === "flagged" ||
+ plugin.scan_status === "error" ||
+ plugin.scan_status === "unscanned");
return (
<>
@@ -192,6 +213,17 @@ export function PluginOwnerMenu({ plugin }: Props) {
) : null}
+ {canRescan ? (
+ rescanPlugin({ pluginId: plugin.id })}
+ >
+
+ Re-run scan
+
+ ) : null}
+