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;
+ }
+}