diff --git a/src/lib/account/packages.functions.ts b/src/lib/account/packages.functions.ts index 32d5ce46..d698928d 100644 --- a/src/lib/account/packages.functions.ts +++ b/src/lib/account/packages.functions.ts @@ -1,6 +1,22 @@ import { createServerFn } from "@tanstack/react-start"; import { z } from "zod"; import { requireSupabaseAuth } from "@/integrations/supabase/auth-middleware"; +import { supabaseAdmin as _supabaseAdmin } from "@/integrations/supabase/client.server"; +const supabaseAdmin = _supabaseAdmin as any; + +// Marketplace integrity: authors CANNOT flip their own packages to public. +// They can only submit drafts for admin review. Admins approve via the +// /admin/review flow, which also runs the adversarial gate. Authors can +// always unpublish their own packages (taking content down is safe). +async function isAdmin(supabase: any, userId: string): Promise { + const { data } = await supabase + .from("user_roles") + .select("role") + .eq("user_id", userId) + .eq("role", "admin") + .maybeSingle(); + return !!data; +} export const listMyAuthoredPackages = createServerFn({ method: "GET" }) .middleware([requireSupabaseAuth]) @@ -26,6 +42,18 @@ const PublishInput = z.object({ price_credits: z.number().int().min(0).max(100000).optional(), }); +/** + * Submit a draft package for marketplace review, OR unpublish a previously + * published one. The action taken depends on caller role: + * + * - Author (non-admin), `publish: true` → submit for review (review_status='pending'), + * DOES NOT make the package public. + * - Author, `publish: false` → unpublish + revert to draft. + * - Admin, `publish: true/false` → directly flip is_published (the + * /admin/review screen is still the + * recommended path, but this is the + * programmatic equivalent). + */ export const setMyPackagePublished = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) .inputValidator((d: unknown) => PublishInput.parse(d)) @@ -36,7 +64,7 @@ export const setMyPackagePublished = createServerFn({ method: "POST" }) // Verify ownership before any write. const { data: pkg, error: getErr } = await supabase .from("packages") - .select("id, name, author_id, is_published") + .select("id, name, author_id, is_published, review_status") .eq("id", data.id) .maybeSingle(); if (getErr) throw new Response(getErr.message, { status: 500 }); @@ -47,16 +75,57 @@ export const setMyPackagePublished = createServerFn({ method: "POST" }) const expected = `PUBLISH ${pkg.name}`; if ((data.confirm_phrase ?? "").trim() !== expected) { throw new Response( - `Confirmation required. Type "${expected}" to publish to the marketplace.`, + `Confirmation required. Type "${expected}" to submit for review.`, { status: 400 } ); } - } - const update: Record = { is_published: data.publish }; - if (typeof data.price_credits === "number") update.price_credits = data.price_credits; + const admin = await isAdmin(supabase, userId); + if (!admin) { + // Non-admin authors submit for review; they cannot self-publish. + const update: Record = { + review_status: "pending", + submitted_at: new Date().toISOString(), + is_published: false, + }; + if (typeof data.price_credits === "number") update.price_credits = data.price_credits; + const { error: updErr } = await supabaseAdmin + .from("packages") + .update(update) + .eq("id", data.id); + if (updErr) throw new Response(updErr.message, { status: 500 }); + return { + ok: true, + is_published: false, + review_status: "pending" as const, + submitted_for_review: true, + }; + } + + // Admin path: direct publish. (The /admin/review flow with the + // adversarial gate is preferred, but admins can also flip the flag + // here for hot-fix scenarios.) + const update: Record = { + is_published: true, + review_status: "approved", + reviewed_by: userId, + reviewed_at: new Date().toISOString(), + }; + if (typeof data.price_credits === "number") update.price_credits = data.price_credits; + const { error: updErr } = await supabaseAdmin + .from("packages") + .update(update) + .eq("id", data.id); + if (updErr) throw new Response(updErr.message, { status: 500 }); + return { ok: true, is_published: true, review_status: "approved" as const }; + } - const { error: updErr } = await supabase.from("packages").update(update).eq("id", data.id); + // Unpublish — always allowed for the author. Reset review state to draft + // so a future re-publish goes through review again. + const { error: updErr } = await supabase + .from("packages") + .update({ is_published: false, review_status: "draft" }) + .eq("id", data.id); if (updErr) throw new Response(updErr.message, { status: 500 }); - return { ok: true, is_published: data.publish }; + return { ok: true, is_published: false, review_status: "draft" as const }; }); diff --git a/src/lib/account/tokens.functions.ts b/src/lib/account/tokens.functions.ts index c8847897..2f1cb8ff 100644 --- a/src/lib/account/tokens.functions.ts +++ b/src/lib/account/tokens.functions.ts @@ -18,7 +18,19 @@ export const listMcpTokens = createServerFn({ method: "GET" }) export const createMcpToken = createServerFn({ method: "POST" }) .middleware([requireSupabaseAuth]) - .inputValidator((d: unknown) => z.object({ name: z.string().min(1).max(80) }).parse(d)) + .inputValidator((d: unknown) => + z + .object({ + // Trim and require a real name — empty / whitespace-only strings used to + // silently default to "Default" in the UI, which left users unable to + // tell their tokens apart in the revoke list. Force a deliberate label. + name: z + .string() + .transform((s) => s.trim()) + .pipe(z.string().min(1, "name is required").max(80)), + }) + .parse(d), + ) .handler(async ({ data, context }) => { const { supabase: _sbCtx, userId } = context as any; const supabase = _sbCtx as any; diff --git a/src/routes/account.packages.tsx b/src/routes/account.packages.tsx index 8abce5f7..266a879e 100644 --- a/src/routes/account.packages.tsx +++ b/src/routes/account.packages.tsx @@ -75,8 +75,16 @@ function AccountPackagesPage() { confirm_phrase?: string; price_credits?: number; }) => setPub({ data: input }), - onSuccess: (r) => { - toast.success(r.is_published ? "Listed on the marketplace." : "Reverted to private draft."); + onSuccess: (r: any) => { + if (r.submitted_for_review) { + toast.success( + "Submitted for review. An admin will approve before it appears on the marketplace.", + ); + } else if (r.is_published) { + toast.success("Listed on the marketplace."); + } else { + toast.success("Reverted to private draft."); + } qc.invalidateQueries({ queryKey: ["account", "my-packages"] }); closeDialog(); }, @@ -97,7 +105,8 @@ function AccountPackagesPage() {

Everything you upload — via the site, the CLI, or an MCP agent — lands here as a{" "} private draft. Drafts are invisible to other users. To list a skill on - the public marketplace you must publish it explicitly from this page. + the public marketplace you must submit it for review from this page; an + admin then approves it after the adversarial gate passes. You can unpublish anytime.

@@ -133,6 +142,12 @@ function AccountPackagesPage() { Public · marketplace + ) : p.review_status === "pending" ? ( + + Pending admin review + + ) : p.review_status === "rejected" ? ( + Rejected ) : ( Private draft )} @@ -161,6 +176,10 @@ function AccountPackagesPage() { > Unpublish + ) : p.review_status === "pending" ? ( + ) : ( )}
@@ -180,19 +199,23 @@ function AccountPackagesPage() {

- Tip: publishing requires typing a confirmation phrase. Agents (Claude, Cursor, Codex…) - cannot list a skill publicly through MCP — only you, from this page, can. + Publishing to the marketplace requires admin approval. From here you submit a + draft for review; an admin runs the adversarial gate and either approves it + (making it public) or sends it back with notes. Agents (Claude, Cursor, Codex…) + cannot list a skill publicly through MCP — only you, from this page, can + submit, and only an admin can approve.

!open && closeDialog()}> - Publish to the marketplace? + Submit for marketplace review? - This will make {target?.name} visible to every user on the public - marketplace, search, leaderboards, and trust feeds. You can unpublish at any time, - but downloads and execution reports collected while public are kept. + This sends {target?.name} to the admin review queue. After the + adversarial gate runs and an admin approves, it becomes visible on the public + marketplace, search, leaderboards, and trust feeds. Until then it stays a private + draft. You can withdraw or unpublish at any time. @@ -256,7 +279,7 @@ function AccountPackagesPage() { }); }} > - {pubMut.isPending ? "Publishing…" : "Publish to marketplace"} + {pubMut.isPending ? "Submitting…" : "Submit for review"} diff --git a/src/routes/account.tokens.tsx b/src/routes/account.tokens.tsx index ce7e8b69..cde4a638 100644 --- a/src/routes/account.tokens.tsx +++ b/src/routes/account.tokens.tsx @@ -34,14 +34,16 @@ function TokensPage() { const PLACEHOLDER = "sas_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; const q = useQuery({ queryKey: ["mcp-tokens"], queryFn: () => list() }); + const trimmedName = name.trim(); const createMut = useMutation({ - mutationFn: () => create({ data: { name: name || "Default" } }), + mutationFn: () => create({ data: { name: trimmedName } }), onSuccess: (r) => { setFresh(r.token); setName(""); qc.invalidateQueries({ queryKey: ["mcp-tokens"] }); }, }); + const canMint = trimmedName.length > 0 && !createMut.isPending; const revokeMut = useMutation({ mutationFn: (id: string) => revoke({ data: { id } }), onSuccess: () => qc.invalidateQueries({ queryKey: ["mcp-tokens"] }), @@ -82,21 +84,39 @@ function TokensPage() { {/* Create */}
New token
+

+ Give the token a name so you can recognise it later (e.g. which device or + agent it's used from). Tokens without a name can't be minted — you'll thank + us when you need to revoke one. +

setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && canMint) createMut.mutate(); + }} placeholder="e.g. cursor-laptop" + aria-label="Token name" + required className="h-10 flex-1 rounded-md border border-border bg-background px-3 text-sm" />
+ {!trimmedName && ( +

+ Tip: a good name describes where the token will live, e.g.{" "} + claude-desktop,{" "} + ci-deploy, cursor-mac. +

+ )} {fresh && (
diff --git a/supabase/migrations/20260523000000_marketplace_publish_admin_only.sql b/supabase/migrations/20260523000000_marketplace_publish_admin_only.sql new file mode 100644 index 00000000..26f30cf9 --- /dev/null +++ b/supabase/migrations/20260523000000_marketplace_publish_admin_only.sql @@ -0,0 +1,79 @@ +-- Marketplace integrity — admin-only publication +-- +-- Until now, the only thing stopping a non-admin author from flipping +-- packages.is_published = true was the application-layer check in +-- setMyPackagePublished. Anyone hitting the table directly with a +-- service-role token, or any new code path that forgot the check, could +-- bypass admin review and put a package on the public marketplace. +-- +-- This migration moves the rule into the database via a trigger so it +-- holds no matter who writes: +-- * Authors may set is_published = false freely (unpublish). +-- * Setting is_published = true requires the calling role to be admin +-- (checked via public.user_roles), OR to be the service-role bypass +-- used by the /admin/review server flow (which always runs the +-- adversarial gate before flipping). +-- +-- We deliberately do NOT enforce the adversarial gate inside the +-- trigger — that logic stays in TypeScript so admins can read its +-- output. The trigger is the floor; the app layer is the policy. + +CREATE OR REPLACE FUNCTION public.enforce_admin_only_publish() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +DECLARE + _flipping_to_public BOOLEAN := ( + TG_OP = 'UPDATE' + AND COALESCE(OLD.is_published, FALSE) = FALSE + AND COALESCE(NEW.is_published, FALSE) = TRUE + ) OR ( + TG_OP = 'INSERT' AND COALESCE(NEW.is_published, FALSE) = TRUE + ); + _uid UUID := auth.uid(); + _is_admin BOOLEAN := FALSE; +BEGIN + IF NOT _flipping_to_public THEN + RETURN NEW; + END IF; + + -- service_role (used by SECURITY DEFINER admin RPCs and server-side + -- admin functions running with the service key) bypasses the check. + -- All user-facing surfaces hit the table as the authenticated role, + -- so this only carves out the legitimate admin server path. + IF current_setting('request.jwt.claim.role', TRUE) = 'service_role' + OR current_user = 'service_role' + OR session_user = 'service_role' THEN + RETURN NEW; + END IF; + + IF _uid IS NULL THEN + RAISE EXCEPTION 'marketplace_publish_forbidden: must be signed in' + USING ERRCODE = '42501'; + END IF; + + SELECT TRUE INTO _is_admin + FROM public.user_roles + WHERE user_id = _uid AND role = 'admin' + LIMIT 1; + + IF NOT COALESCE(_is_admin, FALSE) THEN + RAISE EXCEPTION + 'marketplace_publish_forbidden: only admins can list packages on the public marketplace. Submit for review from /account/packages.' + USING ERRCODE = '42501'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_enforce_admin_only_publish ON public.packages; +CREATE TRIGGER trg_enforce_admin_only_publish + BEFORE INSERT OR UPDATE OF is_published ON public.packages + FOR EACH ROW + EXECUTE FUNCTION public.enforce_admin_only_publish(); + +COMMENT ON FUNCTION public.enforce_admin_only_publish IS + 'Blocks non-admin sessions from setting packages.is_published = true. Service-role contexts (admin RPCs) bypass. Authors must submit drafts for admin review.';