From 155997551879c09de54dcd31618287596e41c4b7 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 23:26:28 +0330 Subject: [PATCH 1/3] =?UTF-8?q?M3:=20BYOK=20=E2=80=94=20encrypted=20owner-?= =?UTF-8?q?only=20API=20configs=20+=20model=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SharedKernel: Autonomy dial enum; IModelClient (ModelRequest/ModelCompletion); IApiConfigResolver (+ ApiConfigSummary/ResolvedApiConfig) — server-side, decrypted. Integrations module: - ApiConfig entity (org-scoped) + IntegrationsDbContext (schema "integrations") + InitialIntegrations migration; the key is AES-256-GCM encrypted at rest (key derived from Encryption:MasterKey) and never returned to a client. - Model adapters: StubModelClient (no-network, provider "stub"/"echo"), an OpenAI-compatible HTTP adapter, and a ModelClientRouter; ApiConfigResolver decrypts server-side only. - Endpoints: POST/GET/DELETE /api/integrations/api-configs and POST .../{id}/test. Create/ test/delete require ManageApiKeys (owner); listing requires ConfigureAgents (assign-only, no key). Dev master key in appsettings; override Encryption__MasterKey in prod. Verified: build green; ArchitectureTests 8/8 (Integrations references only SharedKernel); IntegrationTests 26/26 incl. a BYOK flow — key never appears in any response, the connection test succeeds (stub), and a Member is 403'd from create + list. Co-Authored-By: Claude Opus 4.8 --- .editorconfig | 2 + src/Hosts/TeamUp.Web/appsettings.json | 3 + src/Hosts/TeamUp.Worker/appsettings.json | 3 + .../Ai/ApiConfigResolver.cs | 20 +++ .../Ai/ModelClients.cs | 72 ++++++++++ .../Domain/ApiConfig.cs | 44 ++++++ .../Endpoints/IntegrationsDtos.cs | 14 ++ .../Endpoints/IntegrationsEndpoints.cs | 120 +++++++++++++++++ .../IntegrationsModule.cs | 43 ++++-- .../Persistence/IntegrationsDbContext.cs | 29 ++++ .../IntegrationsDbContextFactory.cs | 21 +++ ...0609194740_InitialIntegrations.Designer.cs | 79 +++++++++++ .../20260609194740_InitialIntegrations.cs | 59 +++++++++ .../IntegrationsDbContextModelSnapshot.cs | 76 +++++++++++ .../Security/SecretProtector.cs | 72 ++++++++++ .../TeamUp.Modules.Integrations.csproj | 14 +- .../TeamUp.SharedKernel/Access/Autonomy.cs | 12 ++ .../Ai/IApiConfigResolver.cs | 16 +++ .../TeamUp.SharedKernel/Ai/IModelClient.cs | 21 +++ tests/TeamUp.IntegrationTests/ByokTests.cs | 125 ++++++++++++++++++ 20 files changed, 827 insertions(+), 18 deletions(-) create mode 100644 src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs create mode 100644 src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs create mode 100644 src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs create mode 100644 tests/TeamUp.IntegrationTests/ByokTests.cs diff --git a/.editorconfig b/.editorconfig index 0166bd8..e28d1f2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -41,6 +41,8 @@ dotnet_diagnostic.CA2007.severity = none # CA1848 / CA1873: LoggerMessage-delegate perf rules — opt-in perf, not worth enforcing in V1. dotnet_diagnostic.CA1848.severity = none dotnet_diagnostic.CA1873.severity = none +# CA1031: a model/test boundary intentionally catches broadly to report any failure as a result. +dotnet_diagnostic.CA1031.severity = none # EF Core migrations are tool-generated — don't style-police them. [**/Migrations/*.cs] diff --git a/src/Hosts/TeamUp.Web/appsettings.json b/src/Hosts/TeamUp.Web/appsettings.json index f23be41..88af75b 100644 --- a/src/Hosts/TeamUp.Web/appsettings.json +++ b/src/Hosts/TeamUp.Web/appsettings.json @@ -11,6 +11,9 @@ "Audience": "teamup", "ExpiryMinutes": 480 }, + "Encryption": { + "MasterKey": "dev-only-teamup-master-secret-change-in-production" + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Hosts/TeamUp.Worker/appsettings.json b/src/Hosts/TeamUp.Worker/appsettings.json index c17bfab..79ea812 100644 --- a/src/Hosts/TeamUp.Worker/appsettings.json +++ b/src/Hosts/TeamUp.Worker/appsettings.json @@ -11,6 +11,9 @@ "Audience": "teamup", "ExpiryMinutes": 480 }, + "Encryption": { + "MasterKey": "dev-only-teamup-master-secret-change-in-production" + }, "OpenTelemetry": { "OtlpEndpoint": "" }, diff --git a/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs b/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs new file mode 100644 index 0000000..96effef --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Ai/ApiConfigResolver.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Integrations.Persistence; +using TeamUp.Modules.Integrations.Security; +using TeamUp.SharedKernel.Ai; + +namespace TeamUp.Modules.Integrations.Ai; + +/// Resolves a BYOK config and decrypts its key — server-side only. +internal sealed class ApiConfigResolver(IntegrationsDbContext db, ISecretProtector protector) : IApiConfigResolver +{ + public async Task ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default) + { + var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == apiConfigId, cancellationToken); + return config is null + ? null + : new ResolvedApiConfig( + config.Id, config.Name, config.Provider, config.Model, config.Endpoint, + protector.Unprotect(config.EncryptedKey)); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs new file mode 100644 index 0000000..a1eaea8 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Ai/ModelClients.cs @@ -0,0 +1,72 @@ +using System.Diagnostics; +using System.Net.Http.Json; +using System.Text.Json; +using TeamUp.SharedKernel.Ai; + +namespace TeamUp.Modules.Integrations.Ai; + +/// No-network adapter for the "stub"/"echo" provider — used by tests and dogfood without keys. +internal sealed class StubModelClient : IModelClient +{ + public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) => + Task.FromResult(new ModelCompletion( + Success: true, + Text: $"[stub {request.Provider}/{request.Model}] {request.Prompt}", + Error: null, + LatencyMs: 0)); +} + +/// +/// OpenAI-compatible /v1/chat/completions adapter (OpenAI, Ollama, vLLM, and OpenAI-compatible +/// gateways). Returns a failed completion rather than throwing, so the connection test can report it. +/// +internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient +{ + public async Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) + { + var stopwatch = Stopwatch.StartNew(); + try + { + var baseUrl = (request.Endpoint ?? "https://api.openai.com").TrimEnd('/'); + using var message = new HttpRequestMessage(HttpMethod.Post, $"{baseUrl}/v1/chat/completions"); + if (!string.IsNullOrEmpty(request.ApiKey)) + { + message.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", request.ApiKey); + } + + message.Content = JsonContent.Create(new + { + model = request.Model, + max_tokens = request.MaxTokens, + messages = new[] { new { role = "user", content = request.Prompt } }, + }); + + using var response = await http.SendAsync(message, cancellationToken); + stopwatch.Stop(); + if (!response.IsSuccessStatusCode) + { + return new ModelCompletion(false, null, $"HTTP {(int)response.StatusCode}", stopwatch.ElapsedMilliseconds); + } + + var doc = await response.Content.ReadFromJsonAsync(cancellationToken); + var text = doc.GetProperty("choices")[0].GetProperty("message").GetProperty("content").GetString(); + return new ModelCompletion(true, text, null, stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + return new ModelCompletion(false, null, ex.Message, stopwatch.ElapsedMilliseconds); + } + } +} + +/// Routes a request to the adapter for its provider. +internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient +{ + public Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) => + request.Provider.ToLowerInvariant() switch + { + "stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken), + _ => openAi.CompleteAsync(request, cancellationToken), + }; +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs b/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs new file mode 100644 index 0000000..de08be2 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Domain/ApiConfig.cs @@ -0,0 +1,44 @@ +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.Integrations.Domain; + +/// +/// A BYOK model configuration (a named provider+model with an encrypted key), owned at the org +/// scope. Owner-only to create/test/delete; the key is encrypted at rest and never returned to a +/// client after save — team owners assign a config by id without ever seeing the key. +/// +internal sealed class ApiConfig : Entity +{ + public Guid OrganizationId { get; private set; } + public string Name { get; private set; } = null!; + public string Provider { get; private set; } = null!; + public string Model { get; private set; } = null!; + public string? Endpoint { get; private set; } + public string EncryptedKey { get; private set; } = null!; + public Guid CreatedByMemberId { get; private set; } + public DateTimeOffset CreatedAtUtc { get; private set; } + + private ApiConfig() + { + } + + public ApiConfig( + Guid organizationId, + string name, + string provider, + string model, + string? endpoint, + string encryptedKey, + Guid createdByMemberId, + DateTimeOffset createdAtUtc) + { + OrganizationId = organizationId; + Name = name; + Provider = provider; + Model = model; + Endpoint = endpoint; + EncryptedKey = encryptedKey; + CreatedByMemberId = createdByMemberId; + CreatedAtUtc = createdAtUtc; + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs new file mode 100644 index 0000000..a9acd69 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsDtos.cs @@ -0,0 +1,14 @@ +namespace TeamUp.Modules.Integrations.Endpoints; + +internal sealed record CreateApiConfigRequest( + Guid OrganizationId, + string Name, + string Provider, + string Model, + string? Endpoint, + string ApiKey); + +/// Public view of a config — never includes the key. +internal sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint); + +internal sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample); diff --git a/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs new file mode 100644 index 0000000..6a5f024 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Endpoints/IntegrationsEndpoints.cs @@ -0,0 +1,120 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Integrations.Domain; +using TeamUp.Modules.Integrations.Persistence; +using TeamUp.Modules.Integrations.Security; +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Ai; +using TeamUp.SharedKernel.Modularity; + +namespace TeamUp.Modules.Integrations.Endpoints; + +internal static class IntegrationsEndpoints +{ + public static void Map(IEndpointRouteBuilder endpoints) + { + var group = endpoints.MapGroup("/api/integrations").WithTags("Integrations"); + + group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("integrations"))); + group.MapPost("/api-configs", CreateApiConfig).RequireAuthorization(); + group.MapGet("/api-configs", ListApiConfigs).RequireAuthorization(); + group.MapPost("/api-configs/{id:guid}/test", TestApiConfig).RequireAuthorization(); + group.MapDelete("/api-configs/{id:guid}", DeleteApiConfig).RequireAuthorization(); + } + + // Owner-only. Encrypts the key; the response never includes it. + private static async Task CreateApiConfig( + CreateApiConfigRequest request, ICurrentUser user, IPermissionService permissions, + IntegrationsDbContext db, ISecretProtector protector, TimeProvider clock, CancellationToken ct) + { + if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(request.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.Provider) + || string.IsNullOrWhiteSpace(request.Model) || string.IsNullOrWhiteSpace(request.ApiKey)) + { + return Results.BadRequest("Name, provider, model and apiKey are required."); + } + + var config = new ApiConfig( + request.OrganizationId, request.Name.Trim(), request.Provider.Trim(), request.Model.Trim(), + request.Endpoint, protector.Protect(request.ApiKey), user.MemberId, clock.GetUtcNow()); + + db.ApiConfigs.Add(config); + await db.SaveChangesAsync(ct); + return Results.Ok(ToDto(config)); + } + + // Team owners may list (to assign) — without ever seeing the key. + private static async Task ListApiConfigs( + Guid organizationId, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct) + { + if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Org(organizationId))) + { + return Results.Forbid(); + } + + var configs = await db.ApiConfigs + .Where(c => c.OrganizationId == organizationId) + .OrderBy(c => c.Name) + .Select(c => new ApiConfigDto(c.Id, c.Name, c.Provider, c.Model, c.Endpoint)) + .ToListAsync(ct); + + return Results.Ok(configs); + } + + // Owner-only. Resolves + decrypts server-side, makes a tiny model call, returns the outcome. + private static async Task TestApiConfig( + Guid id, IPermissionService permissions, IntegrationsDbContext db, + IApiConfigResolver resolver, IModelClient model, CancellationToken ct) + { + var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct); + if (config is null) + { + return Results.NotFound(); + } + + if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId))) + { + return Results.Forbid(); + } + + var resolved = await resolver.ResolveAsync(id, ct); + if (resolved is null) + { + return Results.NotFound(); + } + + var completion = await model.CompleteAsync( + new ModelRequest(resolved.Provider, resolved.Model, resolved.ApiKey, resolved.Endpoint, "ping", MaxTokens: 16), ct); + + var sample = completion.Text is { Length: > 0 } text ? text[..Math.Min(text.Length, 80)] : null; + return Results.Ok(new TestResultDto(completion.Success, completion.Error, completion.LatencyMs, sample)); + } + + private static async Task DeleteApiConfig( + Guid id, IPermissionService permissions, IntegrationsDbContext db, CancellationToken ct) + { + var config = await db.ApiConfigs.FirstOrDefaultAsync(c => c.Id == id, ct); + if (config is null) + { + return Results.NotFound(); + } + + if (!permissions.Has(Capability.ManageApiKeys, ScopeRef.Org(config.OrganizationId))) + { + return Results.Forbid(); + } + + db.ApiConfigs.Remove(config); + await db.SaveChangesAsync(ct); + return Results.NoContent(); + } + + private static ApiConfigDto ToDto(ApiConfig config) => + new(config.Id, config.Name, config.Provider, config.Model, config.Endpoint); +} diff --git a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs index ea66bc0..fca029b 100644 --- a/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs +++ b/src/Modules/TeamUp.Modules.Integrations/IntegrationsModule.cs @@ -1,17 +1,23 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Routing; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using TeamUp.Modules.Integrations.Ai; +using TeamUp.Modules.Integrations.Endpoints; using TeamUp.Modules.Integrations.Git; +using TeamUp.Modules.Integrations.Persistence; +using TeamUp.Modules.Integrations.Security; +using TeamUp.SharedKernel.Ai; using TeamUp.SharedKernel.Git; using TeamUp.SharedKernel.Modularity; +using TeamUp.SharedKernel.Persistence; namespace TeamUp.Modules.Integrations; /// -/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the -/// (filesystem for dogfood, Gitea over REST). BYOK lands in M3. +/// BYOK API configs (encrypted, owner-only), the model-client adapters, and the Git connection. +/// Encryption keys are owner-only and server-side; the decrypted key never leaves the server. /// public sealed class IntegrationsModule : IModule { @@ -19,10 +25,26 @@ public sealed class IntegrationsModule : IModule public void Register(IServiceCollection services, IConfiguration configuration) { - services.Configure(configuration.GetSection(GitSourceOptions.SectionName)); - var options = configuration.GetSection(GitSourceOptions.SectionName).Get() ?? new GitSourceOptions(); + var connectionString = configuration.GetConnectionString("Postgres") + ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'."); - if (string.Equals(options.Provider, "gitea", StringComparison.OrdinalIgnoreCase)) + // BYOK credential store + encryption. + services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); + services.Configure(configuration.GetSection(EncryptionOptions.SectionName)); + services.AddSingleton(); + services.AddScoped(); + services.TryAddSingleton(TimeProvider.System); + + // Model clients: a router over per-provider adapters. + services.AddSingleton(); + services.AddHttpClient(); + services.AddScoped(); + + // Git source (M2) — filesystem for dogfood, Gitea over REST when configured. + services.Configure(configuration.GetSection(GitSourceOptions.SectionName)); + var gitOptions = configuration.GetSection(GitSourceOptions.SectionName).Get() ?? new GitSourceOptions(); + if (string.Equals(gitOptions.Provider, "gitea", StringComparison.OrdinalIgnoreCase)) { services.AddHttpClient(); } @@ -32,10 +54,5 @@ public sealed class IntegrationsModule : IModule } } - public void MapEndpoints(IEndpointRouteBuilder endpoints) - { - endpoints.MapGroup($"/api/{Name}") - .WithTags("Integrations") - .MapGet("/ping", () => TypedResults.Ok(new ModulePing(Name))); - } + public void MapEndpoints(IEndpointRouteBuilder endpoints) => IntegrationsEndpoints.Map(endpoints); } diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs new file mode 100644 index 0000000..4edb33c --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContext.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using TeamUp.Modules.Integrations.Domain; +using TeamUp.SharedKernel.Persistence; + +namespace TeamUp.Modules.Integrations.Persistence; + +internal sealed class IntegrationsDbContext(DbContextOptions options) + : DbContext(options), IModuleDbContext +{ + public DbSet ApiConfigs => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema("integrations"); + + modelBuilder.Entity(config => + { + config.ToTable("api_configs"); + config.HasKey(c => c.Id); + config.Property(c => c.Name).HasMaxLength(120).IsRequired(); + config.Property(c => c.Provider).HasMaxLength(60).IsRequired(); + config.Property(c => c.Model).HasMaxLength(120).IsRequired(); + config.Property(c => c.Endpoint).HasMaxLength(500); + config.Property(c => c.EncryptedKey).IsRequired(); + config.HasIndex(c => c.OrganizationId); + config.HasIndex(c => new { c.OrganizationId, c.Name }).IsUnique(); + }); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs new file mode 100644 index 0000000..585f1e1 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/IntegrationsDbContextFactory.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace TeamUp.Modules.Integrations.Persistence; + +/// Design-time factory so `dotnet ef` can build the internal context without a host. +internal sealed class IntegrationsDbContextFactory : IDesignTimeDbContextFactory +{ + public IntegrationsDbContext CreateDbContext(string[] args) + { + var connectionString = + Environment.GetEnvironmentVariable("ConnectionStrings__Postgres") + ?? "Host=localhost;Port=5432;Database=teamup;Username=teamup;Password=teamup"; + + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + + return new IntegrationsDbContext(options); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs new file mode 100644 index 0000000..905b427 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.Designer.cs @@ -0,0 +1,79 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeamUp.Modules.Integrations.Persistence; + +#nullable disable + +namespace TeamUp.Modules.Integrations.Persistence.Migrations +{ + [DbContext(typeof(IntegrationsDbContext))] + [Migration("20260609194740_InitialIntegrations")] + partial class InitialIntegrations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("integrations") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("EncryptedKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("api_configs", "integrations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs new file mode 100644 index 0000000..18873f4 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/20260609194740_InitialIntegrations.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.Integrations.Persistence.Migrations +{ + /// + public partial class InitialIntegrations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "integrations"); + + migrationBuilder.CreateTable( + name: "api_configs", + schema: "integrations", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + OrganizationId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Provider = table.Column(type: "character varying(60)", maxLength: 60, nullable: false), + Model = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Endpoint = table.Column(type: "character varying(500)", maxLength: 500, nullable: true), + EncryptedKey = table.Column(type: "text", nullable: false), + CreatedByMemberId = table.Column(type: "uuid", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_api_configs", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_api_configs_OrganizationId", + schema: "integrations", + table: "api_configs", + column: "OrganizationId"); + + migrationBuilder.CreateIndex( + name: "IX_api_configs_OrganizationId_Name", + schema: "integrations", + table: "api_configs", + columns: new[] { "OrganizationId", "Name" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "api_configs", + schema: "integrations"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs new file mode 100644 index 0000000..744df39 --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Persistence/Migrations/IntegrationsDbContextModelSnapshot.cs @@ -0,0 +1,76 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using TeamUp.Modules.Integrations.Persistence; + +#nullable disable + +namespace TeamUp.Modules.Integrations.Persistence.Migrations +{ + [DbContext(typeof(IntegrationsDbContext))] + partial class IntegrationsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("integrations") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeamUp.Modules.Integrations.Domain.ApiConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("EncryptedKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("Endpoint") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(60) + .HasColumnType("character varying(60)"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.HasIndex("OrganizationId", "Name") + .IsUnique(); + + b.ToTable("api_configs", "integrations"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs b/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs new file mode 100644 index 0000000..3732a9d --- /dev/null +++ b/src/Modules/TeamUp.Modules.Integrations/Security/SecretProtector.cs @@ -0,0 +1,72 @@ +using System.Security.Cryptography; +using System.Text; +using Microsoft.Extensions.Options; + +namespace TeamUp.Modules.Integrations.Security; + +internal sealed class EncryptionOptions +{ + public const string SectionName = "Encryption"; + + /// Deployment master secret. A 32-byte AES key is derived from it (SHA-256). + public string MasterKey { get; set; } = string.Empty; +} + +internal interface ISecretProtector +{ + string Protect(string plaintext); + + string Unprotect(string protectedValue); +} + +/// +/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret. +/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded. +/// +internal sealed class AesGcmSecretProtector : ISecretProtector +{ + private const int NonceSize = 12; + private const int TagSize = 16; + private readonly byte[] _key; + + public AesGcmSecretProtector(IOptions options) + { + var masterKey = options.Value.MasterKey; + if (string.IsNullOrWhiteSpace(masterKey)) + { + throw new InvalidOperationException("Missing 'Encryption:MasterKey'."); + } + + _key = SHA256.HashData(Encoding.UTF8.GetBytes(masterKey)); + } + + public string Protect(string plaintext) + { + var plain = Encoding.UTF8.GetBytes(plaintext); + var nonce = RandomNumberGenerator.GetBytes(NonceSize); + var cipher = new byte[plain.Length]; + var tag = new byte[TagSize]; + + using var aes = new AesGcm(_key, TagSize); + aes.Encrypt(nonce, plain, cipher, tag); + + var blob = new byte[NonceSize + TagSize + cipher.Length]; + Buffer.BlockCopy(nonce, 0, blob, 0, NonceSize); + Buffer.BlockCopy(tag, 0, blob, NonceSize, TagSize); + Buffer.BlockCopy(cipher, 0, blob, NonceSize + TagSize, cipher.Length); + return Convert.ToBase64String(blob); + } + + public string Unprotect(string protectedValue) + { + var blob = Convert.FromBase64String(protectedValue); + var nonce = blob.AsSpan(0, NonceSize); + var tag = blob.AsSpan(NonceSize, TagSize); + var cipher = blob.AsSpan(NonceSize + TagSize); + var plain = new byte[cipher.Length]; + + using var aes = new AesGcm(_key, TagSize); + aes.Decrypt(nonce, cipher, tag, plain); + return Encoding.UTF8.GetString(plain); + } +} diff --git a/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj b/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj index 876bf04..ef26d73 100644 --- a/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj +++ b/src/Modules/TeamUp.Modules.Integrations/TeamUp.Modules.Integrations.csproj @@ -1,12 +1,16 @@ - + + + + + + + diff --git a/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs b/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs new file mode 100644 index 0000000..66121b0 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Access/Autonomy.cs @@ -0,0 +1,12 @@ +namespace TeamUp.SharedKernel.Access; + +/// +/// The per-seat autonomy dial, set by the team owner. The action gate (M5) compares it to an +/// action's risk to decide execute-vs-hold. Stored on the Agent (M3); evaluated in Governance. +/// +public enum Autonomy +{ + DraftOnly, + Gated, + Autonomous, +} diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs b/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs new file mode 100644 index 0000000..ef1cd86 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Ai/IApiConfigResolver.cs @@ -0,0 +1,16 @@ +namespace TeamUp.SharedKernel.Ai; + +/// Non-sensitive BYOK config info (no key) — safe to list/return to clients. +public sealed record ApiConfigSummary(Guid Id, string Name, string Provider, string Model); + +/// A resolved config including the decrypted key. Server-side only — never serialized to a client. +public sealed record ResolvedApiConfig(Guid Id, string Name, string Provider, string Model, string? Endpoint, string ApiKey); + +/// +/// Resolves a BYOK config (decrypting the key) for server-side use — the M3 connection test and the +/// M4 assembler. Implemented by Integrations; the decrypted key never leaves the server. +/// +public interface IApiConfigResolver +{ + Task ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default); +} diff --git a/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs new file mode 100644 index 0000000..964c965 --- /dev/null +++ b/src/Shared/TeamUp.SharedKernel/Ai/IModelClient.cs @@ -0,0 +1,21 @@ +namespace TeamUp.SharedKernel.Ai; + +/// One model invocation. The key is passed explicitly (BYOK, server-side only). +public sealed record ModelRequest( + string Provider, + string Model, + string ApiKey, + string? Endpoint, + string Prompt, + int MaxTokens = 256); + +public sealed record ModelCompletion(bool Success, string? Text, string? Error, long LatencyMs); + +/// +/// Provider-agnostic model client. Implemented in Integrations (a router over per-provider HTTP +/// adapters). Used by the M3 BYOK test call and the M4 assembler. BYOK — never resells tokens. +/// +public interface IModelClient +{ + Task CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default); +} diff --git a/tests/TeamUp.IntegrationTests/ByokTests.cs b/tests/TeamUp.IntegrationTests/ByokTests.cs new file mode 100644 index 0000000..1315975 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/ByokTests.cs @@ -0,0 +1,125 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// M3 BYOK acceptance: an owner adds an API config (key encrypted, never returned by any endpoint), +/// a connection test succeeds, and a non-owner Member cannot create or list configs. +/// +public sealed class ByokTests(PostgresFixture postgres) : IClassFixture +{ + private const string SecretKey = "sk-teamup-test-deadbeef-do-not-leak"; + + private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); + + private sealed record AuthResponse(string Token, Guid MemberId); + + private sealed record InviteResponse(Guid InvitationId, string Token); + + private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint); + + private sealed record TestResultDto(bool Success, string? Error, long LatencyMs, string? Sample); + + [Fact] + public async Task Owner_adds_config_key_never_returned_test_succeeds_member_forbidden() + { + await using var factory = new TeamUpWebFactory(postgres.ConnectionString); + using var anon = factory.CreateClient(); + + var owner = await Bootstrap(anon); + using var ownerClient = Authed(factory, owner.Token); + + // Owner creates a config (stub provider, no network). The key must NOT appear in the response. + var createResponse = await ownerClient.PostAsJsonAsync("/api/integrations/api-configs", new + { + organizationId = owner.OrganizationId, + name = "Stub-Pro", + provider = "stub", + model = "test-model", + apiKey = SecretKey, + }); + Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode); + Assert.DoesNotContain(SecretKey, await createResponse.Content.ReadAsStringAsync()); + var config = await createResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(config); + Assert.Equal("stub", config!.Provider); + + // Listing returns the config but never the key. + var listResponse = await ownerClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}"); + Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode); + var listBody = await listResponse.Content.ReadAsStringAsync(); + Assert.DoesNotContain(SecretKey, listBody); + Assert.Contains(config.Id.ToString(), listBody); + + // The connection test succeeds (stub uses the decrypted key server-side; never echoes it). + var test = await ownerClient.PostAsync($"/api/integrations/api-configs/{config.Id}/test", content: null); + Assert.Equal(HttpStatusCode.OK, test.StatusCode); + var testBody = await test.Content.ReadAsStringAsync(); + Assert.DoesNotContain(SecretKey, testBody); + var result = await test.Content.ReadFromJsonAsync(); + Assert.True(result!.Success); + + // A Member cannot manage or even list BYOK configs. + var member = await InviteMember(ownerClient, anon, owner.OrganizationId); + using var memberClient = Authed(factory, member.Token); + + var memberCreate = await memberClient.PostAsJsonAsync("/api/integrations/api-configs", new + { + organizationId = owner.OrganizationId, + name = "Nope", + provider = "stub", + model = "x", + apiKey = "sk-nope", + }); + Assert.Equal(HttpStatusCode.Forbidden, memberCreate.StatusCode); + + var memberList = await memberClient.GetAsync($"/api/integrations/api-configs?organizationId={owner.OrganizationId}"); + Assert.Equal(HttpStatusCode.Forbidden, memberList.StatusCode); + } + + private static async Task Bootstrap(HttpClient client) + { + var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new + { + organizationName = "AliaSaaS", + ownerEmail = "owner@alia.test", + ownerDisplayName = "Owner", + ownerPassword = "Passw0rd!", + }); + var owner = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(owner); + return owner!; + } + + private static async Task InviteMember(HttpClient ownerClient, HttpClient anon, Guid organizationId) + { + var invite = await ownerClient.PostAsJsonAsync("/api/identity/invitations", new + { + email = "dev@alia.test", + scopeType = "Organization", + scopeId = organizationId, + role = "Member", + organizationId, + }); + var inviteResponse = await invite.Content.ReadFromJsonAsync(); + var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new + { + token = inviteResponse!.Token, + displayName = "Dev", + password = "Passw0rd!", + }); + var member = await accept.Content.ReadFromJsonAsync(); + Assert.NotNull(member); + return member!; + } + + private static HttpClient Authed(TeamUpWebFactory factory, string token) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } +} From e202246a1c7aeb808c0319d129157b91e18037ca Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Tue, 9 Jun 2026 23:49:28 +0330 Subject: [PATCH 2/3] =?UTF-8?q?M3:=20Agent=20bound=20to=20a=20seat=20?= =?UTF-8?q?=E2=80=94=20configure=20an=20AI=20seat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OrgBoard: Agent entity (name, monogram, autonomy dial, ApiConfigId + optional fallback, skill keys, docs) + AddAgents migration; one agent per seat. References Skills by key and the BYOK config by id — never reaches into those modules. Endpoints: POST/GET /api/orgboard/seats (create/list seats), POST/GET /api/orgboard/seats/{id}/agent (configure/read the agent) — ConfigureAgents at [team, org]. Configuring an agent flips the seat to the AI state and points it at the agent; audited. Verified: build green; ArchitectureTests 8/8; IntegrationTests 27/27 incl. the M3 acceptance flow — owner adds a BYOK config, then configures "Aria" (gated autonomy, skills, that config) on a seat, flipping it to AI, with the key never exposed. Co-Authored-By: Claude Opus 4.8 --- .../TeamUp.Modules.OrgBoard/Domain/Agent.cs | 54 +++++ .../TeamUp.Modules.OrgBoard/Domain/Seat.cs | 7 + .../Endpoints/OrgBoardDtos.cs | 25 ++ .../Endpoints/OrgBoardEndpoints.cs | 128 +++++++++++ .../20260609200923_AddAgents.Designer.cs | 217 ++++++++++++++++++ .../Migrations/20260609200923_AddAgents.cs | 53 +++++ .../OrgBoardDbContextModelSnapshot.cs | 52 +++++ .../Persistence/OrgBoardDbContext.cs | 11 + tests/Directory.Build.props | 2 +- .../SeatConfigTests.cs | 110 +++++++++ 10 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs create mode 100644 tests/TeamUp.IntegrationTests/SeatConfigTests.cs diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs new file mode 100644 index 0000000..3795b96 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Agent.cs @@ -0,0 +1,54 @@ +using TeamUp.SharedKernel.Access; +using TeamUp.SharedKernel.Domain; + +namespace TeamUp.Modules.OrgBoard.Domain; + +/// +/// The AI staffing an open seat: identity (name, monogram) + matched skill atoms + autonomy + +/// the model config + docs. References Skills by key and the BYOK ApiConfig by id — never reaches +/// into those modules' tables. One agent per seat. +/// +internal sealed class Agent : Entity +{ + public Guid SeatId { get; private set; } + public string Name { get; private set; } = null!; + public string? Monogram { get; private set; } + public Autonomy Autonomy { get; private set; } + public Guid ApiConfigId { get; private set; } + public Guid? FallbackApiConfigId { get; private set; } + public List SkillKeys { get; private set; } = []; + public List Docs { get; private set; } = []; + public DateTimeOffset CreatedAtUtc { get; private set; } + public DateTimeOffset UpdatedAtUtc { get; private set; } + + private Agent() + { + } + + public Agent(Guid seatId, DateTimeOffset createdAtUtc) + { + SeatId = seatId; + CreatedAtUtc = createdAtUtc; + UpdatedAtUtc = createdAtUtc; + } + + public void Configure( + string name, + string? monogram, + Autonomy autonomy, + Guid apiConfigId, + Guid? fallbackApiConfigId, + List skillKeys, + List docs, + DateTimeOffset nowUtc) + { + Name = name; + Monogram = monogram; + Autonomy = autonomy; + ApiConfigId = apiConfigId; + FallbackApiConfigId = fallbackApiConfigId; + SkillKeys = skillKeys; + Docs = docs; + UpdatedAtUtc = nowUtc; + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs index 6ddd707..de5850c 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Domain/Seat.cs @@ -38,4 +38,11 @@ internal sealed class Seat : Entity AgentId = null; State = SeatState.Open; } + + public void AssignAgent(Guid agentId) + { + AgentId = agentId; + MemberId = null; + State = SeatState.Ai; + } } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs index 77933a1..dd58e90 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardDtos.cs @@ -1,4 +1,5 @@ using TeamUp.Modules.OrgBoard.Domain; +using TeamUp.SharedKernel.Access; namespace TeamUp.Modules.OrgBoard.Endpoints; @@ -30,3 +31,27 @@ internal sealed record TaskResponse( internal sealed record BoardColumn(string Status, IReadOnlyList Items); internal sealed record BoardResponse(Guid TeamId, IReadOnlyList Columns); + +internal sealed record CreateSeatRequest(Guid TeamId, string RoleName); + +internal sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); + +internal sealed record ConfigureAgentRequest( + string Name, + string? Monogram, + Autonomy Autonomy, + Guid ApiConfigId, + Guid? FallbackApiConfigId, + List SkillKeys, + List Docs); + +internal sealed record AgentResponse( + Guid Id, + Guid SeatId, + string Name, + string? Monogram, + string Autonomy, + Guid ApiConfigId, + Guid? FallbackApiConfigId, + List SkillKeys, + List Docs); diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs index aa46d03..c6b0eeb 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Endpoints/OrgBoardEndpoints.cs @@ -25,6 +25,11 @@ internal static class OrgBoardEndpoints group.MapPatch("/tasks/{id:guid}/move", MoveTask).RequireAuthorization(); group.MapPatch("/tasks/{id:guid}/assign", AssignTask).RequireAuthorization(); group.MapGet("/cartable", Cartable).RequireAuthorization(); + + group.MapPost("/seats", CreateSeat).RequireAuthorization(); + group.MapGet("/seats", ListSeats).RequireAuthorization(); + group.MapPost("/seats/{id:guid}/agent", ConfigureAgent).RequireAuthorization(); + group.MapGet("/seats/{id:guid}/agent", GetAgent).RequireAuthorization(); } private static TaskResponse ToResponse(WorkItem item) => new( @@ -225,4 +230,127 @@ internal static class OrgBoardEndpoints return (item, team, null); } + + private static SeatResponse ToSeat(Seat seat) => + new(seat.Id, seat.TeamId, seat.RoleName, seat.State.ToString(), seat.MemberId, seat.AgentId); + + private static AgentResponse ToAgent(Agent agent) => new( + agent.Id, agent.SeatId, agent.Name, agent.Monogram, agent.Autonomy.ToString(), + agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.Docs); + + private static async Task CreateSeat( + CreateSeatRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == request.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.RoleName)) + { + return Results.BadRequest("RoleName is required."); + } + + var seat = new Seat(team.Id, request.RoleName.Trim(), SeatState.Open, clock.GetUtcNow()); + db.Seats.Add(seat); + await db.SaveChangesAsync(ct); + await audit.WriteAsync(new AuditEvent("seat.created", "Seat", seat.Id, user.MemberId, request.RoleName), ct); + return Results.Ok(ToSeat(seat)); + } + + private static async Task ListSeats( + Guid teamId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == teamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + var seats = await db.Seats.Where(s => s.TeamId == teamId).OrderBy(s => s.CreatedAtUtc).ToListAsync(ct); + return Results.Ok(seats.Select(ToSeat).ToList()); + } + + private static async Task ConfigureAgent( + Guid id, ConfigureAgentRequest request, ICurrentUser user, IPermissionService permissions, + IAuditLog audit, OrgBoardDbContext db, TimeProvider clock, CancellationToken ct) + { + var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct); + if (seat is null) + { + return Results.NotFound("Seat not found."); + } + + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ConfigureAgents, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + if (string.IsNullOrWhiteSpace(request.Name) || request.ApiConfigId == Guid.Empty) + { + return Results.BadRequest("Name and apiConfigId are required."); + } + + var now = clock.GetUtcNow(); + var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct); + var isNew = agent is null; + agent ??= new Agent(seat.Id, now); + agent.Configure( + request.Name.Trim(), request.Monogram, request.Autonomy, request.ApiConfigId, + request.FallbackApiConfigId, request.SkillKeys ?? [], request.Docs ?? [], now); + + if (isNew) + { + db.Agents.Add(agent); + } + + seat.AssignAgent(agent.Id); + await db.SaveChangesAsync(ct); + await audit.WriteAsync(new AuditEvent("agent.configured", "Agent", agent.Id, user.MemberId, agent.Name), ct); + return Results.Ok(ToAgent(agent)); + } + + private static async Task GetAgent( + Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) + { + var seat = await db.Seats.FirstOrDefaultAsync(s => s.Id == id, ct); + if (seat is null) + { + return Results.NotFound("Seat not found."); + } + + var team = await db.Teams.FirstOrDefaultAsync(t => t.Id == seat.TeamId, ct); + if (team is null) + { + return Results.NotFound("Team not found."); + } + + if (!permissions.Has(Capability.ViewBoard, ScopeRef.Team(team.Id), ScopeRef.Org(team.OrganizationId))) + { + return Results.Forbid(); + } + + var agent = await db.Agents.FirstOrDefaultAsync(a => a.SeatId == seat.Id, ct); + return agent is null + ? Results.NotFound("Seat has no agent configured.") + : Results.Ok(ToAgent(agent)); + } } diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs new file mode 100644 index 0000000..0c93bf9 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.Designer.cs @@ -0,0 +1,217 @@ +// +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 TeamUp.Modules.OrgBoard.Persistence; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + [DbContext(typeof(OrgBoardDbContext))] + [Migration("20260609200923_AddAgents")] + partial class AddAgents + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("orgboard") + .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SeatId") + .IsUnique(); + + b.ToTable("agents", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("organizations", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AgentId") + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MemberId") + .HasColumnType("uuid"); + + b.Property("RoleName") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("State") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.ToTable("seats", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OrganizationId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("OrganizationId"); + + b.ToTable("teams", "orgboard"); + }); + + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssigneeId") + .HasColumnType("uuid"); + + b.Property("AssigneeKind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByMemberId") + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("ParentId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("TeamId") + .HasColumnType("uuid"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TeamId"); + + b.HasIndex("AssigneeKind", "AssigneeId"); + + b.ToTable("work_items", "orgboard"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs new file mode 100644 index 0000000..2780fc3 --- /dev/null +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/20260609200923_AddAgents.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace TeamUp.Modules.OrgBoard.Persistence.Migrations +{ + /// + public partial class AddAgents : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "agents", + schema: "orgboard", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + SeatId = table.Column(type: "uuid", nullable: false), + Name = table.Column(type: "character varying(120)", maxLength: 120, nullable: false), + Monogram = table.Column(type: "character varying(8)", maxLength: 8, nullable: true), + Autonomy = table.Column(type: "character varying(20)", maxLength: 20, nullable: false), + ApiConfigId = table.Column(type: "uuid", nullable: false), + FallbackApiConfigId = table.Column(type: "uuid", nullable: true), + SkillKeys = table.Column>(type: "text[]", nullable: false), + Docs = table.Column>(type: "text[]", nullable: false), + CreatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAtUtc = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_agents", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_agents_SeatId", + schema: "orgboard", + table: "agents", + column: "SeatId", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "agents", + schema: "orgboard"); + } + } +} diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs index dd27b6e..66ac1b1 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/Migrations/OrgBoardDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -23,6 +24,57 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApiConfigId") + .HasColumnType("uuid"); + + b.Property("Autonomy") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection>("Docs") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("FallbackApiConfigId") + .HasColumnType("uuid"); + + b.Property("Monogram") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("character varying(120)"); + + b.Property("SeatId") + .HasColumnType("uuid"); + + b.PrimitiveCollection>("SkillKeys") + .IsRequired() + .HasColumnType("text[]"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SeatId") + .IsUnique(); + + b.ToTable("agents", "orgboard"); + }); + modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b => { b.Property("Id") diff --git a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs index 298de91..3af4055 100644 --- a/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs +++ b/src/Modules/TeamUp.Modules.OrgBoard/Persistence/OrgBoardDbContext.cs @@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti public DbSet Organizations => Set(); public DbSet Teams => Set(); public DbSet Seats => Set(); + public DbSet Agents => Set(); public DbSet WorkItems => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) @@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions opti seat.HasIndex(s => s.TeamId); }); + modelBuilder.Entity(agent => + { + agent.ToTable("agents"); + agent.HasKey(a => a.Id); + agent.Property(a => a.Name).HasMaxLength(120).IsRequired(); + agent.Property(a => a.Monogram).HasMaxLength(8); + agent.Property(a => a.Autonomy).HasConversion().HasMaxLength(20); + agent.HasIndex(a => a.SeatId).IsUnique(); + }); + modelBuilder.Entity(workItem => { workItem.ToTable("work_items"); diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props index 32fd14a..200503c 100644 --- a/tests/Directory.Build.props +++ b/tests/Directory.Build.props @@ -12,7 +12,7 @@ - $(NoWarn);CA1707;CA1711;xUnit1051 + $(NoWarn);CA1707;CA1711;CA1861;xUnit1051 diff --git a/tests/TeamUp.IntegrationTests/SeatConfigTests.cs b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs new file mode 100644 index 0000000..35bb467 --- /dev/null +++ b/tests/TeamUp.IntegrationTests/SeatConfigTests.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Xunit; + +namespace TeamUp.IntegrationTests; + +/// +/// M3 acceptance: an owner adds a BYOK config, then configures an AI seat ("Aria", gated autonomy, +/// a skill, that config) — flipping the seat to AI — without the key ever being exposed. +/// +public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture +{ + private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); + + private sealed record OrganizationResponse(Guid Id, string Name); + + private sealed record TeamResponse(Guid Id, Guid OrganizationId, string Name); + + private sealed record ApiConfigDto(Guid Id, string Name, string Provider, string Model, string? Endpoint); + + private sealed record SeatResponse(Guid Id, Guid TeamId, string RoleName, string State, Guid? MemberId, Guid? AgentId); + + private sealed record AgentResponse( + Guid Id, Guid SeatId, string Name, string? Monogram, string Autonomy, + Guid ApiConfigId, Guid? FallbackApiConfigId, List SkillKeys, List Docs); + + [Fact] + public async Task Owner_configures_an_ai_seat_with_skills_autonomy_and_byok_config() + { + await using var factory = new TeamUpWebFactory(postgres.ConnectionString); + using var anon = factory.CreateClient(); + + var owner = await Bootstrap(anon); + using var client = Authed(factory, owner.Token); + + await PostOk(client, "/api/orgboard/organizations", + new { organizationId = owner.OrganizationId, name = "AliaSaaS" }); + var team = await PostOk(client, "/api/orgboard/teams", + new { organizationId = owner.OrganizationId, name = "IPNOPS" }); + + var config = await PostOk(client, "/api/integrations/api-configs", new + { + organizationId = owner.OrganizationId, + name = "Vertex-Pro", + provider = "stub", + model = "gemini-pro", + apiKey = "sk-byok-secret", + }); + + // Create an open seat, then configure an AI agent on it. + var seat = await PostOk(client, "/api/orgboard/seats", + new { teamId = team.Id, roleName = "Product Owner" }); + Assert.Equal("Open", seat.State); + + var agent = await PostOk(client, $"/api/orgboard/seats/{seat.Id}/agent", new + { + name = "Aria", + monogram = "AR", + autonomy = "Gated", + apiConfigId = config.Id, + skillKeys = new[] { "spec-writing", "story-breakdown" }, + docs = new[] { "product-docs" }, + }); + Assert.Equal("Aria", agent.Name); + Assert.Equal("Gated", agent.Autonomy); + Assert.Equal(config.Id, agent.ApiConfigId); + Assert.Contains("spec-writing", agent.SkillKeys); + + // Reading it back returns the same configuration. + var fetched = await client.GetFromJsonAsync($"/api/orgboard/seats/{seat.Id}/agent"); + Assert.Equal(agent.Id, fetched!.Id); + + // The seat is now an AI seat pointing at the agent. + var seats = await client.GetFromJsonAsync>($"/api/orgboard/seats?teamId={team.Id}"); + var aiSeat = seats!.Single(s => s.Id == seat.Id); + Assert.Equal("Ai", aiSeat.State); + Assert.Equal(agent.Id, aiSeat.AgentId); + } + + private static async Task Bootstrap(HttpClient client) + { + var response = await client.PostAsJsonAsync("/api/identity/bootstrap", new + { + organizationName = "AliaSaaS", + ownerEmail = "owner@alia.test", + ownerDisplayName = "Owner", + ownerPassword = "Passw0rd!", + }); + var owner = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(owner); + return owner!; + } + + private static HttpClient Authed(TeamUpWebFactory factory, string token) + { + var client = factory.CreateClient(); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return client; + } + + private static async Task PostOk(HttpClient client, string url, object body) + { + var response = await client.PostAsJsonAsync(url, body); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var value = await response.Content.ReadFromJsonAsync(); + Assert.NotNull(value); + return value!; + } +} From b61bbbcc52007fbde5eb8a90d656730a74f90076 Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Wed, 10 Jun 2026 00:02:59 +0330 Subject: [PATCH 3/3] M3: seat configurator UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "AI seats" page (shadcn, on the design language): manage BYOK model connections (add + test; the key is write-only), create seats on a team, and configure an agent per seat — name, the color-graded autonomy dial (draft slate / gated indigo / auto teal), a model connection, skill toggles from the registry, and docs. Navigable AppShell sidebar (Board / AI seats). Verified: client `npm run build` clean (1890 modules, tsc + vite). --- client/src/App.tsx | 2 + client/src/components/AppShell.tsx | 43 ++-- client/src/pages/SeatsPage.tsx | 377 +++++++++++++++++++++++++++++ 3 files changed, 408 insertions(+), 14 deletions(-) create mode 100644 client/src/pages/SeatsPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 3c1d806..0f26b6b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -2,6 +2,7 @@ import { Navigate, Route, Routes } from 'react-router' import { Toaster } from '@/components/ui/sonner' import { BoardPage } from '@/pages/BoardPage' import { LoginPage } from '@/pages/LoginPage' +import { SeatsPage } from '@/pages/SeatsPage' import { useAuth } from '@/store/auth' export default function App() { @@ -12,6 +13,7 @@ export default function App() { : } /> : } /> + : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index 63b9042..d50714c 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -1,5 +1,6 @@ import type { ReactNode } from 'react' -import { Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react' +import { Link, useLocation } from 'react-router' +import { Bot, Inbox, type LucideIcon, LayoutDashboard, LogOut, Network } from 'lucide-react' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { cn } from '@/lib/utils' @@ -25,8 +26,9 @@ export function AppShell({ children }: { children: ReactNode }) { @@ -54,25 +56,38 @@ export function AppShell({ children }: { children: ReactNode }) { function NavItem({ icon: Icon, label, - active, + to, muted, }: { icon: LucideIcon label: string - active?: boolean + to?: string muted?: boolean }) { - return ( - + const location = useLocation() + const active = to ? location.pathname === to : false + + const className = cn( + 'flex items-center gap-3 rounded-md px-3 py-2 text-sm', + active ? 'bg-sidebar-accent font-medium text-sidebar-accent-foreground' : 'text-sidebar-foreground/80', + muted ? 'opacity-50' : 'hover:bg-sidebar-accent/60', + ) + + const content = ( + <> {label} {muted && soon} - + + ) + + if (!to || muted) { + return {content} + } + + return ( + + {content} + ) } diff --git a/client/src/pages/SeatsPage.tsx b/client/src/pages/SeatsPage.tsx new file mode 100644 index 0000000..0fe6dda --- /dev/null +++ b/client/src/pages/SeatsPage.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import { KeyRound, Plus, Bot, Wand2 } from 'lucide-react' +import { toast } from 'sonner' +import { AppShell } from '@/components/AppShell' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { cn } from '@/lib/utils' +import { api } from '@/lib/api' +import { useAuth } from '@/store/auth' + +interface Team { + id: string + name: string +} + +interface ApiConfig { + id: string + name: string + provider: string + model: string +} + +interface Seat { + id: string + teamId: string + roleName: string + state: string + agentId?: string | null +} + +interface Skill { + skillKey: string + name: string + roles: string[] + status: string +} + +interface Agent { + id: string + name: string + monogram?: string | null + autonomy: string + apiConfigId: string + skillKeys: string[] + docs: string[] +} + +const AUTONOMY = [ + { value: 'DraftOnly', label: 'Draft', on: 'bg-slate-600 text-white' }, + { value: 'Gated', label: 'Gated', on: 'bg-indigo-600 text-white' }, + { value: 'Autonomous', label: 'Auto', on: 'bg-teal-600 text-white' }, +] as const + +export function SeatsPage() { + const organizationId = useAuth((s) => s.organizationId) + + const [teams, setTeams] = useState([]) + const [teamId, setTeamId] = useState(null) + const [configs, setConfigs] = useState([]) + const [seats, setSeats] = useState([]) + const [skills, setSkills] = useState([]) + + const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + const [newSeat, setNewSeat] = useState('') + const [selectedSeat, setSelectedSeat] = useState(null) + const [agent, setAgent] = useState({ + name: '', + monogram: '', + autonomy: 'Gated', + apiConfigId: '', + skillKeys: [] as string[], + docs: '', + }) + + const run = useCallback(async (action: () => Promise) => { + try { + await action() + } catch (err) { + toast.error((err as Error).message) + } + }, []) + + const loadConfigs = useCallback(async () => { + if (!organizationId) return + setConfigs(await api.get(`/api/integrations/api-configs?organizationId=${organizationId}`)) + }, [organizationId]) + + const loadSeats = useCallback(async (id: string) => { + setSeats(await api.get(`/api/orgboard/seats?teamId=${id}`)) + }, []) + + useEffect(() => { + if (!organizationId) return + void run(async () => { + setTeams(await api.get(`/api/orgboard/teams?organizationId=${organizationId}`)) + setSkills(await api.get('/api/skills/')) + await loadConfigs() + }) + }, [organizationId, loadConfigs, run]) + + useEffect(() => { + if (teamId) void run(() => loadSeats(teamId)) + }, [teamId, loadSeats, run]) + + const createConfig = () => + run(async () => { + await api.post('/api/integrations/api-configs', { organizationId, ...cfg }) + setCfg({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' }) + await loadConfigs() + toast.success('API config saved (key encrypted).') + }) + + const testConfig = (id: string) => + run(async () => { + const result = await api.post<{ success: boolean; error?: string; latencyMs: number }>( + `/api/integrations/api-configs/${id}/test`, + ) + result.success + ? toast.success(`Test call succeeded (${result.latencyMs} ms).`) + : toast.error(`Test failed: ${result.error}`) + }) + + const createSeat = () => + run(async () => { + if (!teamId) return + await api.post('/api/orgboard/seats', { teamId, roleName: newSeat }) + setNewSeat('') + await loadSeats(teamId) + }) + + const selectSeat = (seat: Seat) => + run(async () => { + setSelectedSeat(seat.id) + const existing = seat.agentId + ? await api.get(`/api/orgboard/seats/${seat.id}/agent`).catch(() => null) + : null + setAgent( + existing + ? { + name: existing.name, + monogram: existing.monogram ?? '', + autonomy: existing.autonomy, + apiConfigId: existing.apiConfigId, + skillKeys: existing.skillKeys, + docs: existing.docs.join(', '), + } + : { name: '', monogram: '', autonomy: 'Gated', apiConfigId: configs[0]?.id ?? '', skillKeys: [], docs: '' }, + ) + }) + + const saveAgent = () => + run(async () => { + if (!selectedSeat) return + await api.post(`/api/orgboard/seats/${selectedSeat}/agent`, { + name: agent.name, + monogram: agent.monogram || null, + autonomy: agent.autonomy, + apiConfigId: agent.apiConfigId, + skillKeys: agent.skillKeys, + docs: agent.docs ? agent.docs.split(',').map((d) => d.trim()).filter(Boolean) : [], + }) + if (teamId) await loadSeats(teamId) + toast.success(`${agent.name || 'Agent'} configured — seat is now AI.`) + }) + + const toggleSkill = (key: string) => + setAgent((a) => ({ + ...a, + skillKeys: a.skillKeys.includes(key) ? a.skillKeys.filter((k) => k !== key) : [...a.skillKeys, key], + })) + + const selected = useMemo(() => seats.find((s) => s.id === selectedSeat) ?? null, [seats, selectedSeat]) + + return ( + +
+
+

AI seats

+

Connect a model (BYOK) and staff a seat with an AI agent.

+
+ + + + + Model connections (BYOK) + + Keys are encrypted server-side and never shown again after saving. + + +
+ + setCfg({ ...cfg, name: e.target.value })} className="w-40" placeholder="Vertex-Pro" /> + + + + + + setCfg({ ...cfg, model: e.target.value })} className="w-40" /> + + + setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" /> + + +
+
+ {configs.map((c) => ( +
+ {c.name} + {c.provider} · {c.model} + +
+ ))} + {configs.length === 0 &&

No model connections yet.

} +
+
+
+ + + + Team + + + + + + {teamId && ( + +
+ setNewSeat(e.target.value)} className="w-48" placeholder="Product Owner" /> + +
+
+ )} +
+
+ + {teamId && ( +
+ + + Seats + Pick a seat to configure its agent. + + + {seats.map((seat) => ( + + ))} + {seats.length === 0 &&

No seats yet.

} +
+
+ + + + + Agent + + + {selected ? `Configure “${selected.roleName}”` : 'Select a seat on the left.'} + + + {selected && ( + +
+ + setAgent({ ...agent, name: e.target.value })} className="w-40" placeholder="Aria" /> + + + setAgent({ ...agent, monogram: e.target.value })} className="w-20" placeholder="AR" /> + +
+ +
+ +
+ {AUTONOMY.map((a) => ( + + ))} +
+
+ + + + + +
+ +
+ {skills.map((skill) => ( + + ))} + {skills.length === 0 &&

No skills indexed yet.

} +
+
+ + + setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" /> + + + +
+ )} +
+
+ )} +
+
+ ) +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ + {children} +
+ ) +}