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 Sync( HttpContext http, IOptions 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 Webhook(SkillSyncService sync, CancellationToken ct) => Results.Ok(new SyncResult(await sync.SyncAsync(ct))); /// /// 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. /// private static bool IsPlatformAdmin(HttpContext http, IOptions 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 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 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(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 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 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 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 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 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 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 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 IndexSkill( HttpContext http, IOptions 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); }