From 62883ed01f07c0dc8d07680b23a4ca4eece4241e Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 13 Jun 2026 12:27:22 +0330 Subject: [PATCH] Skill marketplace: publish, install, org-aware listing (+ adversarial-review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/pages/SkillsPage.tsx | 93 +++++++-- .../TeamUp.Modules.Skills/Domain/Skill.cs | 14 +- .../Endpoints/SkillsDtos.cs | 10 + .../Endpoints/SkillsEndpoints.cs | 146 ++++++++++++- .../Indexing/SkillIndexer.cs | 21 +- .../SkillMarketplaceTests.cs | 195 ++++++++++++++++++ 6 files changed, 452 insertions(+), 27 deletions(-) create mode 100644 tests/TeamUp.IntegrationTests/SkillMarketplaceTests.cs diff --git a/client/src/pages/SkillsPage.tsx b/client/src/pages/SkillsPage.tsx index 945e39e..77c0191 100644 --- a/client/src/pages/SkillsPage.tsx +++ b/client/src/pages/SkillsPage.tsx @@ -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([]) - const [marketplace, setMarketplace] = useState([]) + const [marketplace, setMarketplace] = useState([]) const [form, setForm] = useState(null) const [busy, setBusy] = useState(false) @@ -129,7 +135,7 @@ export function SkillsPage() { try { const [lib, market] = await Promise.all([ api.get(`/api/skills?organizationId=${organizationId}`), - api.get(`/api/skills/marketplace`), + api.get(`/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() { ) : (

- 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.

- {marketplace.map((s) => ( - + {marketplace.map(({ skill: s, alreadyInLibrary }) => ( + {s.name} {s.version} + {s.skillKey} {s.summary} - + {s.roles.map((r) => {r})} - + + {s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'} + + {alreadyInLibrary ? ( + In your library + ) : ( + + )} ))} - {marketplace.length === 0 &&

Nothing published yet.

} + {marketplace.length === 0 && ( +

+ Nothing published yet. Publish one of your own skills to share it here. +

+ )}
)} @@ -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 ( @@ -456,6 +511,7 @@ function SkillGroupCard({ )} {current.roles.map((r) => {r})} {current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'} + {isListed && Listed}
{isBuiltin ? ( @@ -463,9 +519,20 @@ function SkillGroupCard({ Fork to my org ) : ( - + <> + + {isListed ? ( + + ) : canPublish ? ( + + ) : null} + )}