From 6e32787d35f1aa4af20994eacf9828d52e07a85e Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Fri, 16 Oct 2026 14:54:04 +0300 Subject: [PATCH] plugins: let owners re-run the security scan from the listing menu Owners whose plugin is flagged, errored or unscanned can queue a new scan themselves instead of asking in an issue. Same path as the admin Rescan (status reset, queue, audit event with the owner as actor), gated by ownership, the permanent block, the current scan state, and the per-user scan rate limit that already caps create/update scans. Refs #447. --- apps/cursor/src/actions/rescan-own-plugin.ts | 80 +++++++++++++++++++ .../components/plugins/plugin-owner-menu.tsx | 34 +++++++- 2 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 apps/cursor/src/actions/rescan-own-plugin.ts 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} +