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
@@ -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,