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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user