From 2c2239099d225e6d0754587ff53895adcadefab4 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 8 Oct 2026 18:37:03 +0300 Subject: [PATCH 1/8] fix(plugins): fail the delete action when no row was removed (cherry picked from commit f3a7e69c9eb5f39f8841fe863771fdfec3cfd099) --- apps/cursor/src/actions/delete-plugin.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/cursor/src/actions/delete-plugin.ts b/apps/cursor/src/actions/delete-plugin.ts index 1b74e971..5c6737d6 100644 --- a/apps/cursor/src/actions/delete-plugin.ts +++ b/apps/cursor/src/actions/delete-plugin.ts @@ -33,16 +33,26 @@ export const deletePluginAction = authActionClient ); } - const { error } = await supabase + const { data: deleted, error } = await supabase .from("plugins") .delete() .eq("id", id) - .eq("owner_id", userId); + .eq("owner_id", userId) + .select("id"); if (error) { throw new ActionError(`Failed to delete plugin: ${error.message}`); } + // A delete that matches no rows is not an error for PostgREST, so without + // this check a row the policy refuses to delete would still produce the + // "Plugin deleted." toast while staying live and keeping its slug. + if (!deleted || deleted.length === 0) { + throw new ActionError( + "The plugin could not be deleted. It is still listed; contact support to have it removed.", + ); + } + // Deletions must disappear from cached lists immediately for the owner. updateTag("plugins"); updateTag(`plugin-${existing.slug}`); From 54ca93acb3af6fd6bd3ec2938040f50e050ada31 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 8 Oct 2026 20:24:44 +0300 Subject: [PATCH 2/8] fix(plugins): render plugin logos unoptimized so any manifest logo host works (cherry picked from commit f00fa3ba88fd00c4ec45ccf7872ce314d8cebadc) --- AGENTS.md | 2 +- apps/cursor/src/components/plugins/detail/plugin-logo.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ec958a4c..ce3457fe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,7 @@ Lint from repo root: `bunx biome ci .` (matches CI in `.github/workflows/ci.yml` - App env lives in `apps/cursor/.env` (gitignored). For local dev it uses the Supabase CLI's well-known local default keys (`supabase status` prints them) with `NEXT_PUBLIC_SUPABASE_URL=http://127.0.0.1:54321`. `CRON_SECRET` guards `/api/queue/plugin-scans/drain` and `/api/cron/*`. - The login page only offers GitHub/Google OAuth, which are not configured locally. Create a confirmed user via the GoTrue admin API (`POST {SUPABASE_URL}/auth/v1/admin/users` with the secret key, `"email_confirm": true`); a DB trigger provisions the `public.users` profile row. To get a browser session, add a temporary route that calls `supabase.auth.signInWithPassword(...)` with the server client (do not commit it). To use `/admin/plugins` and the MCP server, add the user to the `public.admins` table: `insert into public.admins (user_id) values ('')`. Email rows only match Google/GitHub identities, so local password users need a `user_id` row. - Cache Components (`cacheComponents: true`) makes pages with `generateStaticParams` throw 500 on an empty database — seed at least one active plugin. Use `bun run seed:extract` / `seed:insert`, but note: the insert script must be run as `bun run --conditions=react-server --env-file=apps/cursor/.env apps/cursor/src/scripts/insert-from-jsonl.ts` or the `server-only` import throws under plain `bun run`. GitHub Code Search (used by `seed:extract` discovery) is heavily rate-limited; hand-writing `apps/cursor/.seed/candidates.json` with `{"candidates":[{"owner":...,"repo":...,"source":"seed:topic","matchedQuery":"manual"}]}` skips discovery. -- Seeded plugin logos hosted on `raw.githubusercontent.com` 500 plugin pages because that host is not in `next.config.mjs` `images.remotePatterns`; null them: `update plugins set logo = null where logo like 'https://raw.githubusercontent.com%'`. +- Plugin logos are rendered `unoptimized` in `PluginLogo`, so hosts outside `next.config.mjs` `images.remotePatterns` (for example `raw.githubusercontent.com` for manifests with a relative `logo`) no longer 500 the plugin page. Only `next/image` usages that go through the optimizer need the allowlist. - After changing `.env` or database content backing cached pages, restart `bun dev` (and `rm -rf apps/cursor/.next` if stale renders persist) — hot reload does not invalidate Cache Components output. - Without `CURSOR_API_KEY`, plugin submission works but the security scan errors (plugin lands in the admin "Scan issues" queue, where an admin can publish it manually). This is expected locally. - Supabase `edge_runtime` is disabled in `supabase/config.toml` (no edge functions in this app; its boot probe needs deno.land which is blocked). diff --git a/apps/cursor/src/components/plugins/detail/plugin-logo.tsx b/apps/cursor/src/components/plugins/detail/plugin-logo.tsx index 34aa0fda..0ca65d9d 100644 --- a/apps/cursor/src/components/plugins/detail/plugin-logo.tsx +++ b/apps/cursor/src/components/plugins/detail/plugin-logo.tsx @@ -42,6 +42,12 @@ export function PluginLogo({ logo.endsWith(".svg") && "invert", )} onError={() => setError(true)} + // Logos come from whatever host the plugin manifest points at (raw + // GitHub content for relative paths, anything for absolute URLs), so + // the image optimizer's remotePatterns allowlist would 500 the page + // for most of them. The card and leaderboard already render the same + // URLs as plain ; do the same here. + unoptimized /> ); } From 5e8d9832c39d3d87b2374ed99c4105908061fe14 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Thu, 8 Oct 2026 20:26:48 +0300 Subject: [PATCH 3/8] chore: point docs at agent-plugins.org, make lint run biome, document detected manifests and commands (cherry picked from commit 00c7536effcc35b65698dcb4267b10cc407333aa) --- README.md | 11 +++++++---- apps/cursor/package.json | 2 +- apps/cursor/src/app/plugins/new/page.tsx | 2 +- apps/cursor/src/components/forms/plugin-form.tsx | 2 +- apps/cursor/src/lib/github-plugin/parse.ts | 2 +- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 6dc43fc5..80149f38 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,7 @@ All content is submitted through the website — no pull requests needed for dat 1. Go to [cursor.directory/plugins/new](https://cursor.directory/plugins/new) 2. Sign in with GitHub or Google -3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://open-plugins.com) standard +3. Paste a GitHub repo URL — we auto-detect components following the [Open Plugins](https://agent-plugins.org) standard 4. Click **Submit** Auto-detected components: @@ -85,13 +85,16 @@ Auto-detected components: | Component | Path | |-----------|------| | Rules | `rules/*.mdc` | -| MCP Servers | `.mcp.json` | +| MCP Servers | `.mcp.json` or `mcp.json` | | Skills | `skills/*/SKILL.md` | | Agents | `agents/*.md` | +| Commands | `commands/*.md` | | Hooks | `hooks/hooks.json` | | LSP Servers | `.lsp.json` | -See the [Open Plugins specification](https://open-plugins.com/plugin-builders/specification) and [plugin template](https://github.com/cursor/plugin-template) for details. +The manifest is read from `.plugin/plugin.json`, `.cursor-plugin/plugin.json` or `.claude-plugin/plugin.json` (first match wins). For monorepos, the same paths are also scanned inside every directory listed as `plugins[].source` in `.cursor-plugin/marketplace.json` and every directory that contains its own `.cursor-plugin/plugin.json`. + +See the [Open Plugins specification](https://agent-plugins.org/specification) and [plugin template](https://github.com/cursor/plugin-template) for details. --- @@ -104,7 +107,7 @@ See the [Open Plugins specification](https://open-plugins.com/plugin-builders/sp - **Styling**: [Tailwind CSS](https://tailwindcss.com) - **UI**: [Radix UI](https://radix-ui.com) + [shadcn/ui](https://ui.shadcn.com) - **Search**: [Fuse.js](https://fusejs.io) (client-side fuzzy search) -- **URL State**: [nuqs](https://nuqs.47ng.com) +- **URL State**: [nuqs](https://nuqs.dev) - **Linting**: [Biome](https://biomejs.dev) ## Plugin security scan diff --git a/apps/cursor/package.json b/apps/cursor/package.json index a73647c6..c86bfa9e 100644 --- a/apps/cursor/package.json +++ b/apps/cursor/package.json @@ -9,7 +9,7 @@ "dev": "next dev --turbopack", "build": "next build", "start": "next start", - "lint": "next lint", + "lint": "biome check .", "typecheck": "tsc --noEmit", "seed:extract": "bun run --env-file=.env src/scripts/extract-from-github.ts", "seed:insert": "bun run --env-file=.env src/scripts/insert-from-jsonl.ts", diff --git a/apps/cursor/src/app/plugins/new/page.tsx b/apps/cursor/src/app/plugins/new/page.tsx index 116690e0..a51b2ca9 100644 --- a/apps/cursor/src/app/plugins/new/page.tsx +++ b/apps/cursor/src/app/plugins/new/page.tsx @@ -46,7 +46,7 @@ export default function Page() {
We follow the{" "} Date: Fri, 9 Oct 2026 09:38:09 +0300 Subject: [PATCH 4/8] chore: constant-time cron secret compare, least-privilege CI token, single biome version (cherry picked from commit 4b1a6ac61a58cd449c6d1a9e86d8306a27e82c04) --- .github/workflows/ci.yml | 7 +++++++ apps/cursor/package.json | 2 +- apps/cursor/src/lib/cron-auth.ts | 11 +++++++++-- bun.lock | 20 +------------------- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9295e1d9..d0eb869f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,13 @@ on: branches: [main] pull_request: +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: checks: name: Lint & typecheck diff --git a/apps/cursor/package.json b/apps/cursor/package.json index c86bfa9e..3df01a60 100644 --- a/apps/cursor/package.json +++ b/apps/cursor/package.json @@ -60,7 +60,7 @@ "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "2.4.14", + "@biomejs/biome": "2.4.15", "@tailwindcss/postcss": "^4.2.4", "@types/node": "^25.6.0", "@types/react": "^19", diff --git a/apps/cursor/src/lib/cron-auth.ts b/apps/cursor/src/lib/cron-auth.ts index c7c83838..350b29aa 100644 --- a/apps/cursor/src/lib/cron-auth.ts +++ b/apps/cursor/src/lib/cron-auth.ts @@ -1,3 +1,4 @@ +import { timingSafeEqual } from "node:crypto"; import { NextResponse } from "next/server"; /** @@ -24,8 +25,14 @@ export function requireCronAuth(request: Request): NextResponse | null { ); } - const header = request.headers.get("authorization"); - if (header !== `Bearer ${cronSecret}`) { + const header = request.headers.get("authorization") ?? ""; + const expected = Buffer.from(`Bearer ${cronSecret}`); + const received = Buffer.from(header); + // Constant-time compare so the response time does not leak how much of + // the secret matched. Lengths must match first or timingSafeEqual throws. + const authorized = + received.length === expected.length && timingSafeEqual(received, expected); + if (!authorized) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); } diff --git a/bun.lock b/bun.lock index 50aebdde..fb1ad1fc 100644 --- a/bun.lock +++ b/bun.lock @@ -57,7 +57,7 @@ "zod": "^4.4.3", }, "devDependencies": { - "@biomejs/biome": "2.4.14", + "@biomejs/biome": "2.4.15", "@tailwindcss/postcss": "^4.2.4", "@types/node": "^25.6.0", "@types/react": "^19", @@ -735,8 +735,6 @@ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@directories/cursor/@biomejs/biome": ["@biomejs/biome@2.4.14", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.14", "@biomejs/cli-darwin-x64": "2.4.14", "@biomejs/cli-linux-arm64": "2.4.14", "@biomejs/cli-linux-arm64-musl": "2.4.14", "@biomejs/cli-linux-x64": "2.4.14", "@biomejs/cli-linux-x64-musl": "2.4.14", "@biomejs/cli-win32-arm64": "2.4.14", "@biomejs/cli-win32-x64": "2.4.14" }, "bin": { "biome": "bin/biome" } }, "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ=="], - "@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-avatar/@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], @@ -811,22 +809,6 @@ "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], - "@directories/cursor/@biomejs/biome/@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.14", "", { "os": "linux", "cpu": "x64" }, "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA=="], - - "@directories/cursor/@biomejs/biome/@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.14", "", { "os": "win32", "cpu": "x64" }, "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA=="], - "cacache/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], "cacache/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], From 470d7d0689031599065cd38b77c44bc4ad739270 Mon Sep 17 00:00:00 2001 From: Dilpreet Kaur Date: Thu, 27 Aug 2026 21:51:50 +0100 Subject: [PATCH 5/8] Persist MCP server name in component metadata (cherry picked from commit a520aef3391af4d5c1e63afbb6ec8843a8d3378e) --- apps/cursor/src/lib/github-plugin/parse.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cursor/src/lib/github-plugin/parse.ts b/apps/cursor/src/lib/github-plugin/parse.ts index aec085e9..6c78c6e2 100644 --- a/apps/cursor/src/lib/github-plugin/parse.ts +++ b/apps/cursor/src/lib/github-plugin/parse.ts @@ -437,6 +437,7 @@ export async function parseGitHubPlugin( description: `MCP server: ${name}`, content: JSON.stringify(cfg, null, 2), metadata: { + name, command: cfg.command, args: cfg.args, env: cfg.env, From ca8773d3ab84874c5ef196c1452037b200a0f83f Mon Sep 17 00:00:00 2001 From: Dilpreet Kaur Date: Thu, 27 Aug 2026 21:51:52 +0100 Subject: [PATCH 6/8] Fix MCP install name fallback and show paste-ready config (cherry picked from commit 777c4f5e5a0c56e464d47c05e4a94b11893242ee) --- .../components/plugins/detail/mcp-section.tsx | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/apps/cursor/src/components/plugins/detail/mcp-section.tsx b/apps/cursor/src/components/plugins/detail/mcp-section.tsx index f33a39e2..5a403ccc 100644 --- a/apps/cursor/src/components/plugins/detail/mcp-section.tsx +++ b/apps/cursor/src/components/plugins/detail/mcp-section.tsx @@ -12,6 +12,7 @@ import { buildMCPInstallDeepLink } from "./deeplinks"; function resolveMcpConfig( content: string | null, meta: Record, + fallbackName: string, ): { name: string; config: Record } | null { // Try content first, then metadata.config let parsed: Record | null = null; @@ -43,8 +44,13 @@ function resolveMcpConfig( } } - // Content is already a raw config (no mcpServers wrapper) - return { name: (meta?.name as string) ?? "server", config: parsed }; + // Content is already a raw config (no mcpServers wrapper). Ingestion strips + // the wrapper, so fall back to the component name before the generic default + // -- otherwise every such server installs as literally "server". + return { + name: (meta?.name as string) ?? fallbackName ?? "server", + config: parsed, + }; } export function McpSection({ @@ -70,10 +76,15 @@ export function McpSection({ let installLink = mcpLink ?? null; let configPreview: string | null = null; try { - const resolved = resolveMcpConfig(mcp.content, meta); + const resolved = resolveMcpConfig(mcp.content, meta, mcp.name); if (resolved) { - const serialized = JSON.stringify(resolved.config, null, 2); - configPreview = serialized; + // Show the wrapped form: this is what a user pastes into + // ~/.cursor/mcp.json. The bare inner object is not valid there. + configPreview = JSON.stringify( + { mcpServers: { [resolved.name]: resolved.config } }, + null, + 2, + ); if (!installLink) { installLink = buildMCPInstallDeepLink( resolved.name, @@ -133,8 +144,11 @@ export function McpSection({ mcp_link={installLink} onInstall={onInstall} /> - ) : installable && mcp.content ? ( - + ) : installable && (configPreview ?? mcp.content) ? ( + ) : null} @@ -142,8 +156,9 @@ export function McpSection({ {isExpanded && configPreview && (

- This config will be passed to Cursor on install. Inspect - `command`, `args`, and `env` before continuing. + This config will be passed to Cursor on install, and is + what you paste into `~/.cursor/mcp.json` to add the server by + hand. Inspect `command`, `args`, and `env` before continuing.

From 4ac271527d4f08621a76d10218e37048fa6705a8 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Fri, 16 Oct 2026 10:04:27 +0300 Subject: [PATCH 7/8] style: biome format for the MCP install note (follow-up to #427) --- apps/cursor/src/components/plugins/detail/mcp-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/cursor/src/components/plugins/detail/mcp-section.tsx b/apps/cursor/src/components/plugins/detail/mcp-section.tsx index 5a403ccc..c59539e7 100644 --- a/apps/cursor/src/components/plugins/detail/mcp-section.tsx +++ b/apps/cursor/src/components/plugins/detail/mcp-section.tsx @@ -156,9 +156,9 @@ export function McpSection({ {isExpanded && configPreview && (

- This config will be passed to Cursor on install, and is - what you paste into `~/.cursor/mcp.json` to add the server by - hand. Inspect `command`, `args`, and `env` before continuing. + This config will be passed to Cursor on install, and is what + you paste into `~/.cursor/mcp.json` to add the server by hand. + Inspect `command`, `args`, and `env` before continuing.

From fa4367e0121d7f6513e151fc689dbb383bfc52e3 Mon Sep 17 00:00:00 2001 From: Dean Rie Date: Fri, 16 Oct 2026 14:54:04 +0300 Subject: [PATCH 8/8] 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} +