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("/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); } var skills = await query .OrderBy(s => s.SkillKey) .ThenByDescending(s => s.Version) .ToListAsync(ct); 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. private static async Task Marketplace(SkillsDbContext db, CancellationToken ct) { var listed = await db.Skills .Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public) .OrderBy(s => s.SkillKey) .ThenByDescending(s => s.Version) .ToListAsync(ct); return Results.Ok(listed.Select(ToSummary).ToList()); } 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), 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), 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 ?? [], Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : 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.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); }