Merge M3: seat config + BYOK

Encrypted owner-only API configs (AES-256-GCM, key never returned), model adapters with a
connection test, the Agent bound to a seat (skills, autonomy dial, model config, docs) that
flips a seat to AI, and the seat-configurator UI. Verified: build green, ArchitectureTests
8/8, IntegrationTests 27/27, client build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 00:02:59 +03:30
33 changed files with 1893 additions and 33 deletions
+2
View File
@@ -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]
+2
View File
@@ -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() {
<Routes>
<Route path="/login" element={token ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/" element={token ? <BoardPage /> : <Navigate to="/login" replace />} />
<Route path="/seats" element={token ? <SeatsPage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
<Toaster richColors position="top-right" />
+27 -12
View File
@@ -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 }) {
<Separator className="bg-sidebar-border" />
<nav className="flex flex-1 flex-col gap-1 p-3">
<NavItem icon={LayoutDashboard} label="Board" active />
<NavItem icon={Inbox} label="Cartable" />
<NavItem icon={LayoutDashboard} label="Board" to="/" />
<NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={Inbox} label="Cartable" muted />
<NavItem icon={Network} label="Org chart" muted />
</nav>
@@ -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 (
<span
className={cn(
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',
)}
>
muted ? 'opacity-50' : 'hover:bg-sidebar-accent/60',
)
const content = (
<>
<Icon className="size-4" />
{label}
{muted && <span className="ml-auto text-[10px] uppercase tracking-wide opacity-70">soon</span>}
</span>
</>
)
if (!to || muted) {
return <span className={className}>{content}</span>
}
return (
<Link to={to} className={className}>
{content}
</Link>
)
}
+377
View File
@@ -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<Team[]>([])
const [teamId, setTeamId] = useState<string | null>(null)
const [configs, setConfigs] = useState<ApiConfig[]>([])
const [seats, setSeats] = useState<Seat[]>([])
const [skills, setSkills] = useState<Skill[]>([])
const [cfg, setCfg] = useState({ name: '', provider: 'stub', model: 'gpt-4o-mini', apiKey: '' })
const [newSeat, setNewSeat] = useState('')
const [selectedSeat, setSelectedSeat] = useState<string | null>(null)
const [agent, setAgent] = useState({
name: '',
monogram: '',
autonomy: 'Gated',
apiConfigId: '',
skillKeys: [] as string[],
docs: '',
})
const run = useCallback(async (action: () => Promise<unknown>) => {
try {
await action()
} catch (err) {
toast.error((err as Error).message)
}
}, [])
const loadConfigs = useCallback(async () => {
if (!organizationId) return
setConfigs(await api.get<ApiConfig[]>(`/api/integrations/api-configs?organizationId=${organizationId}`))
}, [organizationId])
const loadSeats = useCallback(async (id: string) => {
setSeats(await api.get<Seat[]>(`/api/orgboard/seats?teamId=${id}`))
}, [])
useEffect(() => {
if (!organizationId) return
void run(async () => {
setTeams(await api.get<Team[]>(`/api/orgboard/teams?organizationId=${organizationId}`))
setSkills(await api.get<Skill[]>('/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<Agent>(`/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 (
<AppShell>
<div className="mx-auto flex max-w-5xl flex-col gap-6 p-6">
<header>
<h1 className="text-2xl font-semibold tracking-tight">AI seats</h1>
<p className="text-sm text-muted-foreground">Connect a model (BYOK) and staff a seat with an AI agent.</p>
</header>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<KeyRound className="size-4" /> Model connections (BYOK)
</CardTitle>
<CardDescription>Keys are encrypted server-side and never shown again after saving.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={cfg.name} onChange={(e) => setCfg({ ...cfg, name: e.target.value })} className="w-40" placeholder="Vertex-Pro" />
</Field>
<Field label="Provider">
<Select value={cfg.provider} onValueChange={(v) => setCfg({ ...cfg, provider: v })}>
<SelectTrigger className="w-36"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{['stub', 'openai', 'anthropic', 'vertex', 'ollama'].map((p) => (
<SelectItem key={p} value={p}>{p}</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<Field label="Model">
<Input value={cfg.model} onChange={(e) => setCfg({ ...cfg, model: e.target.value })} className="w-40" />
</Field>
<Field label="API key">
<Input type="password" value={cfg.apiKey} onChange={(e) => setCfg({ ...cfg, apiKey: e.target.value })} className="w-44" placeholder="sk-…" />
</Field>
<Button onClick={createConfig}><Plus data-icon="inline-start" />Add</Button>
</div>
<div className="flex flex-col gap-2">
{configs.map((c) => (
<div key={c.id} className="flex items-center justify-between rounded-md border px-3 py-2 text-sm">
<span className="font-medium">{c.name}</span>
<span className="text-muted-foreground">{c.provider} · {c.model}</span>
<Button variant="outline" size="sm" onClick={() => testConfig(c.id)}>Test</Button>
</div>
))}
{configs.length === 0 && <p className="text-sm text-muted-foreground">No model connections yet.</p>}
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Team</CardTitle>
</CardHeader>
<CardContent className="flex flex-wrap items-end gap-3">
<Field label="Team">
<Select value={teamId ?? ''} onValueChange={(v) => setTeamId(v || null)}>
<SelectTrigger className="w-56"><SelectValue placeholder="Select a team" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{teams.map((t) => <SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
{teamId && (
<Field label="New seat (role)">
<div className="flex gap-2">
<Input value={newSeat} onChange={(e) => setNewSeat(e.target.value)} className="w-48" placeholder="Product Owner" />
<Button onClick={createSeat}><Plus data-icon="inline-start" />Create</Button>
</div>
</Field>
)}
</CardContent>
</Card>
{teamId && (
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">Seats</CardTitle>
<CardDescription>Pick a seat to configure its agent.</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-2">
{seats.map((seat) => (
<button
key={seat.id}
onClick={() => selectSeat(seat)}
className={cn(
'flex items-center justify-between rounded-md border px-3 py-2 text-left text-sm',
selectedSeat === seat.id && 'border-indigo-500 ring-1 ring-indigo-500',
)}
>
<span className="font-medium">{seat.roleName}</span>
<Badge variant={seat.state === 'Ai' ? 'default' : 'secondary'}>{seat.state}</Badge>
</button>
))}
{seats.length === 0 && <p className="text-sm text-muted-foreground">No seats yet.</p>}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Bot className="size-4" /> Agent
</CardTitle>
<CardDescription>
{selected ? `Configure “${selected.roleName}` : 'Select a seat on the left.'}
</CardDescription>
</CardHeader>
{selected && (
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-end gap-3">
<Field label="Name">
<Input value={agent.name} onChange={(e) => setAgent({ ...agent, name: e.target.value })} className="w-40" placeholder="Aria" />
</Field>
<Field label="Monogram">
<Input value={agent.monogram} onChange={(e) => setAgent({ ...agent, monogram: e.target.value })} className="w-20" placeholder="AR" />
</Field>
</div>
<div className="flex flex-col gap-2">
<Label>Autonomy</Label>
<div className="flex gap-2">
{AUTONOMY.map((a) => (
<button
key={a.value}
onClick={() => setAgent({ ...agent, autonomy: a.value })}
className={cn(
'rounded-md border px-3 py-1.5 text-sm',
agent.autonomy === a.value ? a.on : 'text-muted-foreground',
)}
>
{a.label}
</button>
))}
</div>
</div>
<Field label="Model connection">
<Select value={agent.apiConfigId} onValueChange={(v) => setAgent({ ...agent, apiConfigId: v })}>
<SelectTrigger className="w-64"><SelectValue placeholder="Pick a connection" /></SelectTrigger>
<SelectContent>
<SelectGroup>
{configs.map((c) => <SelectItem key={c.id} value={c.id}>{c.name} · {c.model}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
</Field>
<div className="flex flex-col gap-2">
<Label>Skills</Label>
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<button key={skill.skillKey} onClick={() => toggleSkill(skill.skillKey)}>
<Badge variant={agent.skillKeys.includes(skill.skillKey) ? 'default' : 'outline'}>
{skill.name}
</Badge>
</button>
))}
{skills.length === 0 && <p className="text-sm text-muted-foreground">No skills indexed yet.</p>}
</div>
</div>
<Field label="Docs (comma-separated)">
<Input value={agent.docs} onChange={(e) => setAgent({ ...agent, docs: e.target.value })} placeholder="product-docs, house-style" />
</Field>
<Button onClick={saveAgent} className="self-start">
<Wand2 data-icon="inline-start" />
Save agent
</Button>
</CardContent>
)}
</Card>
</div>
)}
</div>
</AppShell>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<Label>{label}</Label>
{children}
</div>
)
}
+3
View File
@@ -11,6 +11,9 @@
"Audience": "teamup",
"ExpiryMinutes": 480
},
"Encryption": {
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
+3
View File
@@ -11,6 +11,9 @@
"Audience": "teamup",
"ExpiryMinutes": 480
},
"Encryption": {
"MasterKey": "dev-only-teamup-master-secret-change-in-production"
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
@@ -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;
/// <summary>Resolves a BYOK config and decrypts its key — server-side only.</summary>
internal sealed class ApiConfigResolver(IntegrationsDbContext db, ISecretProtector protector) : IApiConfigResolver
{
public async Task<ResolvedApiConfig?> 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));
}
}
@@ -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;
/// <summary>No-network adapter for the "stub"/"echo" provider — used by tests and dogfood without keys.</summary>
internal sealed class StubModelClient : IModelClient
{
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
Task.FromResult(new ModelCompletion(
Success: true,
Text: $"[stub {request.Provider}/{request.Model}] {request.Prompt}",
Error: null,
LatencyMs: 0));
}
/// <summary>
/// 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.
/// </summary>
internal sealed class OpenAiCompatibleModelClient(HttpClient http) : IModelClient
{
public async Task<ModelCompletion> 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<JsonElement>(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);
}
}
}
/// <summary>Routes a request to the adapter for its provider.</summary>
internal sealed class ModelClientRouter(StubModelClient stub, OpenAiCompatibleModelClient openAi) : IModelClient
{
public Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default) =>
request.Provider.ToLowerInvariant() switch
{
"stub" or "echo" or "test" => stub.CompleteAsync(request, cancellationToken),
_ => openAi.CompleteAsync(request, cancellationToken),
};
}
@@ -0,0 +1,44 @@
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.Integrations.Domain;
/// <summary>
/// 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.
/// </summary>
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;
}
}
@@ -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);
/// <summary>Public view of a config — never includes the key.</summary>
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);
@@ -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<IResult> 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<IResult> 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<IResult> 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<IResult> 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);
}
@@ -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;
/// <summary>
/// BYOK API configs, the Git connection, the encrypted-credential store. In M2 it provides the
/// <see cref="IGitProvider"/> (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.
/// </summary>
public sealed class IntegrationsModule : IModule
{
@@ -19,10 +25,26 @@ public sealed class IntegrationsModule : IModule
public void Register(IServiceCollection services, IConfiguration configuration)
{
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
var options = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? 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<IntegrationsDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<IntegrationsDbContext>());
services.Configure<EncryptionOptions>(configuration.GetSection(EncryptionOptions.SectionName));
services.AddSingleton<ISecretProtector, AesGcmSecretProtector>();
services.AddScoped<IApiConfigResolver, ApiConfigResolver>();
services.TryAddSingleton(TimeProvider.System);
// Model clients: a router over per-provider adapters.
services.AddSingleton<StubModelClient>();
services.AddHttpClient<OpenAiCompatibleModelClient>();
services.AddScoped<IModelClient, ModelClientRouter>();
// Git source (M2) — filesystem for dogfood, Gitea over REST when configured.
services.Configure<GitSourceOptions>(configuration.GetSection(GitSourceOptions.SectionName));
var gitOptions = configuration.GetSection(GitSourceOptions.SectionName).Get<GitSourceOptions>() ?? new GitSourceOptions();
if (string.Equals(gitOptions.Provider, "gitea", StringComparison.OrdinalIgnoreCase))
{
services.AddHttpClient<IGitProvider, GiteaGitProvider>();
}
@@ -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);
}
@@ -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<IntegrationsDbContext> options)
: DbContext(options), IModuleDbContext
{
public DbSet<ApiConfig> ApiConfigs => Set<ApiConfig>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.HasDefaultSchema("integrations");
modelBuilder.Entity<ApiConfig>(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();
});
}
}
@@ -0,0 +1,21 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
namespace TeamUp.Modules.Integrations.Persistence;
/// <summary>Design-time factory so `dotnet ef` can build the internal context without a host.</summary>
internal sealed class IntegrationsDbContextFactory : IDesignTimeDbContextFactory<IntegrationsDbContext>
{
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<IntegrationsDbContext>()
.UseNpgsql(connectionString)
.Options;
return new IntegrationsDbContext(options);
}
}
@@ -0,0 +1,79 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("EncryptedKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("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
}
}
}
@@ -0,0 +1,59 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Integrations.Persistence.Migrations
{
/// <inheritdoc />
public partial class InitialIntegrations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "integrations");
migrationBuilder.CreateTable(
name: "api_configs",
schema: "integrations",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Provider = table.Column<string>(type: "character varying(60)", maxLength: 60, nullable: false),
Model = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Endpoint = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: true),
EncryptedKey = table.Column<string>(type: "text", nullable: false),
CreatedByMemberId = table.Column<Guid>(type: "uuid", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "api_configs",
schema: "integrations");
}
}
}
@@ -0,0 +1,76 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("EncryptedKey")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Endpoint")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Model")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("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
}
}
}
@@ -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";
/// <summary>Deployment master secret. A 32-byte AES key is derived from it (SHA-256).</summary>
public string MasterKey { get; set; } = string.Empty;
}
internal interface ISecretProtector
{
string Protect(string plaintext);
string Unprotect(string protectedValue);
}
/// <summary>
/// AES-256-GCM authenticated encryption with a key derived from the deployment master secret.
/// Output blob = nonce(12) ‖ tag(16) ‖ ciphertext, base64-encoded.
/// </summary>
internal sealed class AesGcmSecretProtector : ISecretProtector
{
private const int NonceSize = 12;
private const int TagSize = 16;
private readonly byte[] _key;
public AesGcmSecretProtector(IOptions<EncryptionOptions> 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);
}
}
@@ -1,12 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- A self-contained module. References SharedKernel ONLY (ASP.NET flows transitively for the
IModule seam). M1 adds this module's EF/Npgsql/FluentValidation/Mapperly packages when it
gains an (internal) DbContext and validators. It must never reference another module.
NOTE: the AI model-client packages (Microsoft.Extensions.AI, ONNX) are deferred to M3-M4;
this module exposes only seam interfaces in V1, no concrete model client. -->
<!-- BYOK API configs, the Git connection, the encrypted-credential store (M3). References
SharedKernel only. The Git provider uses framework HttpClient; the BYOK store uses EF Core.
Model calls go through thin HTTP adapters (no Microsoft.Extensions.AI dependency yet). -->
<ItemGroup>
<ProjectReference Include="..\..\Shared\TeamUp.SharedKernel\TeamUp.SharedKernel.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" PrivateAssets="all" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
</ItemGroup>
</Project>
@@ -0,0 +1,54 @@
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Domain;
namespace TeamUp.Modules.OrgBoard.Domain;
/// <summary>
/// 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.
/// </summary>
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<string> SkillKeys { get; private set; } = [];
public List<string> 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<string> skillKeys,
List<string> docs,
DateTimeOffset nowUtc)
{
Name = name;
Monogram = monogram;
Autonomy = autonomy;
ApiConfigId = apiConfigId;
FallbackApiConfigId = fallbackApiConfigId;
SkillKeys = skillKeys;
Docs = docs;
UpdatedAtUtc = nowUtc;
}
}
@@ -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;
}
}
@@ -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<TaskResponse> Items);
internal sealed record BoardResponse(Guid TeamId, IReadOnlyList<BoardColumn> 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<string> SkillKeys,
List<string> Docs);
internal sealed record AgentResponse(
Guid Id,
Guid SeatId,
string Name,
string? Monogram,
string Autonomy,
Guid ApiConfigId,
Guid? FallbackApiConfigId,
List<string> SkillKeys,
List<string> Docs);
@@ -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<IResult> 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<IResult> 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<IResult> 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<IResult> 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));
}
}
@@ -0,0 +1,217 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.OrgBoard.Persistence;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
[DbContext(typeof(OrgBoardDbContext))]
[Migration("20260609200923_AddAgents")]
partial class AddAgents
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("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
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddAgents : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "agents",
schema: "orgboard",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
SeatId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "character varying(120)", maxLength: 120, nullable: false),
Monogram = table.Column<string>(type: "character varying(8)", maxLength: 8, nullable: true),
Autonomy = table.Column<string>(type: "character varying(20)", maxLength: 20, nullable: false),
ApiConfigId = table.Column<Guid>(type: "uuid", nullable: false),
FallbackApiConfigId = table.Column<Guid>(type: "uuid", nullable: true),
SkillKeys = table.Column<List<string>>(type: "text[]", nullable: false),
Docs = table.Column<List<string>>(type: "text[]", nullable: false),
CreatedAtUtc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
UpdatedAtUtc = table.Column<DateTimeOffset>(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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "agents",
schema: "orgboard");
}
}
}
@@ -1,5 +1,6 @@
// <auto-generated />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("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<Guid>("Id")
@@ -10,6 +10,7 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
public DbSet<Organization> Organizations => Set<Organization>();
public DbSet<Team> Teams => Set<Team>();
public DbSet<Seat> Seats => Set<Seat>();
public DbSet<Agent> Agents => Set<Agent>();
public DbSet<WorkItem> WorkItems => Set<WorkItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -40,6 +41,16 @@ internal sealed class OrgBoardDbContext(DbContextOptions<OrgBoardDbContext> opti
seat.HasIndex(s => s.TeamId);
});
modelBuilder.Entity<Agent>(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<string>().HasMaxLength(20);
agent.HasIndex(a => a.SeatId).IsUnique();
});
modelBuilder.Entity<WorkItem>(workItem =>
{
workItem.ToTable("work_items");
@@ -0,0 +1,12 @@
namespace TeamUp.SharedKernel.Access;
/// <summary>
/// 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.
/// </summary>
public enum Autonomy
{
DraftOnly,
Gated,
Autonomous,
}
@@ -0,0 +1,16 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>Non-sensitive BYOK config info (no key) — safe to list/return to clients.</summary>
public sealed record ApiConfigSummary(Guid Id, string Name, string Provider, string Model);
/// <summary>A resolved config including the decrypted key. Server-side only — never serialized to a client.</summary>
public sealed record ResolvedApiConfig(Guid Id, string Name, string Provider, string Model, string? Endpoint, string ApiKey);
/// <summary>
/// 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.
/// </summary>
public interface IApiConfigResolver
{
Task<ResolvedApiConfig?> ResolveAsync(Guid apiConfigId, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,21 @@
namespace TeamUp.SharedKernel.Ai;
/// <summary>One model invocation. The key is passed explicitly (BYOK, server-side only).</summary>
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);
/// <summary>
/// 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.
/// </summary>
public interface IModelClient
{
Task<ModelCompletion> CompleteAsync(ModelRequest request, CancellationToken cancellationToken = default);
}
+1 -1
View File
@@ -12,7 +12,7 @@
<!-- Analyzer rules that fight idiomatic test code:
CA1707 underscored test names · CA1711 xUnit [CollectionDefinition] "Collection" suffix
· xUnit1051 TestContext cancellation token (not needed for these short tests). -->
<NoWarn>$(NoWarn);CA1707;CA1711;xUnit1051</NoWarn>
<NoWarn>$(NoWarn);CA1707;CA1711;CA1861;xUnit1051</NoWarn>
</PropertyGroup>
<ItemGroup>
+125
View File
@@ -0,0 +1,125 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// 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.
/// </summary>
public sealed class ByokTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
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<ApiConfigDto>();
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<TestResultDto>();
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<BootstrapResponse> 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<BootstrapResponse>();
Assert.NotNull(owner);
return owner!;
}
private static async Task<AuthResponse> 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<InviteResponse>();
var accept = await anon.PostAsJsonAsync("/api/identity/invitations/accept", new
{
token = inviteResponse!.Token,
displayName = "Dev",
password = "Passw0rd!",
});
var member = await accept.Content.ReadFromJsonAsync<AuthResponse>();
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;
}
}
@@ -0,0 +1,110 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// 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.
/// </summary>
public sealed class SeatConfigTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
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<string> SkillKeys, List<string> 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<OrganizationResponse>(client, "/api/orgboard/organizations",
new { organizationId = owner.OrganizationId, name = "AliaSaaS" });
var team = await PostOk<TeamResponse>(client, "/api/orgboard/teams",
new { organizationId = owner.OrganizationId, name = "IPNOPS" });
var config = await PostOk<ApiConfigDto>(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<SeatResponse>(client, "/api/orgboard/seats",
new { teamId = team.Id, roleName = "Product Owner" });
Assert.Equal("Open", seat.State);
var agent = await PostOk<AgentResponse>(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<AgentResponse>($"/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<List<SeatResponse>>($"/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<BootstrapResponse> 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<BootstrapResponse>();
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<T> PostOk<T>(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<T>();
Assert.NotNull(value);
return value!;
}
}