Dynamic per-org skill library: in-app authoring, versioning, fork (+ marketplace seam)

Skills move from a global Git-only registry to a per-company library that orgs author and
version in-app — Git stays as the shared *starter* library.

Domain & persistence:
- Skill gains OrganizationId (null = shared builtin, visible to every org), Origin
  (Builtin | Authored | Installed), AuthoredByMemberId. Identity is now
  (OrganizationId, SkillKey, Version); the unique index uses NULLS NOT DISTINCT so builtins
  stay unique by key+version while each org gets its own namespace (and can fork a builtin).
  AddSkillOwnership migration backfills existing rows as Builtin.
- Owned GoldenExample rows are cloned in Skill.Index so a fork can't re-parent the source's
  tracked entities.

Authoring (tenant, dynamic):
- POST /api/skills/authored — structured fields → same indexer pipeline (embedding +
  publish gate apply identically), tagged org + author. POST /api/skills/{key}/fork copies a
  builtin/global skill into your org as an editable Authored draft. List/Get are org-scoped
  (your org + shared builtins). New Capability.ManageSkills (Owner + TeamOwner), audited.
- GET /api/skills/marketplace: read-only seam listing public skills across orgs (install is
  the next step).

Security (from adversarial review — two confirmed criticals):
- Managing shared builtins is an operator action, not a tenant one. /index (posts arbitrary
  content as a global builtin) and /sync (re-indexes the shared library) now require a
  platform admin key (X-Skills-Admin-Key, fixed-time compare, fail-closed when unset) via
  SkillAdminOptions — previously any authenticated user of any org could inject/poison global
  skills. New test asserts an authenticated Owner without the key gets 403 on both.

UI: new /skills library page — browse shared + org skills grouped by key with their versions,
create / new-version / fork, golden-test editor + body, Draft/Published badge and the
publish-gate hint (needs roles + ≥1 golden test).

Verified: ArchitectureTests 8/8, IntegrationTests 46/46 (new SkillLibraryTests: org
isolation, version coexistence, fork, publish gate, Member 403, admin-gate 403), client build
green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 11:09:02 +03:30
parent 414ff44b48
commit fad476f115
24 changed files with 1398 additions and 37 deletions
@@ -9,6 +9,10 @@ namespace TeamUp.Modules.Skills.Domain;
/// </summary>
internal sealed class Skill : Entity
{
/// <summary>Owning org. Null = a shared builtin (Git starter library), visible to every org.</summary>
public Guid? OrganizationId { get; private set; }
public SkillOrigin Origin { get; private set; }
public Guid? AuthoredByMemberId { get; private set; }
public string SkillKey { get; private set; } = null!;
public string Name { get; private set; } = null!;
public string Version { get; private set; } = null!;
@@ -36,14 +40,16 @@ internal sealed class Skill : Entity
{
}
public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc };
public static Skill Create(string skillKey, string version, Guid? organizationId, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, OrganizationId = organizationId, IndexedAtUtc = nowUtc };
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
public void Index(
SkillManifest manifest,
string body,
string contentHash,
SkillOrigin origin,
Guid? authoredByMemberId,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
@@ -51,6 +57,8 @@ internal sealed class Skill : Entity
SkillStatus status,
DateTimeOffset nowUtc)
{
Origin = origin;
AuthoredByMemberId = authoredByMemberId;
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
Version = manifest.Version;
Summary = manifest.Summary;
@@ -62,7 +70,11 @@ internal sealed class Skill : Entity
.ToList();
Tools = manifest.Tools;
Context = manifest.Context;
GoldenTests = manifest.GoldenTests;
// Fresh owned-entity instances — a manifest built from another skill (fork) must not
// re-parent that skill's tracked GoldenExample rows onto this one.
GoldenTests = manifest.GoldenTests
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList();
Visibility = ParseVisibility(manifest.Visibility);
MinTier = ParseTier(manifest.MinTier);
Status = status;
@@ -31,6 +31,18 @@ internal enum SkillStatus
Published,
}
/// <summary>
/// Where a skill row came from. <c>Builtin</c> = synced from the shared Git starter library
/// (OrganizationId null, visible to every org). <c>Authored</c> = created in-app by an org.
/// <c>Installed</c> = copied from the marketplace into an org (next step).
/// </summary>
internal enum SkillOrigin
{
Builtin,
Authored,
Installed,
}
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
internal sealed class SkillAction
{
@@ -1,6 +1,8 @@
namespace TeamUp.Modules.Skills.Endpoints;
internal sealed record ActionDto(string Name, string Risk);
internal sealed record ActionDto(string Name, string Risk, string? Description = null);
internal sealed record GoldenTestDto(string Input, string Expected);
internal sealed record SkillSummary(
string SkillKey,
@@ -11,6 +13,9 @@ internal sealed record SkillSummary(
string Visibility,
string MinTier,
string Status,
string Origin,
Guid? OrganizationId,
int GoldenTestCount,
List<ActionDto> Actions);
internal sealed record SkillDetail(
@@ -19,9 +24,33 @@ internal sealed record SkillDetail(
string? Outputs,
List<string> Tools,
List<string> Context,
int GoldenTestCount,
List<GoldenTestDto> GoldenTests,
string Body);
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
internal sealed record SyncResult(int Indexed);
/// <summary>
/// Author or version an org-owned skill from structured fields. Re-saving the same
/// (OrganizationId, SkillKey, Version) edits in place; bumping Version creates a new version.
/// </summary>
internal sealed record AuthorSkillRequest(
Guid OrganizationId,
string SkillKey,
string Name,
string Version,
string? Summary,
List<string> Roles,
string? Inputs,
string? Outputs,
List<ActionDto> Actions,
List<string> Tools,
List<string> Context,
string Visibility,
string MinTier,
string Body,
List<GoldenTestDto> GoldenTests);
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
@@ -1,11 +1,16 @@
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;
@@ -18,24 +23,64 @@ internal static class SkillsEndpoints
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();
}
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
// 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();
}
// Gitea push webhook → re-sync the source. M2 re-indexes the whole source (idempotent);
// signature verification + changed-file-only sync via the job queue land later.
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)));
private static async Task<IResult> ListSkills(
string? role, string? visibility, SkillsDbContext db, CancellationToken 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 query = db.Skills.AsQueryable();
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))
{
@@ -55,11 +100,30 @@ internal static class SkillsEndpoints
return Results.Ok(skills.Select(ToSummary).ToList());
}
private static async Task<IResult> GetSkill(string key, SkillsDbContext db, CancellationToken ct)
// 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)
{
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<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)
.OrderByDescending(s => s.Version)
.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
@@ -67,8 +131,75 @@ internal static class SkillsEndpoints
: Results.Ok(versions.Select(ToDetail).ToList());
}
private static async Task<IResult> IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct)
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), 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), 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.");
@@ -86,6 +217,46 @@ internal static class SkillsEndpoints
}
}
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,
@@ -95,7 +266,10 @@ internal static class SkillsEndpoints
skill.Visibility.ToString(),
skill.MinTier.ToString(),
skill.Status.ToString(),
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList());
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),
@@ -103,6 +277,6 @@ internal static class SkillsEndpoints
skill.Outputs,
skill.Tools,
skill.Context,
skill.GoldenTests.Count,
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
skill.Body);
}
@@ -8,10 +8,24 @@ using TeamUp.Modules.Skills.Persistence;
namespace TeamUp.Modules.Skills.Indexing;
/// <summary>Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).</summary>
/// <summary>Who owns an indexed skill row — set once, then immutable for that (org, key, version).</summary>
internal readonly record struct SkillOwnership(Guid? OrganizationId, SkillOrigin Origin, Guid? AuthoredByMemberId)
{
/// <summary>The shared Git starter library: no org, visible to everyone.</summary>
public static readonly SkillOwnership Builtin = new(null, SkillOrigin.Builtin, null);
public static SkillOwnership Authored(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Authored, memberId);
public static SkillOwnership Installed(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Installed, memberId);
}
/// <summary>Parses/projects a skill manifest, computes its embedding, and upserts the row (by org+key+version).</summary>
internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
{
public async Task<Skill> IndexAsync(
/// <summary>Index raw SKILL.md content from the Git source (a shared builtin).</summary>
public Task<Skill> IndexAsync(
string content,
string? sourceRepo,
string? sourcePath,
@@ -19,26 +33,51 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
CancellationToken cancellationToken = default)
{
var parsed = SkillMarkdownParser.Parse(content);
var manifest = parsed.Manifest;
var now = clock.GetUtcNow();
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var contentHash = Hash(content);
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
}
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}";
/// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
public Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
SkillOwnership ownership,
CancellationToken cancellationToken = default)
{
// The content hash spans the structured manifest + body so re-authoring changes it.
var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Summary}\n{string.Join(',', manifest.Roles)}\n{body}";
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, cancellationToken);
}
private async Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
string contentHash,
SkillOwnership ownership,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{body}";
var embedding = new Vector(embedder.Embed(embeddingText));
// M2 publish gate (structural): a skill is published only if it declares roles and carries
// at least one well-formed golden test. Executing the golden tests against a model — and
// gating on edit distance — lands in M4 when the assembler/runtime exists.
// Publish gate (structural): a skill is published only if it declares roles and carries at
// least one well-formed golden test. Executing the golden tests against a model — and gating
// on edit distance — lands in M4 when the assembler/runtime exists.
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
? SkillStatus.Published
: SkillStatus.Draft;
var skill = await db.Skills
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
var skill = await db.Skills.FirstOrDefaultAsync(
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
cancellationToken);
var isNew = skill is null;
skill ??= Skill.Create(manifest.Id, manifest.Version, now);
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
skill ??= Skill.Create(manifest.Id, manifest.Version, ownership.OrganizationId, now);
skill.Index(manifest, body, contentHash, ownership.Origin, ownership.AuthoredByMemberId, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
if (isNew)
{
@@ -48,4 +87,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
await db.SaveChangesAsync(cancellationToken);
return skill;
}
private static string Hash(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
}
@@ -0,0 +1,203 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Skills.Persistence;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
[DbContext(typeof(SkillsDbContext))]
[Migration("20260610180442_AddSkillOwnership")]
partial class AddSkillOwnership
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skills")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<List<string>>("Context")
.IsRequired()
.HasColumnType("text[]");
b.Property<Vector>("Embedding")
.HasColumnType("vector(384)");
b.Property<DateTimeOffset>("IndexedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Inputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("MinTier")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("SkillKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceCommit")
.HasColumnType("text");
b.Property<string>("SourcePath")
.HasColumnType("text");
b.Property<string>("SourceRepo")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.PrimitiveCollection<List<string>>("Tools")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status");
b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills");
});
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Expected")
.IsRequired();
b1.Property<string>("Input")
.IsRequired();
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("GoldenTests")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Name")
.IsRequired();
b1.Property<int>("Risk");
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("Actions")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.Navigation("Actions");
b.Navigation("GoldenTests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSkillOwnership : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.AddColumn<Guid>(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
// Every pre-existing row came from Git sync, so backfill them as Builtin (an empty
// string wouldn't parse back to the SkillOrigin enum). New rows always set Origin.
migrationBuilder.AddColumn<string>(
name: "Origin",
schema: "skills",
table: "skills",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Builtin");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "OrganizationId", "SkillKey", "Version" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "Origin",
schema: "skills",
table: "skills");
migrationBuilder.CreateIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "SkillKey", "Version" },
unique: true);
}
}
}
@@ -31,6 +31,9 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
@@ -64,6 +67,14 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
@@ -114,11 +125,15 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status");
b.HasIndex("SkillKey", "Version")
b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills");
});
@@ -17,6 +17,7 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
{
skill.ToTable("skills");
skill.HasKey(s => s.Id);
skill.Property(s => s.Origin).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
@@ -33,7 +34,12 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique();
// Identity is org-scoped: an org owns its (key, version); builtins share the null-org
// namespace. NULLS NOT DISTINCT so two builtins can't collide on (null, key, version).
skill.HasIndex(s => new { s.OrganizationId, s.SkillKey, s.Version })
.IsUnique()
.AreNullsDistinct(false);
skill.HasIndex(s => s.OrganizationId);
skill.HasIndex(s => s.Status);
});
}
@@ -0,0 +1,14 @@
namespace TeamUp.Modules.Skills;
/// <summary>
/// Platform-operator settings for managing the shared builtin skill library (null-org skills,
/// visible to every tenant). Builtin management is NOT a tenant action — the endpoints that
/// create/sync builtins require <see cref="AdminKey"/>, which no tenant role grants.
/// </summary>
internal sealed class SkillAdminOptions
{
public const string SectionName = "Skills";
/// <summary>Operator key required to manage builtins. Null/empty ⇒ builtin management is disabled.</summary>
public string? AdminKey { get; set; }
}
@@ -24,6 +24,7 @@ public sealed class SkillsModule : IModule
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.Configure<SkillAdminOptions>(configuration.GetSection(SkillAdminOptions.SectionName));
services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();