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:
@@ -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>
|
||||
{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
|
||||
|
||||
@@ -75,9 +75,12 @@ internal sealed class Skill : Entity
|
||||
GoldenTests = manifest.GoldenTests
|
||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||
.ToList();
|
||||
Visibility = ParseVisibility(manifest.Visibility);
|
||||
MinTier = ParseTier(manifest.MinTier);
|
||||
Status = status;
|
||||
// Invariant: only a published (golden-tested) skill may be Public. This holds on every
|
||||
// (re)projection, so re-authoring a listed version without golden tests can't leave it
|
||||
// Public+Draft — the marketplace/listing gate can never be decoupled from the eval gate.
|
||||
Visibility = status == SkillStatus.Published ? ParseVisibility(manifest.Visibility) : SkillVisibility.PrivateToOrg;
|
||||
MinTier = ParseTier(manifest.MinTier);
|
||||
Body = body;
|
||||
ContentHash = contentHash;
|
||||
SourceRepo = sourceRepo;
|
||||
@@ -87,6 +90,13 @@ internal sealed class Skill : Entity
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
/// <summary>Lists/unlists this version on the marketplace (Public ⇔ PrivateToOrg).</summary>
|
||||
public void SetVisibility(SkillVisibility visibility, DateTimeOffset nowUtc)
|
||||
{
|
||||
Visibility = visibility;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
|
||||
|
||||
private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch
|
||||
|
||||
@@ -5,6 +5,7 @@ internal sealed record ActionDto(string Name, string Risk, string? Description =
|
||||
internal sealed record GoldenTestDto(string Input, string Expected);
|
||||
|
||||
internal sealed record SkillSummary(
|
||||
Guid Id,
|
||||
string SkillKey,
|
||||
string Name,
|
||||
string Version,
|
||||
@@ -54,3 +55,12 @@ internal sealed record AuthorSkillRequest(
|
||||
|
||||
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
|
||||
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
|
||||
|
||||
/// <summary>List/unlist one of an org's own (published) skill versions on the marketplace.</summary>
|
||||
internal sealed record PublishSkillRequest(Guid OrganizationId, string Version);
|
||||
|
||||
/// <summary>Install a public marketplace skill (by its row id) into an org as an Installed copy.</summary>
|
||||
internal sealed record InstallSkillRequest(Guid OrganizationId, Guid SourceSkillId);
|
||||
|
||||
/// <summary>A marketplace listing plus whether the requesting org already holds that skill key.</summary>
|
||||
internal sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary);
|
||||
|
||||
@@ -27,6 +27,9 @@ internal static class SkillsEndpoints
|
||||
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
||||
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
||||
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
||||
group.MapPost("/{key}/publish", PublishSkill).RequireAuthorization();
|
||||
group.MapPost("/{key}/unpublish", UnpublishSkill).RequireAuthorization();
|
||||
group.MapPost("/install", InstallSkill).RequireAuthorization();
|
||||
group.MapPost("/index", IndexSkill).RequireAuthorization();
|
||||
group.MapPost("/sync", Sync).RequireAuthorization();
|
||||
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
|
||||
@@ -102,14 +105,143 @@ internal static class SkillsEndpoints
|
||||
|
||||
// Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
|
||||
// Publishing controls and install-into-your-org land in the next step.
|
||||
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
|
||||
// The marketplace: published skills other orgs have listed publicly. Excludes your own skills
|
||||
// and flags any whose key already exists in your library (installed or authored).
|
||||
private static async Task<IResult> Marketplace(
|
||||
Guid organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var listed = await db.Skills
|
||||
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
|
||||
.Where(s => s.Origin == SkillOrigin.Authored
|
||||
&& s.Visibility == SkillVisibility.Public
|
||||
&& s.Status == SkillStatus.Published
|
||||
&& s.OrganizationId != null
|
||||
&& s.OrganizationId != organizationId)
|
||||
.OrderBy(s => s.SkillKey)
|
||||
.ThenByDescending(s => s.Version)
|
||||
.ToListAsync(ct);
|
||||
return Results.Ok(listed.Select(ToSummary).ToList());
|
||||
|
||||
// Flag by (key, version) — matching the install conflict rule — so a newer, not-yet-owned
|
||||
// version of a key you already hold still shows as installable.
|
||||
var owned = (await db.Skills
|
||||
.Where(s => s.OrganizationId == organizationId)
|
||||
.Select(s => new { s.SkillKey, s.Version })
|
||||
.ToListAsync(ct))
|
||||
.Select(s => (s.SkillKey, s.Version))
|
||||
.ToHashSet();
|
||||
|
||||
return Results.Ok(listed
|
||||
.Select(s => new MarketplaceEntry(ToSummary(s), owned.Contains((s.SkillKey, s.Version))))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
private static async Task<IResult> PublishSkill(
|
||||
string key, PublishSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, SkillsDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||
s => s.OrganizationId == request.OrganizationId && s.SkillKey == key && s.Version == request.Version, ct);
|
||||
if (skill is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
// Only golden-tested (published) skills may be listed — the marketplace inherits the eval gate.
|
||||
if (skill.Status != SkillStatus.Published)
|
||||
{
|
||||
return Results.BadRequest("Only a published (golden-tested) skill can be listed on the marketplace.");
|
||||
}
|
||||
|
||||
skill.SetVisibility(SkillVisibility.Public, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.published", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
return Results.Ok(ToDetail(skill));
|
||||
}
|
||||
|
||||
private static async Task<IResult> UnpublishSkill(
|
||||
string key, PublishSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, SkillsDbContext db, TimeProvider clock, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||
s => s.OrganizationId == request.OrganizationId && s.SkillKey == key && s.Version == request.Version, ct);
|
||||
if (skill is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
skill.SetVisibility(SkillVisibility.PrivateToOrg, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(ct);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.unpublished", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
return Results.Ok(ToDetail(skill));
|
||||
}
|
||||
|
||||
// Copy a publicly-listed skill into the caller's org as a private Installed copy.
|
||||
private static async Task<IResult> InstallSkill(
|
||||
InstallSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||
IAuditLog audit, SkillsDbContext db, SkillIndexer indexer, CancellationToken ct)
|
||||
{
|
||||
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||
{
|
||||
return Results.Forbid();
|
||||
}
|
||||
|
||||
var source = await db.Skills.FirstOrDefaultAsync(s => s.Id == request.SourceSkillId, ct);
|
||||
if (source is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
if (source.Origin != SkillOrigin.Authored
|
||||
|| source.Visibility != SkillVisibility.Public
|
||||
|| source.Status != SkillStatus.Published)
|
||||
{
|
||||
return Results.BadRequest("That skill is not published to the marketplace.");
|
||||
}
|
||||
|
||||
if (source.OrganizationId == request.OrganizationId)
|
||||
{
|
||||
return Results.BadRequest("That skill already belongs to your organization.");
|
||||
}
|
||||
|
||||
if (await db.Skills.AnyAsync(
|
||||
s => s.OrganizationId == request.OrganizationId && s.SkillKey == source.SkillKey && s.Version == source.Version, ct))
|
||||
{
|
||||
return Results.Conflict("This skill version is already in your library.");
|
||||
}
|
||||
|
||||
var manifest = ToManifest(source);
|
||||
manifest.Visibility = "private"; // an installed copy is private until the installer chooses to publish it
|
||||
try
|
||||
{
|
||||
// insertOnly: the DB unique index is the source of truth, so a race with a concurrent
|
||||
// install/author of the same (org, key, version) becomes a clean 409, never a clobber.
|
||||
var skill = await indexer.IndexAsync(
|
||||
manifest, source.Body, SkillOwnership.Installed(request.OrganizationId, user.MemberId),
|
||||
insertOnly: true, cancellationToken: ct);
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.installed", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
return Results.Ok(ToDetail(skill));
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
return Results.Conflict("This skill version is already in your library.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetSkill(
|
||||
@@ -150,7 +282,7 @@ internal static class SkillsEndpoints
|
||||
|
||||
var manifest = ToManifest(request);
|
||||
var skill = await indexer.IndexAsync(
|
||||
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
||||
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), cancellationToken: ct);
|
||||
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
@@ -183,7 +315,7 @@ internal static class SkillsEndpoints
|
||||
}
|
||||
|
||||
var skill = await indexer.IndexAsync(
|
||||
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
||||
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), cancellationToken: ct);
|
||||
|
||||
await audit.WriteAsync(
|
||||
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||
@@ -231,7 +363,8 @@ internal static class SkillsEndpoints
|
||||
.ToList(),
|
||||
Tools = request.Tools ?? [],
|
||||
Context = request.Context ?? [],
|
||||
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
|
||||
// Authored skills are private by default; listing on the marketplace is an explicit publish step.
|
||||
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "private" : request.Visibility,
|
||||
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
|
||||
GoldenTests = (request.GoldenTests ?? [])
|
||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||
@@ -258,6 +391,7 @@ internal static class SkillsEndpoints
|
||||
};
|
||||
|
||||
private static SkillSummary ToSummary(Skill skill) => new(
|
||||
skill.Id,
|
||||
skill.SkillKey,
|
||||
skill.Name,
|
||||
skill.Version,
|
||||
|
||||
@@ -34,19 +34,25 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
||||
{
|
||||
var parsed = SkillMarkdownParser.Parse(content);
|
||||
var contentHash = Hash(content);
|
||||
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
|
||||
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, insertOnly: false, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
|
||||
/// <summary>
|
||||
/// Index a manifest authored/installed in-app — same pipeline, org-owned. When
|
||||
/// <paramref name="insertOnly"/> is true the row must not already exist: it is always inserted
|
||||
/// and a colliding (org, key, version) trips the unique index (DbUpdateException) rather than
|
||||
/// silently overwriting — the install path uses this so a race can't clobber an existing skill.
|
||||
/// </summary>
|
||||
public Task<Skill> IndexAsync(
|
||||
SkillManifest manifest,
|
||||
string body,
|
||||
SkillOwnership ownership,
|
||||
bool insertOnly = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
// The content hash spans the structured manifest + body so re-authoring changes it.
|
||||
var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Summary}\n{string.Join(',', manifest.Roles)}\n{body}";
|
||||
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, cancellationToken);
|
||||
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, insertOnly, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Skill> IndexAsync(
|
||||
@@ -57,6 +63,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
||||
string? sourceRepo,
|
||||
string? sourcePath,
|
||||
string? sourceCommit,
|
||||
bool insertOnly,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
@@ -71,7 +78,9 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
||||
? SkillStatus.Published
|
||||
: SkillStatus.Draft;
|
||||
|
||||
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||
var skill = insertOnly
|
||||
? null
|
||||
: await db.Skills.FirstOrDefaultAsync(
|
||||
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
|
||||
cancellationToken);
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TeamUp.Modules.Skills.Domain;
|
||||
using TeamUp.Modules.Skills.Indexing;
|
||||
using Xunit;
|
||||
|
||||
namespace TeamUp.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// The skill marketplace, end to end: the publish gate (only golden-tested may list), own-org
|
||||
/// exclusion, another org's published skill listed and installed as a private copy, the duplicate
|
||||
/// conflict, and ManageSkills authorization. One flow per class — bootstrap is single-use.
|
||||
/// </summary>
|
||||
public sealed class SkillMarketplaceTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||
{
|
||||
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
|
||||
|
||||
private sealed record AuthResponse(string Token, Guid MemberId);
|
||||
|
||||
private sealed record InviteResponse(Guid InvitationId, string Token);
|
||||
|
||||
private sealed record ActionDto(string Name, string Risk, string? Description);
|
||||
|
||||
private sealed record GoldenTestDto(string Input, string Expected);
|
||||
|
||||
private sealed record SkillSummary(
|
||||
Guid Id, string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
|
||||
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
|
||||
int GoldenTestCount, List<ActionDto> Actions);
|
||||
|
||||
private sealed record SkillDetail(
|
||||
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
|
||||
List<string> Context, List<GoldenTestDto> GoldenTests, string Body);
|
||||
|
||||
private sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary);
|
||||
|
||||
[Fact]
|
||||
public async Task Publishes_lists_installs_with_gate_own_exclusion_and_authorization()
|
||||
{
|
||||
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||
using var anon = factory.CreateClient();
|
||||
|
||||
var orgA = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
||||
{
|
||||
organizationName = "OrgA",
|
||||
ownerEmail = "owner@alia.test",
|
||||
ownerDisplayName = "Owner",
|
||||
ownerPassword = "Passw0rd!",
|
||||
});
|
||||
using var client = Authed(factory, orgA.Token);
|
||||
|
||||
// Gate: a Draft (no golden test) cannot be listed on the marketplace.
|
||||
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "draft-skill", goldenTested: false));
|
||||
var cantPublish = await client.PostAsJsonAsync("/api/skills/draft-skill/publish",
|
||||
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, cantPublish.StatusCode);
|
||||
|
||||
// A published skill lists fine; visibility flips to Public, but it never shows in its own org's marketplace.
|
||||
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "prod-skill", goldenTested: true));
|
||||
var published = await PostOk<SkillDetail>(client, "/api/skills/prod-skill/publish",
|
||||
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||
Assert.Equal("Public", published.Skill.Visibility);
|
||||
|
||||
var ownMarket = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||
Assert.DoesNotContain(ownMarket!, e => e.Skill.SkillKey == "prod-skill");
|
||||
|
||||
// You can't install your own skill.
|
||||
var prod = await client.GetFromJsonAsync<List<SkillDetail>>(
|
||||
$"/api/skills/prod-skill?organizationId={orgA.OrganizationId}");
|
||||
var installOwn = await client.PostAsJsonAsync("/api/skills/install",
|
||||
new { organizationId = orgA.OrganizationId, sourceSkillId = prod!.Single().Skill.Id });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, installOwn.StatusCode);
|
||||
|
||||
// Another org's published skill (no second-org onboarding yet — seed it via the indexer).
|
||||
var orgB = Guid.NewGuid();
|
||||
var sourceId = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief");
|
||||
|
||||
// Org A sees org B's skill on the marketplace, not yet in its library.
|
||||
var market = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||
var entry = Assert.Single(market!, e => e.Skill.SkillKey == "research-brief");
|
||||
Assert.False(entry.AlreadyInLibrary);
|
||||
Assert.Equal(sourceId, entry.Skill.Id);
|
||||
|
||||
// Installing lands a private, Installed copy in org A's namespace.
|
||||
var installed = await PostOk<SkillDetail>(client, "/api/skills/install",
|
||||
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
|
||||
Assert.Equal("Installed", installed.Skill.Origin);
|
||||
Assert.Equal(orgA.OrganizationId, installed.Skill.OrganizationId);
|
||||
Assert.Equal("PrivateToOrg", installed.Skill.Visibility);
|
||||
Assert.Equal("research-brief", installed.Skill.SkillKey);
|
||||
|
||||
// Now it's in the library (flagged), and re-installing the same version is a conflict.
|
||||
var market2 = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||
Assert.True(Assert.Single(market2!, e => e.Skill.SkillKey == "research-brief").AlreadyInLibrary);
|
||||
|
||||
var dup = await client.PostAsJsonAsync("/api/skills/install",
|
||||
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
|
||||
Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode);
|
||||
|
||||
// A newer version of an already-installed key stays installable — the flag is per (key, version).
|
||||
var sourceV2 = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief", "2.0.0");
|
||||
var market3 = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||
Assert.False(Assert.Single(market3!, e => e.Skill.Id == sourceV2).AlreadyInLibrary);
|
||||
Assert.True(Assert.Single(market3!, e => e.Skill.SkillKey == "research-brief" && e.Skill.Version == "1.0.0").AlreadyInLibrary);
|
||||
|
||||
// Invariant: re-authoring a listed version without golden tests cannot leave it Public.
|
||||
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: true));
|
||||
await PostOk<SkillDetail>(client, "/api/skills/regate-skill/publish",
|
||||
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||
var degated = await PostOk<SkillDetail>(client, "/api/skills/authored",
|
||||
Authored(orgA.OrganizationId, "regate-skill", goldenTested: false, visibility: "public"));
|
||||
Assert.Equal("Draft", degated.Skill.Status);
|
||||
Assert.Equal("PrivateToOrg", degated.Skill.Visibility);
|
||||
|
||||
// A plain Member cannot publish/unpublish (ManageSkills).
|
||||
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
||||
{
|
||||
email = "member@alia.test",
|
||||
scopeType = "Organization",
|
||||
scopeId = orgA.OrganizationId,
|
||||
role = "Member",
|
||||
organizationId = orgA.OrganizationId,
|
||||
});
|
||||
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
|
||||
new { token = invite.Token, displayName = "Member", password = "Passw0rd!" });
|
||||
using var memberClient = Authed(factory, member.Token);
|
||||
var forbidden = await memberClient.PostAsJsonAsync("/api/skills/prod-skill/unpublish",
|
||||
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
private static object Authored(Guid organizationId, string key, bool goldenTested, string visibility = "private") => new
|
||||
{
|
||||
organizationId,
|
||||
skillKey = key,
|
||||
name = key,
|
||||
version = "1.0.0",
|
||||
summary = "x",
|
||||
roles = new[] { "engineer" },
|
||||
inputs = (string?)null,
|
||||
outputs = (string?)null,
|
||||
actions = Array.Empty<ActionDto>(),
|
||||
tools = Array.Empty<string>(),
|
||||
context = Array.Empty<string>(),
|
||||
visibility,
|
||||
minTier = "free",
|
||||
body = "Do the thing.",
|
||||
goldenTests = goldenTested
|
||||
? new[] { new GoldenTestDto("in", "out") }
|
||||
: Array.Empty<GoldenTestDto>(),
|
||||
};
|
||||
|
||||
// No public second-org onboarding yet, so seed the "other org's" published skill directly through
|
||||
// the same indexer the authoring endpoint uses (InternalsVisibleTo grants the test access).
|
||||
private static async Task<Guid> SeedPublishedSkillAsync(
|
||||
TeamUpWebFactory factory, Guid orgId, string key, string name, string version = "1.0.0")
|
||||
{
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var indexer = scope.ServiceProvider.GetRequiredService<SkillIndexer>();
|
||||
var manifest = new SkillManifest
|
||||
{
|
||||
Id = key,
|
||||
Name = name,
|
||||
Version = version,
|
||||
Summary = "A shared, published skill.",
|
||||
Roles = ["analyst"],
|
||||
Visibility = "public",
|
||||
GoldenTests = [new GoldenExample { Input = "a topic", Expected = "a brief" }],
|
||||
};
|
||||
var skill = await indexer.IndexAsync(manifest, "Write a research brief.", SkillOwnership.Authored(orgId, Guid.NewGuid()));
|
||||
return skill.Id;
|
||||
}
|
||||
|
||||
private static HttpClient Authed(TeamUpWebFactory factory, string token)
|
||||
{
|
||||
var client = factory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<T> PostOk<T>(HttpClient client, string url, object body)
|
||||
{
|
||||
var response = await client.PostAsJsonAsync(url, body);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var value = await response.Content.ReadFromJsonAsync<T>();
|
||||
Assert.NotNull(value);
|
||||
return value!;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user