diff --git a/apps/cursor/src/actions/create-plugin.ts b/apps/cursor/src/actions/create-plugin.ts index 8578cf7e..347fb27e 100644 --- a/apps/cursor/src/actions/create-plugin.ts +++ b/apps/cursor/src/actions/create-plugin.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; +import { resolveGithubRepoIdFromRepository } from "@/lib/github-plugin/parse"; import { InsertPluginError, insertPlugin } from "@/lib/plugins/insert"; import { pluginScanLimit } from "@/lib/rate-limit"; import { ActionError, authActionClient } from "./safe-action"; @@ -62,6 +63,12 @@ export const createPluginAction = authActionClient ); } + // Never trust a client-supplied github_repo_id — resolve from the + // repository URL via GitHub so squatters cannot block idempotent imports. + const githubRepoId = await resolveGithubRepoIdFromRepository(repository, { + maxWaitMs: 3000, + }); + let result: { id: string; slug: string }; try { result = await insertPlugin( @@ -74,7 +81,12 @@ export const createPluginAction = authActionClient keywords, components, }, - { ownerId: userId, source: "user", skipScan: false }, + { + ownerId: userId, + source: "user", + skipScan: false, + githubRepoId, + }, ); } catch (err) { if (err instanceof InsertPluginError) { diff --git a/apps/cursor/src/actions/star-plugin.ts b/apps/cursor/src/actions/star-plugin.ts index 5f85ecb3..8e8628cf 100644 --- a/apps/cursor/src/actions/star-plugin.ts +++ b/apps/cursor/src/actions/star-plugin.ts @@ -2,9 +2,8 @@ import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { createClient as createAdminClient } from "@/utils/supabase/admin-client"; import { createClient } from "@/utils/supabase/server"; -import { authActionClient } from "./safe-action"; +import { ActionError, authActionClient } from "./safe-action"; export const starPluginAction = authActionClient .metadata({ @@ -16,32 +15,17 @@ export const starPluginAction = authActionClient slug: z.string(), }), ) - .action(async ({ parsedInput: { pluginId, slug }, ctx: { userId } }) => { + .action(async ({ parsedInput: { pluginId, slug } }) => { + // User-scoped client so `auth.uid()` inside the SECURITY DEFINER RPC + // authorizes against the caller, not the service role. const supabase = await createClient(); - const { data: existing } = await supabase - .from("plugin_stars") - .select("plugin_id") - .eq("plugin_id", pluginId) - .eq("user_id", userId) - .maybeSingle(); + const { error } = await supabase.rpc("toggle_plugin_star", { + plugin_id_input: pluginId, + }); - const admin = await createAdminClient(); - - if (existing) { - await supabase - .from("plugin_stars") - .delete() - .eq("plugin_id", pluginId) - .eq("user_id", userId); - - await admin.rpc("decrement_star_count", { plugin_id_input: pluginId }); - } else { - await supabase - .from("plugin_stars") - .insert({ plugin_id: pluginId, user_id: userId }); - - await admin.rpc("increment_star_count", { plugin_id_input: pluginId }); + if (error) { + throw new ActionError(`Failed to update star: ${error.message}`); } revalidatePath("/"); diff --git a/apps/cursor/src/actions/update-plugin.ts b/apps/cursor/src/actions/update-plugin.ts index 5955165d..7f7a414a 100644 --- a/apps/cursor/src/actions/update-plugin.ts +++ b/apps/cursor/src/actions/update-plugin.ts @@ -24,6 +24,100 @@ const componentSchema = z.object({ metadata: z.record(z.string(), z.unknown()).optional(), }); +type ComponentInput = z.infer; + +type ExistingComponent = { + type: string; + name: string; + slug: string; + description: string | null; + content: string | null; + metadata: Record | null; + sort_order: number; +}; + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function componentKey(type: string, slug: string): string { + return `${type}:${slug}`; +} + +// Only fields that affect the install payload — cosmetic edits to name, +// description, or sort_order must not trigger a rescan. MCP install links and +// configs can live in metadata, so compare the metadata that will be saved. +function fingerprintComponent(c: { + type: string; + slug?: string | null; + name: string; + content?: string | null; + metadata?: Record | null; +}): string { + const slug = c.slug || slugify(c.name); + return JSON.stringify({ + type: c.type, + slug, + content: c.content ?? "", + metadata: c.metadata ?? {}, + }); +} + +function resolveComponentMetadata( + comp: ComponentInput, + prevByKey: Map, +): Record { + const slug = comp.slug || slugify(comp.name); + const submitted = comp.metadata; + if (submitted && Object.keys(submitted).length > 0) { + return submitted; + } + const prev = prevByKey.get(componentKey(comp.type, slug)); + return prev?.metadata ?? {}; +} + +function installRelevantChanged( + prevComponents: ExistingComponent[], + prevRepository: string | null, + nextComponents: ComponentInput[], + nextRepository: string | null, +): boolean { + if ((prevRepository ?? null) !== (nextRepository ?? null)) { + return true; + } + + if (prevComponents.length !== nextComponents.length) { + return true; + } + + const prevSorted = [...prevComponents] + .sort((a, b) => a.sort_order - b.sort_order) + .map(fingerprintComponent) + .sort(); + const prevByKey = new Map( + prevComponents.map((c) => [ + componentKey(c.type, c.slug || slugify(c.name)), + c, + ]), + ); + const nextSorted = nextComponents + .map((component) => + fingerprintComponent({ + ...component, + metadata: resolveComponentMetadata(component, prevByKey), + }), + ) + .sort(); + + for (let i = 0; i < prevSorted.length; i++) { + if (prevSorted[i] !== nextSorted[i]) return true; + } + return false; +} + export const updatePluginAction = authActionClient .metadata({ actionName: "update-plugin", @@ -62,7 +156,9 @@ export const updatePluginAction = authActionClient const { data: existing, error: fetchError } = await supabase .from("plugins") - .select("id, owner_id, slug") + .select( + "id, owner_id, slug, repository, github_repo_id, active, plugin_components(type, name, slug, description, content, metadata, sort_order)", + ) .eq("id", id) .single(); @@ -76,86 +172,93 @@ export const updatePluginAction = authActionClient ); } - const { success } = await pluginScanLimit(userId); - if (!success) { - throw new ActionError( - "Too many plugin updates in the last hour. Please try again later.", - ); - } + // Source URL is pinned to the parsed GitHub repo so an owner can't keep + // the verified-looking badge while swapping the install payload. + const repositoryLocked = existing.github_repo_id != null; + const effectiveRepository = repositoryLocked + ? (existing.repository ?? null) + : (repository ?? null); - const { error: updateError } = await supabase - .from("plugins") - .update({ - name, - description, - logo: logo || null, - repository: repository || null, - homepage: homepage || null, - keywords: keywords || [], - }) - .eq("id", id); + const prevComponents = (existing.plugin_components ?? + []) as ExistingComponent[]; - if (updateError) { - if (updateError.code === "23505") { + const installChanged = installRelevantChanged( + prevComponents, + existing.repository ?? null, + components, + effectiveRepository, + ); + + const shouldRescan = installChanged; + if (shouldRescan) { + const { success } = await pluginScanLimit(userId); + if (!success) { throw new ActionError( - "A plugin with this name already exists. Please choose a different name.", + "Too many plugin updates in the last hour. Please try again later.", ); } - throw new ActionError( - `Failed to update plugin: ${updateError.message}`, - ); } - const { error: deleteError } = await supabase - .from("plugin_components") - .delete() - .eq("plugin_id", id); + const prevByKey = new Map( + prevComponents.map((c) => [ + componentKey(c.type, c.slug || slugify(c.name)), + c, + ]), + ); - if (deleteError) { - throw new ActionError( - `Failed to update components: ${deleteError.message}`, - ); - } + const componentRows = components.map((comp, i) => ({ + plugin_id: id, + type: comp.type, + name: comp.name, + slug: comp.slug || slugify(comp.name), + description: comp.description || null, + content: comp.content || null, + metadata: resolveComponentMetadata(comp, prevByKey), + sort_order: i, + })); - type ComponentInput = z.infer; - const componentRows = components.map( - (comp: ComponentInput, i: number) => ({ - plugin_id: id, - type: comp.type, - name: comp.name, - slug: - comp.slug || - comp.name - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""), - description: comp.description || null, - content: comp.content || null, - metadata: comp.metadata || {}, - sort_order: i, - }), + const { error: updateError } = await supabase.rpc( + "update_plugin_with_components", + { + p_plugin_id: id, + p_name: name, + p_description: description, + p_logo: logo || null, + p_repository: effectiveRepository, + p_homepage: homepage || null, + p_keywords: keywords || [], + p_components: componentRows, + p_deactivate_for_scan: shouldRescan && existing.active, + }, ); - const { error: compError } = await supabase - .from("plugin_components") - .insert(componentRows); - - if (compError) { + if (updateError) { + if (updateError.code === "23505") { + throw new ActionError( + "A plugin with this name already exists. Please choose a different name.", + ); + } throw new ActionError( - `Failed to save plugin components: ${compError.message}`, + `Failed to update plugin: ${updateError.message}`, ); } - try { - await enqueuePluginScan(id); - kickDrainAfterResponse(); - } catch (queueError) { - console.error("Failed to enqueue plugin scan", queueError); + if (shouldRescan) { + try { + await enqueuePluginScan(id); + kickDrainAfterResponse(); + } catch (queueError) { + console.error("Failed to enqueue plugin scan", queueError); + } } revalidatePath("/"); revalidatePath(`/plugins/${existing.slug}`); - return { slug: existing.slug }; + return { + slug: existing.slug, + rescanQueued: shouldRescan, + repositoryLocked, + }; }, ); diff --git a/apps/cursor/src/components/forms/edit-plugin-form.tsx b/apps/cursor/src/components/forms/edit-plugin-form.tsx index 6ac2cab5..b9ea75e5 100644 --- a/apps/cursor/src/components/forms/edit-plugin-form.tsx +++ b/apps/cursor/src/components/forms/edit-plugin-form.tsx @@ -58,6 +58,7 @@ export function EditPluginForm({ data }: { data: PluginRow }) { const [description, setDescription] = useState(data.description ?? ""); const [logo, setLogo] = useState(data.logo); const [repository, setRepository] = useState(data.repository ?? ""); + const repositoryLocked = data.github_repo_id != null; const [homepage, setHomepage] = useState(data.homepage ?? ""); const [keywords, setKeywords] = useState(data.keywords.join(", ")); const [components, setComponents] = useState( @@ -127,7 +128,9 @@ export function EditPluginForm({ data }: { data: PluginRow }) { name: name.trim(), description: description.trim(), logo, - repository: repository.trim() || null, + repository: repositoryLocked + ? (data.repository ?? null) + : repository.trim() || null, homepage: homepage.trim() || null, keywords: keywords .split(",") @@ -139,7 +142,6 @@ export function EditPluginForm({ data }: { data: PluginRow }) { slug: slugify(c.name), description: c.description.trim() || undefined, content: c.content.trim() || undefined, - metadata: {}, })), }); }; @@ -183,15 +185,23 @@ export function EditPluginForm({ data }: { data: PluginRow }) { setRepository(e.target.value)} + onChange={(e) => !repositoryLocked && setRepository(e.target.value)} + readOnly={repositoryLocked} placeholder="https://github.com/you/your-plugin" - className="border-border placeholder:text-[#878787]" + className="border-border placeholder:text-[#878787] read-only:cursor-not-allowed read-only:opacity-70" /> + {repositoryLocked && ( +

+ This plugin was imported from GitHub, so the Source link is locked + to that repository to keep the displayed source consistent with + the install payload. +

+ )}