Merge: skill marketplace (publish/install across orgs) + review hardening

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
6 changed files with 452 additions and 27 deletions
+77 -10
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from 'react' 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 { toast } from 'sonner'
import { AppShell } from '@/components/AppShell' import { AppShell } from '@/components/AppShell'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
@@ -32,6 +32,7 @@ interface GoldenTest {
} }
interface SkillSummary { interface SkillSummary {
id: string
skillKey: string skillKey: string
name: string name: string
version: string version: string
@@ -56,6 +57,11 @@ interface SkillDetail {
body: string body: string
} }
interface MarketplaceEntry {
skill: SkillSummary
alreadyInLibrary: boolean
}
type Mode = 'new' | 'version' | 'edit' type Mode = 'new' | 'version' | 'edit'
interface FormState { interface FormState {
@@ -92,7 +98,7 @@ const emptyForm = (): FormState => ({
outputs: '', outputs: '',
tools: '', tools: '',
context: '', context: '',
visibility: 'public', visibility: 'private',
minTier: 'free', minTier: 'free',
body: '', body: '',
actions: [], actions: [],
@@ -120,7 +126,7 @@ export function SkillsPage() {
const organizationId = useAuth((s) => s.organizationId) const organizationId = useAuth((s) => s.organizationId)
const [tab, setTab] = useState<'library' | 'marketplace'>('library') const [tab, setTab] = useState<'library' | 'marketplace'>('library')
const [skills, setSkills] = useState<SkillSummary[]>([]) const [skills, setSkills] = useState<SkillSummary[]>([])
const [marketplace, setMarketplace] = useState<SkillSummary[]>([]) const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
const [form, setForm] = useState<FormState | null>(null) const [form, setForm] = useState<FormState | null>(null)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -129,7 +135,7 @@ export function SkillsPage() {
try { try {
const [lib, market] = await Promise.all([ const [lib, market] = await Promise.all([
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`), api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
api.get<SkillSummary[]>(`/api/skills/marketplace`), api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
]) ])
setSkills(lib) setSkills(lib)
setMarketplace(market) 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 () => { const save = async () => {
if (!form) return if (!form) return
setBusy(true) setBusy(true)
@@ -260,6 +292,8 @@ export function SkillsPage() {
onNewVersion={(v) => openForm(key, v, 'version')} onNewVersion={(v) => openForm(key, v, 'version')}
onEdit={(v) => openForm(key, v, 'edit')} onEdit={(v) => openForm(key, v, 'edit')}
onFork={(v) => fork(key, v)} onFork={(v) => fork(key, v)}
onPublish={(v) => setListed(key, v, true)}
onUnpublish={(v) => setListed(key, v, false)}
/> />
))} ))}
{groups.length === 0 && ( {groups.length === 0 && (
@@ -269,23 +303,38 @@ export function SkillsPage() {
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground"> <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> </p>
{marketplace.map((s) => ( {marketplace.map(({ skill: s, alreadyInLibrary }) => (
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}> <Card key={s.id}>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
{s.name} <Badge variant="outline">{s.version}</Badge> {s.name} <Badge variant="outline">{s.version}</Badge>
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
</CardTitle> </CardTitle>
<CardDescription>{s.summary}</CardDescription> <CardDescription>{s.summary}</CardDescription>
</CardHeader> </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>)} {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> </CardContent>
</Card> </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>
)} )}
</div> </div>
@@ -420,16 +469,22 @@ function SkillGroupCard({
onNewVersion, onNewVersion,
onEdit, onEdit,
onFork, onFork,
onPublish,
onUnpublish,
}: { }: {
versions: SkillSummary[] versions: SkillSummary[]
busy: boolean busy: boolean
onNewVersion: (version: string) => void onNewVersion: (version: string) => void
onEdit: (version: string) => void onEdit: (version: string) => void
onFork: (version: string) => void onFork: (version: string) => void
onPublish: (version: string) => void
onUnpublish: (version: string) => void
}) { }) {
const [selected, setSelected] = useState(versions[0].version) const [selected, setSelected] = useState(versions[0].version)
const current = versions.find((v) => v.version === selected) ?? versions[0] const current = versions.find((v) => v.version === selected) ?? versions[0]
const isBuiltin = current.origin === 'Builtin' const isBuiltin = current.origin === 'Builtin'
const isListed = current.visibility === 'Public'
const canPublish = !isBuiltin && current.status === 'Published'
return ( return (
<Card> <Card>
@@ -456,6 +511,7 @@ function SkillGroupCard({
)} )}
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)} {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> <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"> <div className="ml-auto flex items-center gap-2">
{isBuiltin ? ( {isBuiltin ? (
@@ -463,9 +519,20 @@ function SkillGroupCard({
<GitFork data-icon="inline-start" /> Fork to my org <GitFork data-icon="inline-start" /> Fork to my org
</Button> </Button>
) : ( ) : (
<>
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}> <Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
<Pencil data-icon="inline-start" /> Edit <Pencil data-icon="inline-start" /> Edit
</Button> </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)}> <Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
<Plus data-icon="inline-start" /> New version <Plus data-icon="inline-start" /> New version
@@ -75,9 +75,12 @@ internal sealed class Skill : Entity
GoldenTests = manifest.GoldenTests GoldenTests = manifest.GoldenTests
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected }) .Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList(); .ToList();
Visibility = ParseVisibility(manifest.Visibility);
MinTier = ParseTier(manifest.MinTier);
Status = status; 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; Body = body;
ContentHash = contentHash; ContentHash = contentHash;
SourceRepo = sourceRepo; SourceRepo = sourceRepo;
@@ -87,6 +90,13 @@ internal sealed class Skill : Entity
UpdatedAtUtc = nowUtc; 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 string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch 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 GoldenTestDto(string Input, string Expected);
internal sealed record SkillSummary( internal sealed record SkillSummary(
Guid Id,
string SkillKey, string SkillKey,
string Name, string Name,
string Version, 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> /// <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); 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.MapGet("/{key}", GetSkill).RequireAuthorization();
group.MapPost("/authored", AuthorSkill).RequireAuthorization(); group.MapPost("/authored", AuthorSkill).RequireAuthorization();
group.MapPost("/{key}/fork", ForkSkill).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("/index", IndexSkill).RequireAuthorization();
group.MapPost("/sync", Sync).RequireAuthorization(); group.MapPost("/sync", Sync).RequireAuthorization();
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous(); 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. // 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. // 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 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) .OrderBy(s => s.SkillKey)
.ThenByDescending(s => s.Version) .ThenByDescending(s => s.Version)
.ToListAsync(ct); .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( private static async Task<IResult> GetSkill(
@@ -150,7 +282,7 @@ internal static class SkillsEndpoints
var manifest = ToManifest(request); var manifest = ToManifest(request);
var skill = await indexer.IndexAsync( 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( await audit.WriteAsync(
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct); 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( 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( await audit.WriteAsync(
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct); new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
@@ -231,7 +363,8 @@ internal static class SkillsEndpoints
.ToList(), .ToList(),
Tools = request.Tools ?? [], Tools = request.Tools ?? [],
Context = request.Context ?? [], 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, MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
GoldenTests = (request.GoldenTests ?? []) GoldenTests = (request.GoldenTests ?? [])
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected }) .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( private static SkillSummary ToSummary(Skill skill) => new(
skill.Id,
skill.SkillKey, skill.SkillKey,
skill.Name, skill.Name,
skill.Version, skill.Version,
@@ -34,19 +34,25 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
{ {
var parsed = SkillMarkdownParser.Parse(content); var parsed = SkillMarkdownParser.Parse(content);
var contentHash = Hash(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( public Task<Skill> IndexAsync(
SkillManifest manifest, SkillManifest manifest,
string body, string body,
SkillOwnership ownership, SkillOwnership ownership,
bool insertOnly = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
// The content hash spans the structured manifest + body so re-authoring changes it. // 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}"; 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( private async Task<Skill> IndexAsync(
@@ -57,6 +63,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
string? sourceRepo, string? sourceRepo,
string? sourcePath, string? sourcePath,
string? sourceCommit, string? sourceCommit,
bool insertOnly,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
@@ -71,7 +78,9 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
? SkillStatus.Published ? SkillStatus.Published
: SkillStatus.Draft; : 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, s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
cancellationToken); 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!;
}
}