Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 76 additions & 7 deletions src/lib/account/packages.functions.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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])
Expand All @@ -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))
Expand All @@ -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 });
Expand All @@ -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<string, unknown> = { 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<string, unknown> = {
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<string, unknown> = {
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 };
});
14 changes: 13 additions & 1 deletion src/lib/account/tokens.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
45 changes: 34 additions & 11 deletions src/routes/account.packages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
},
Expand All @@ -97,7 +105,8 @@ function AccountPackagesPage() {
<p className="mt-2 max-w-2xl text-muted-foreground">
Everything you upload — via the site, the CLI, or an MCP agent — lands here as a{" "}
<strong>private draft</strong>. 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 <strong>submit it for review</strong> from this page; an
admin then approves it after the adversarial gate passes. You can unpublish anytime.
</p>

<div className="mt-8 rounded-2xl border border-border bg-surface">
Expand Down Expand Up @@ -133,6 +142,12 @@ function AccountPackagesPage() {
<Badge className="bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/20 dark:text-emerald-400">
Public · marketplace
</Badge>
) : p.review_status === "pending" ? (
<Badge className="bg-amber-500/15 text-amber-600 hover:bg-amber-500/20 dark:text-amber-400">
Pending admin review
</Badge>
) : p.review_status === "rejected" ? (
<Badge variant="destructive">Rejected</Badge>
) : (
<Badge variant="outline">Private draft</Badge>
)}
Expand Down Expand Up @@ -161,6 +176,10 @@ function AccountPackagesPage() {
>
Unpublish
</Button>
) : p.review_status === "pending" ? (
<Button size="sm" variant="outline" disabled>
Awaiting admin review
</Button>
) : (
<Button
size="sm"
Expand All @@ -169,7 +188,7 @@ function AccountPackagesPage() {
setPrice(String(p.price_credits ?? 0));
}}
>
Publish to marketplace…
Submit for review…
</Button>
)}
</div>
Expand All @@ -180,19 +199,23 @@ function AccountPackagesPage() {
</div>

<p className="mt-6 text-xs text-muted-foreground">
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.
</p>
</main>

<Dialog open={!!target} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Publish to the marketplace?</DialogTitle>
<DialogTitle>Submit for marketplace review?</DialogTitle>
<DialogDescription>
This will make <strong>{target?.name}</strong> 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 <strong>{target?.name}</strong> 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.
</DialogDescription>
</DialogHeader>

Expand Down Expand Up @@ -256,7 +279,7 @@ function AccountPackagesPage() {
});
}}
>
{pubMut.isPending ? "Publishing…" : "Publish to marketplace"}
{pubMut.isPending ? "Submitting…" : "Submit for review"}
</Button>
</DialogFooter>
</DialogContent>
Expand Down
28 changes: 24 additions & 4 deletions src/routes/account.tokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }),
Expand Down Expand Up @@ -82,21 +84,39 @@ function TokensPage() {
{/* Create */}
<div className="mt-8 rounded-2xl border border-border bg-surface p-5">
<div className="font-mono text-xs uppercase tracking-wider text-muted-foreground">New token</div>
<p className="mt-2 text-xs text-muted-foreground">
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.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<input
value={name}
onChange={(e) => 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"
/>
<button
onClick={() => createMut.mutate()}
disabled={createMut.isPending}
className="inline-flex h-10 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-95 disabled:opacity-50"
onClick={() => canMint && createMut.mutate()}
disabled={!canMint}
title={canMint ? "Mint a new token" : "Type a name first"}
className="inline-flex h-10 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:opacity-95 disabled:cursor-not-allowed disabled:opacity-50"
>
{createMut.isPending ? "Minting…" : "Mint token"}
</button>
</div>
{!trimmedName && (
<p className="mt-2 text-xs text-muted-foreground">
Tip: a good name describes where the token will live, e.g.{" "}
<code className="font-mono">claude-desktop</code>,{" "}
<code className="font-mono">ci-deploy</code>, <code className="font-mono">cursor-mac</code>.
</p>
)}
{fresh && (
<div className="mt-4 rounded-lg border border-emerald-500/40 bg-emerald-500/5 p-3 text-sm">
<div className="flex items-center justify-between gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -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.';
Loading