Skill marketplace: publish, install, org-aware listing (+ adversarial-review fixes)

Orgs can now share skills across the tenant boundary — the next step after the per-org library.

Endpoints (all ManageSkills-gated + audited):
- POST /{key}/publish — list one of your published versions on the marketplace (Visibility→Public;
  only a Published/golden-tested skill may be listed). POST /{key}/unpublish reverses it.
- POST /install — copy a publicly-listed skill (by row id) into your org as a private Installed
  copy; rejects installing your own skill and duplicate (org+key+version) installs.
- GET /marketplace?organizationId= — other orgs' Authored+Public+Published skills (yours excluded),
  each flagged whether that exact (key, version) is already in your library.
- SkillSummary now carries Id (install targets a specific source row). Authored skills default to
  private — listing is an explicit publish step, never a side effect of authoring.

UI (Skills page): a Marketplace tab with Install / "In your library"; Publish / Unlist on your own
published skills; a "Listed" badge.

Fixes from the adversarial review (4 confirmed findings, all addressed):
- HIGH — Public⟹Published is now a domain invariant (Skill.Index forces PrivateToOrg whenever the
  re-derived status isn't Published), so re-authoring a listed version without golden tests can no
  longer leave it Public+Draft or decouple the marketplace gate from the eval gate.
- MEDIUM — install now uses an insert-only indexer path so the (org,key,version) unique index is the
  source of truth: a race with a concurrent install/author becomes a clean 409, never an in-place
  clobber of an existing row's content/ownership.
- MEDIUM/LOW — AlreadyInLibrary is computed per (key, version) to match the install conflict rule, so
  a newer, not-yet-owned version of a key you already hold still shows as installable.

Verified: ArchitectureTests 8/8, IntegrationTests 47/47 (SkillMarketplaceTests: publish gate, own-org
exclusion, cross-org list→install→private copy, duplicate 409, per-version flag, Public⟹Published
invariant, Member 403), client build green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 12:27:22 +03:30
parent ae7e0f6bc1
commit 62883ed01f
6 changed files with 452 additions and 27 deletions
+80 -13
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react'
import { BookMarked, Download, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
import { toast } from 'sonner'
import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge'
@@ -32,6 +32,7 @@ interface GoldenTest {
}
interface SkillSummary {
id: string
skillKey: string
name: string
version: string
@@ -56,6 +57,11 @@ interface SkillDetail {
body: string
}
interface MarketplaceEntry {
skill: SkillSummary
alreadyInLibrary: boolean
}
type Mode = 'new' | 'version' | 'edit'
interface FormState {
@@ -92,7 +98,7 @@ const emptyForm = (): FormState => ({
outputs: '',
tools: '',
context: '',
visibility: 'public',
visibility: 'private',
minTier: 'free',
body: '',
actions: [],
@@ -120,7 +126,7 @@ export function SkillsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
const [skills, setSkills] = useState<SkillSummary[]>([])
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
const [form, setForm] = useState<FormState | null>(null)
const [busy, setBusy] = useState(false)
@@ -129,7 +135,7 @@ export function SkillsPage() {
try {
const [lib, market] = await Promise.all([
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
api.get<SkillSummary[]>(`/api/skills/marketplace`),
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
])
setSkills(lib)
setMarketplace(market)
@@ -193,6 +199,32 @@ export function SkillsPage() {
}
}
const setListed = async (key: string, version: string, listed: boolean) => {
setBusy(true)
try {
await api.post(`/api/skills/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version })
toast.success(listed ? `Published ${key}@${version} to the marketplace.` : `Unlisted ${key}@${version}.`)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const install = async (sourceSkillId: string, name: string) => {
setBusy(true)
try {
await api.post('/api/skills/install', { organizationId, sourceSkillId })
toast.success(`Installed ${name} into your library.`)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const save = async () => {
if (!form) return
setBusy(true)
@@ -260,6 +292,8 @@ export function SkillsPage() {
onNewVersion={(v) => openForm(key, v, 'version')}
onEdit={(v) => openForm(key, v, 'edit')}
onFork={(v) => fork(key, v)}
onPublish={(v) => setListed(key, v, true)}
onUnpublish={(v) => setListed(key, v, false)}
/>
))}
{groups.length === 0 && (
@@ -269,23 +303,38 @@ export function SkillsPage() {
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Public skills shared by other organizations. One-click install lands in the next step.
Published skills shared by other organizations. Install a copy into your library — it lands private,
so you can edit or version it freely.
</p>
{marketplace.map((s) => (
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
{marketplace.map(({ skill: s, alreadyInLibrary }) => (
<Card key={s.id}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{s.name} <Badge variant="outline">{s.version}</Badge>
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
</CardTitle>
<CardDescription>{s.summary}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
<CardContent className="flex flex-wrap items-center gap-2">
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
<span className="text-xs text-muted-foreground">
{s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'}
</span>
{alreadyInLibrary ? (
<Badge variant="secondary" className="ml-auto">In your library</Badge>
) : (
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(s.id, s.name)}>
<Download data-icon="inline-start" /> Install
</Button>
)}
</CardContent>
</Card>
))}
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
{marketplace.length === 0 && (
<p className="text-sm text-muted-foreground">
Nothing published yet. Publish one of your own skills to share it here.
</p>
)}
</div>
)}
</div>
@@ -420,16 +469,22 @@ function SkillGroupCard({
onNewVersion,
onEdit,
onFork,
onPublish,
onUnpublish,
}: {
versions: SkillSummary[]
busy: boolean
onNewVersion: (version: string) => void
onEdit: (version: string) => void
onFork: (version: string) => void
onPublish: (version: string) => void
onUnpublish: (version: string) => void
}) {
const [selected, setSelected] = useState(versions[0].version)
const current = versions.find((v) => v.version === selected) ?? versions[0]
const isBuiltin = current.origin === 'Builtin'
const isListed = current.visibility === 'Public'
const canPublish = !isBuiltin && current.status === 'Published'
return (
<Card>
@@ -456,6 +511,7 @@ function SkillGroupCard({
)}
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
<div className="ml-auto flex items-center gap-2">
{isBuiltin ? (
@@ -463,9 +519,20 @@ function SkillGroupCard({
<GitFork data-icon="inline-start" /> Fork to my org
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
<Pencil data-icon="inline-start" /> Edit
</Button>
<>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
<Pencil data-icon="inline-start" /> Edit
</Button>
{isListed ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onUnpublish(current.version)}>
Unlist
</Button>
) : canPublish ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onPublish(current.version)}>
<Upload data-icon="inline-start" /> Publish
</Button>
) : null}
</>
)}
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
<Plus data-icon="inline-start" /> New version