Files
Teamup/src/Modules/TeamUp.Modules.Skills/Endpoints/SkillsEndpoints.cs
T
soroush.asadi 428eae9643 Fix (review): seat picker must surface the skill the run resolves
Adversarial review found a display-vs-run mismatch: the seat picker collapses the library to the
first row per key, but ListSkills ordered by version only — so for a key the org authored alongside
a higher-versioned builtin, the picker showed/flagged the builtin while the run injected the org's
own skill. ListSkills now orders the same way the run-time catalog resolves (Published-first,
org-owned-over-builtin, then latest version with the same Ordinal comparison), computed in-memory so
the version tiebreak can't diverge from SkillCatalog. The run itself was already correct; this aligns
what the operator sees with what executes. No client change needed.

SkillRunScopingTests now also asserts the library's first row for a key the org authored is the
org-owned Published row, not the builtin.

Verified: skills test subset 4/4 (full suite green pre-merge).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:30:12 +03:30

423 lines
18 KiB
C#

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.Modules.Skills.Sync;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Skills.Endpoints;
internal static class SkillsEndpoints
{
public static void Map(IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/skills").WithTags("Skills");
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
group.MapGet("/", ListSkills).RequireAuthorization();
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
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();
}
// Re-syncing the shared builtin library is an operator action, not a tenant one.
private static async Task<IResult> Sync(
HttpContext http, IOptions<SkillAdminOptions> admin, SkillSyncService sync, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
}
// Gitea push webhook → re-sync the source. Re-reads only the trusted Git source (no caller
// content). Signature verification + changed-file-only sync via the job queue land later.
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
/// <summary>
/// Builtins (null-org, all-tenant-visible) may only be managed by a platform operator holding
/// the configured admin key. Fails closed when no key is configured.
/// </summary>
private static bool IsPlatformAdmin(HttpContext http, IOptions<SkillAdminOptions> admin)
{
var configured = admin.Value.AdminKey;
if (string.IsNullOrEmpty(configured) ||
!http.Request.Headers.TryGetValue("X-Skills-Admin-Key", out var provided))
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided.ToString()), Encoding.UTF8.GetBytes(configured));
}
private static async Task<IResult> ListSkills(
Guid? organizationId, string? role, string? visibility,
IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
// The library a company sees = the shared builtin starter skills (null org) + its own.
IQueryable<Skill> query = db.Skills.Where(s => s.OrganizationId == null);
if (organizationId is { } orgId)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
query = db.Skills.Where(s => s.OrganizationId == null || s.OrganizationId == orgId);
}
if (!string.IsNullOrWhiteSpace(role))
{
query = query.Where(s => s.Roles.Contains(role));
}
if (Enum.TryParse<SkillVisibility>(visibility, ignoreCase: true, out var vis))
{
query = query.Where(s => s.Visibility == vis);
}
// Order so the FIRST row per key is exactly the one the run-time catalog resolves
// (SkillCatalog.GetByKeysAsync): a Published row beats a Draft, the org's own beats the
// shared builtin, then the latest version — using the same Ordinal version comparison.
// The seat picker collapses to the first row per key, so display matches what will run.
var skills = (await query.ToListAsync(ct))
.OrderBy(s => s.SkillKey, StringComparer.Ordinal)
.ThenByDescending(s => s.Status == SkillStatus.Published)
.ThenByDescending(s => s.OrganizationId == organizationId)
.ThenByDescending(s => s.Version, StringComparer.Ordinal)
.ToList();
return Results.Ok(skills.Select(ToSummary).ToList());
}
// 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.
// 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
&& s.Status == SkillStatus.Published
&& s.OrganizationId != null
&& s.OrganizationId != organizationId)
.OrderBy(s => s.SkillKey)
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
// 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(
string key, Guid? organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
var versions = await db.Skills
.Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
.OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
return versions.Count == 0
? Results.NotFound()
: Results.Ok(versions.Select(ToDetail).ToList());
}
private static async Task<IResult> AuthorSkill(
AuthorSkillRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, SkillIndexer indexer, CancellationToken ct)
{
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.SkillKey) ||
string.IsNullOrWhiteSpace(request.Name) ||
string.IsNullOrWhiteSpace(request.Version) ||
string.IsNullOrWhiteSpace(request.Body))
{
return Results.BadRequest("skillKey, name, version, and body are required.");
}
var manifest = ToManifest(request);
var skill = await indexer.IndexAsync(
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);
return Results.Ok(ToDetail(skill));
}
private static async Task<IResult> ForkSkill(
string key, ForkSkillRequest 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();
}
// Fork a builtin (or the org's own) version into an editable, org-owned Authored copy.
var source = await db.Skills.FirstOrDefaultAsync(
s => s.SkillKey == key && s.Version == request.Version
&& (s.OrganizationId == null || s.OrganizationId == request.OrganizationId),
ct);
if (source is null)
{
return Results.NotFound();
}
var manifest = ToManifest(source);
if (!string.IsNullOrWhiteSpace(request.Name))
{
manifest.Name = request.Name.Trim();
}
var skill = await indexer.IndexAsync(
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);
return Results.Ok(ToDetail(skill));
}
// Posts raw content as a shared builtin → operator-only (otherwise any tenant could inject a
// global skill). Tenants author org-owned skills via /authored instead.
private static async Task<IResult> IndexSkill(
HttpContext http, IOptions<SkillAdminOptions> admin, IndexRequest request, SkillIndexer indexer, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.Content))
{
return Results.BadRequest("content is required.");
}
try
{
var skill = await indexer.IndexAsync(
request.Content, request.SourceRepo, request.SourcePath, request.SourceCommit, ct);
return Results.Ok(ToDetail(skill));
}
catch (FormatException ex)
{
return Results.BadRequest(ex.Message);
}
}
private static SkillManifest ToManifest(AuthorSkillRequest request) => new()
{
Id = request.SkillKey.Trim(),
Name = request.Name.Trim(),
Version = request.Version.Trim(),
Summary = request.Summary,
Roles = request.Roles ?? [],
Inputs = request.Inputs,
Outputs = request.Outputs,
Actions = (request.Actions ?? [])
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk, Description = a.Description })
.ToList(),
Tools = request.Tools ?? [],
Context = request.Context ?? [],
// 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 })
.ToList(),
};
private static SkillManifest ToManifest(Skill skill) => new()
{
Id = skill.SkillKey,
Name = skill.Name,
Version = skill.Version,
Summary = skill.Summary,
Roles = [.. skill.Roles],
Inputs = skill.Inputs,
Outputs = skill.Outputs,
Actions = skill.Actions
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk.ToString(), Description = a.Description })
.ToList(),
Tools = [.. skill.Tools],
Context = [.. skill.Context],
Visibility = skill.Visibility.ToString(),
MinTier = skill.MinTier.ToString(),
GoldenTests = [.. skill.GoldenTests], // Skill.Index clones these onto the new row.
};
private static SkillSummary ToSummary(Skill skill) => new(
skill.Id,
skill.SkillKey,
skill.Name,
skill.Version,
skill.Summary,
skill.Roles,
skill.Visibility.ToString(),
skill.MinTier.ToString(),
skill.Status.ToString(),
skill.Origin.ToString(),
skill.OrganizationId,
skill.GoldenTests.Count,
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString(), a.Description)).ToList());
private static SkillDetail ToDetail(Skill skill) => new(
ToSummary(skill),
skill.Inputs,
skill.Outputs,
skill.Tools,
skill.Context,
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
skill.Body);
}