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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-13 11:09:02 +03:30
parent 414ff44b48
commit fad476f115
24 changed files with 1398 additions and 37 deletions
+2
View File
@@ -9,6 +9,7 @@ import { OrgChartPage } from '@/pages/OrgChartPage'
import { PerformancePage } from '@/pages/PerformancePage' import { PerformancePage } from '@/pages/PerformancePage'
import { ReviewsPage } from '@/pages/ReviewsPage' import { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage' import { SeatsPage } from '@/pages/SeatsPage'
import { SkillsPage } from '@/pages/SkillsPage'
import { StructurePage } from '@/pages/StructurePage' import { StructurePage } from '@/pages/StructurePage'
import { useAuth } from '@/store/auth' import { useAuth } from '@/store/auth'
@@ -27,6 +28,7 @@ export default function App() {
<Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} /> <Route path="/members" element={token ? <MembersPage /> : <Navigate to="/login" replace />} />
<Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} /> <Route path="/org" element={token ? <OrgChartPage /> : <Navigate to="/login" replace />} />
<Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} /> <Route path="/structure" element={token ? <StructurePage /> : <Navigate to="/login" replace />} />
<Route path="/skills" element={token ? <SkillsPage /> : <Navigate to="/login" replace />} />
<Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} /> <Route path="/performance" element={token ? <PerformancePage /> : <Navigate to="/login" replace />} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />
</Routes> </Routes>
+2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router' import { Link, useLocation } from 'react-router'
import { import {
BookMarked,
Bot, Bot,
Boxes, Boxes,
ChartColumn, ChartColumn,
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={Inbox} label="Cartable" to="/cartable" /> <NavItem icon={Inbox} label="Cartable" to="/cartable" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" /> <NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<NavItem icon={Bot} label="AI seats" to="/seats" /> <NavItem icon={Bot} label="AI seats" to="/seats" />
<NavItem icon={BookMarked} label="Skills" to="/skills" />
<NavItem icon={Network} label="Org chart" to="/org" /> <NavItem icon={Network} label="Org chart" to="/org" />
<NavItem icon={Boxes} label="Structure" to="/structure" /> <NavItem icon={Boxes} label="Structure" to="/structure" />
<NavItem icon={Users} label="Members" to="/members" /> <NavItem icon={Users} label="Members" to="/members" />
+523
View File
@@ -0,0 +1,523 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } 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 { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Textarea } from '@/components/ui/textarea'
import { api } from '@/lib/api'
import { useAuth } from '@/store/auth'
interface ActionDto {
name: string
risk: string
description?: string | null
}
interface GoldenTest {
input: string
expected: string
}
interface SkillSummary {
skillKey: string
name: string
version: string
summary: string | null
roles: string[]
visibility: string
minTier: string
status: string
origin: string
organizationId: string | null
goldenTestCount: number
actions: ActionDto[]
}
interface SkillDetail {
skill: SkillSummary
inputs: string | null
outputs: string | null
tools: string[]
context: string[]
goldenTests: GoldenTest[]
body: string
}
type Mode = 'new' | 'version' | 'edit'
interface FormState {
mode: Mode
skillKey: string
name: string
version: string
summary: string
roles: string
inputs: string
outputs: string
tools: string
context: string
visibility: string
minTier: string
body: string
actions: ActionDto[]
goldenTests: GoldenTest[]
}
const COMMON_ROLES = 'product-owner, engineer, qa, designer, analyst'
const RISKS = ['read', 'draft', 'publish', 'destructive']
const VISIBILITIES = ['public', 'private']
const TIERS = ['free', 'team', 'scale', 'enterprise']
const emptyForm = (): FormState => ({
mode: 'new',
skillKey: '',
name: '',
version: '1.0.0',
summary: '',
roles: '',
inputs: '',
outputs: '',
tools: '',
context: '',
visibility: 'public',
minTier: 'free',
body: '',
actions: [],
goldenTests: [],
})
/** Bump the last numeric segment of a semver-ish string (1.0.0 → 1.0.1). */
function bumpPatch(version: string): string {
const parts = version.split('.')
for (let i = parts.length - 1; i >= 0; i--) {
const n = Number(parts[i])
if (Number.isInteger(n)) {
parts[i] = String(n + 1)
return parts.join('.')
}
}
return `${version}.1`
}
const csv = (s: string): string[] =>
s.split(',').map((x) => x.trim()).filter(Boolean)
/** The org's skill library: builtin starter skills + skills the company authors and versions itself. */
export function SkillsPage() {
const organizationId = useAuth((s) => s.organizationId)
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
const [skills, setSkills] = useState<SkillSummary[]>([])
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
const [form, setForm] = useState<FormState | null>(null)
const [busy, setBusy] = useState(false)
const load = useCallback(async () => {
if (!organizationId) return
try {
const [lib, market] = await Promise.all([
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
api.get<SkillSummary[]>(`/api/skills/marketplace`),
])
setSkills(lib)
setMarketplace(market)
} catch (err) {
toast.error((err as Error).message)
}
}, [organizationId])
useEffect(() => {
void load()
}, [load])
// Group every version under its key; newest (and the org's own) first comes from the API order.
const groups = useMemo(() => {
const byKey = new Map<string, SkillSummary[]>()
for (const s of skills) {
const list = byKey.get(s.skillKey) ?? []
list.push(s)
byKey.set(s.skillKey, list)
}
return [...byKey.entries()].sort((a, b) => a[0].localeCompare(b[0]))
}, [skills])
const openForm = async (key: string, version: string, mode: Mode) => {
try {
const details = await api.get<SkillDetail[]>(`/api/skills/${key}?organizationId=${organizationId}`)
const d = details.find((x) => x.skill.version === version) ?? details[0]
if (!d) return
setForm({
mode,
skillKey: d.skill.skillKey,
name: d.skill.name,
version: mode === 'version' ? bumpPatch(d.skill.version) : d.skill.version,
summary: d.skill.summary ?? '',
roles: d.skill.roles.join(', '),
inputs: d.inputs ?? '',
outputs: d.outputs ?? '',
tools: d.tools.join(', '),
context: d.context.join(', '),
visibility: d.skill.visibility === 'PrivateToOrg' ? 'private' : 'public',
minTier: d.skill.minTier.toLowerCase(),
body: d.body,
actions: d.skill.actions.map((a) => ({ name: a.name, risk: a.risk.toLowerCase(), description: a.description ?? '' })),
goldenTests: d.goldenTests.map((g) => ({ ...g })),
})
} catch (err) {
toast.error((err as Error).message)
}
}
const fork = async (key: string, version: string) => {
setBusy(true)
try {
await api.post(`/api/skills/${key}/fork`, { organizationId, version })
toast.success(`Forked ${key} into your org — edit it to customize.`)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const save = async () => {
if (!form) return
setBusy(true)
try {
await api.post('/api/skills/authored', {
organizationId,
skillKey: form.skillKey.trim(),
name: form.name.trim(),
version: form.version.trim(),
summary: form.summary.trim() || null,
roles: csv(form.roles),
inputs: form.inputs.trim() || null,
outputs: form.outputs.trim() || null,
tools: csv(form.tools),
context: csv(form.context),
visibility: form.visibility,
minTier: form.minTier,
body: form.body,
actions: form.actions
.filter((a) => a.name.trim())
.map((a) => ({ name: a.name.trim(), risk: a.risk, description: a.description?.trim() || null })),
goldenTests: form.goldenTests.filter((g) => g.input.trim() && g.expected.trim()),
})
toast.success(`Saved ${form.skillKey}@${form.version}.`)
setForm(null)
await load()
} catch (err) {
toast.error((err as Error).message)
} finally {
setBusy(false)
}
}
const willPublish = !!form && csv(form.roles).length > 0 && form.goldenTests.some((g) => g.input.trim() && g.expected.trim())
return (
<AppShell>
<div className="mx-auto max-w-5xl p-6">
<header className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<BookMarked className="size-6" /> Skills
</h1>
<p className="text-sm text-muted-foreground">
Your company's skill library. Builtin starter skills are shared; author and version your own.
</p>
</div>
<Button onClick={() => setForm(emptyForm())}>
<Plus data-icon="inline-start" /> New skill
</Button>
</header>
<div className="mb-4 inline-flex rounded-lg border p-1">
<SegBtn active={tab === 'library'} onClick={() => setTab('library')} icon={BookMarked}>Library</SegBtn>
<SegBtn active={tab === 'marketplace'} onClick={() => setTab('marketplace')} icon={Store}>Marketplace</SegBtn>
</div>
{tab === 'library' ? (
<div className="flex flex-col gap-4">
{groups.map(([key, versions]) => (
<SkillGroupCard
key={key}
versions={versions}
busy={busy}
onNewVersion={(v) => openForm(key, v, 'version')}
onEdit={(v) => openForm(key, v, 'edit')}
onFork={(v) => fork(key, v)}
/>
))}
{groups.length === 0 && (
<p className="text-sm text-muted-foreground">No skills yet. Run a Git sync for builtins, or author one.</p>
)}
</div>
) : (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
Public skills shared by other organizations. One-click install lands in the next step.
</p>
{marketplace.map((s) => (
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
{s.name} <Badge variant="outline">{s.version}</Badge>
</CardTitle>
<CardDescription>{s.summary}</CardDescription>
</CardHeader>
<CardContent className="flex items-center gap-2">
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
</CardContent>
</Card>
))}
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
</div>
)}
</div>
{form && (
<Sheet open onOpenChange={(o) => !o && setForm(null)}>
<SheetContent className="flex w-full flex-col gap-0 overflow-y-auto sm:max-w-xl">
<SheetHeader>
<SheetTitle>
{form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`}
</SheetTitle>
<SheetDescription>
{willPublish
? 'Has roles + a golden test saves as Published.'
: 'Add 1 role and 1 golden test to publish; otherwise saved as Draft.'}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-4 px-4 pb-6">
<div className="grid grid-cols-2 gap-3">
<Field label="Skill key (id)">
<Input
value={form.skillKey}
disabled={form.mode !== 'new'}
onChange={(e) => setForm({ ...form, skillKey: e.target.value })}
placeholder="api-endpoint-design"
/>
</Field>
<Field label="Version">
<Input value={form.version} disabled={form.mode === 'edit'} onChange={(e) => setForm({ ...form, version: e.target.value })} />
</Field>
</div>
<Field label="Name">
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" />
</Field>
<Field label="Summary">
<Input value={form.summary} onChange={(e) => setForm({ ...form, summary: e.target.value })} />
</Field>
<Field label={`Roles (comma-separated — e.g. ${COMMON_ROLES})`}>
<Input value={form.roles} onChange={(e) => setForm({ ...form, roles: e.target.value })} placeholder="engineer" />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Inputs">
<Input value={form.inputs} onChange={(e) => setForm({ ...form, inputs: e.target.value })} />
</Field>
<Field label="Outputs">
<Input value={form.outputs} onChange={(e) => setForm({ ...form, outputs: e.target.value })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Visibility">
<Pick value={form.visibility} options={VISIBILITIES} onChange={(v) => setForm({ ...form, visibility: v })} />
</Field>
<Field label="Min tier">
<Pick value={form.minTier} options={TIERS} onChange={(v) => setForm({ ...form, minTier: v })} />
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label="Tools (comma-separated)">
<Input value={form.tools} onChange={(e) => setForm({ ...form, tools: e.target.value })} />
</Field>
<Field label="Context docs (comma-separated)">
<Input value={form.context} onChange={(e) => setForm({ ...form, context: e.target.value })} />
</Field>
</div>
<Repeater
label="Actions (risk-tagged)"
onAdd={() => setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })}
>
{form.actions.map((a, i) => (
<div key={i} className="flex items-center gap-2">
<Input
className="flex-1" placeholder="action name" value={a.name}
onChange={(e) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })}
/>
<Pick
value={a.risk} options={RISKS} className="w-32"
onChange={(v) => setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, actions: form.actions.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
))}
</Repeater>
<Repeater
label="Golden tests (gate publishing)"
onAdd={() => setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })}
>
{form.goldenTests.map((g, i) => (
<div key={i} className="flex flex-col gap-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<Input
className="flex-1" placeholder="input" value={g.input}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })}
/>
<Button size="icon" variant="ghost" onClick={() => setForm({ ...form, goldenTests: form.goldenTests.filter((_, j) => j !== i) })}>
<Trash2 className="size-4" />
</Button>
</div>
<Textarea
rows={2} placeholder="expected output" value={g.expected}
onChange={(e) => setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, expected: e.target.value } : x)) })}
/>
</div>
))}
</Repeater>
<Field label="Body (the prompt the agent runs)">
<Textarea rows={8} value={form.body} onChange={(e) => setForm({ ...form, body: e.target.value })} placeholder="You are the engineer. Turn the input into…" />
</Field>
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" onClick={() => setForm(null)}>Cancel</Button>
<Button disabled={busy || !form.skillKey.trim() || !form.name.trim() || !form.body.trim()} onClick={save}>
Save {willPublish ? '& publish' : 'draft'}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
)}
</AppShell>
)
}
function SkillGroupCard({
versions,
busy,
onNewVersion,
onEdit,
onFork,
}: {
versions: SkillSummary[]
busy: boolean
onNewVersion: (version: string) => void
onEdit: (version: string) => void
onFork: (version: string) => void
}) {
const [selected, setSelected] = useState(versions[0].version)
const current = versions.find((v) => v.version === selected) ?? versions[0]
const isBuiltin = current.origin === 'Builtin'
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-base">
{current.name}
<span className="font-mono text-xs text-muted-foreground">{current.skillKey}</span>
</CardTitle>
<CardDescription className="mt-1">{current.summary}</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant={current.status === 'Published' ? 'default' : 'secondary'}>{current.status}</Badge>
<Badge variant="outline">{current.origin}</Badge>
</div>
</div>
</CardHeader>
<CardContent className="flex flex-wrap items-center gap-2">
{versions.length > 1 ? (
<Pick value={selected} options={versions.map((v) => v.version)} className="w-28" onChange={setSelected} />
) : (
<Badge variant="outline">{current.version}</Badge>
)}
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
<div className="ml-auto flex items-center gap-2">
{isBuiltin ? (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onFork(current.version)}>
<GitFork data-icon="inline-start" /> Fork to my org
</Button>
) : (
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
<Pencil data-icon="inline-start" /> Edit
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
<Plus data-icon="inline-start" /> New version
</Button>
</div>
</CardContent>
</Card>
)
}
function SegBtn({ active, onClick, icon: Icon, children }: { active: boolean; onClick: () => void; icon: typeof BookMarked; children: React.ReactNode }) {
return (
<button
type="button"
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium transition ${active ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:text-foreground'}`}
>
<Icon className="size-4" /> {children}
</button>
)
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1.5">
<Label className="text-xs">{label}</Label>
{children}
</div>
)
}
function Pick({ value, options, onChange, className }: { value: string; options: string[]; onChange: (v: string) => void; className?: string }) {
return (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className={className ?? 'w-full'}><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((o) => <SelectItem key={o} value={o}>{o}</SelectItem>)}
</SelectGroup>
</SelectContent>
</Select>
)
}
function Repeater({ label, onAdd, children }: { label: string; onAdd: () => void; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<Label className="text-xs">{label}</Label>
<Button size="sm" variant="ghost" onClick={onAdd}><Plus data-icon="inline-start" /> Add</Button>
</div>
{children}
</div>
)
}
@@ -9,6 +9,10 @@ namespace TeamUp.Modules.Skills.Domain;
/// </summary> /// </summary>
internal sealed class Skill : Entity internal sealed class Skill : Entity
{ {
/// <summary>Owning org. Null = a shared builtin (Git starter library), visible to every org.</summary>
public Guid? OrganizationId { get; private set; }
public SkillOrigin Origin { get; private set; }
public Guid? AuthoredByMemberId { get; private set; }
public string SkillKey { get; private set; } = null!; public string SkillKey { get; private set; } = null!;
public string Name { get; private set; } = null!; public string Name { get; private set; } = null!;
public string Version { get; private set; } = null!; public string Version { get; private set; } = null!;
@@ -36,14 +40,16 @@ internal sealed class Skill : Entity
{ {
} }
public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) => public static Skill Create(string skillKey, string version, Guid? organizationId, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc }; new() { SkillKey = skillKey, Version = version, OrganizationId = organizationId, IndexedAtUtc = nowUtc };
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary> /// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
public void Index( public void Index(
SkillManifest manifest, SkillManifest manifest,
string body, string body,
string contentHash, string contentHash,
SkillOrigin origin,
Guid? authoredByMemberId,
string? sourceRepo, string? sourceRepo,
string? sourcePath, string? sourcePath,
string? sourceCommit, string? sourceCommit,
@@ -51,6 +57,8 @@ internal sealed class Skill : Entity
SkillStatus status, SkillStatus status,
DateTimeOffset nowUtc) DateTimeOffset nowUtc)
{ {
Origin = origin;
AuthoredByMemberId = authoredByMemberId;
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name; Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
Version = manifest.Version; Version = manifest.Version;
Summary = manifest.Summary; Summary = manifest.Summary;
@@ -62,7 +70,11 @@ internal sealed class Skill : Entity
.ToList(); .ToList();
Tools = manifest.Tools; Tools = manifest.Tools;
Context = manifest.Context; Context = manifest.Context;
GoldenTests = manifest.GoldenTests; // Fresh owned-entity instances — a manifest built from another skill (fork) must not
// re-parent that skill's tracked GoldenExample rows onto this one.
GoldenTests = manifest.GoldenTests
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList();
Visibility = ParseVisibility(manifest.Visibility); Visibility = ParseVisibility(manifest.Visibility);
MinTier = ParseTier(manifest.MinTier); MinTier = ParseTier(manifest.MinTier);
Status = status; Status = status;
@@ -31,6 +31,18 @@ internal enum SkillStatus
Published, Published,
} }
/// <summary>
/// Where a skill row came from. <c>Builtin</c> = synced from the shared Git starter library
/// (OrganizationId null, visible to every org). <c>Authored</c> = created in-app by an org.
/// <c>Installed</c> = copied from the marketplace into an org (next step).
/// </summary>
internal enum SkillOrigin
{
Builtin,
Authored,
Installed,
}
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary> /// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
internal sealed class SkillAction internal sealed class SkillAction
{ {
@@ -1,6 +1,8 @@
namespace TeamUp.Modules.Skills.Endpoints; namespace TeamUp.Modules.Skills.Endpoints;
internal sealed record ActionDto(string Name, string Risk); internal sealed record ActionDto(string Name, string Risk, string? Description = null);
internal sealed record GoldenTestDto(string Input, string Expected);
internal sealed record SkillSummary( internal sealed record SkillSummary(
string SkillKey, string SkillKey,
@@ -11,6 +13,9 @@ internal sealed record SkillSummary(
string Visibility, string Visibility,
string MinTier, string MinTier,
string Status, string Status,
string Origin,
Guid? OrganizationId,
int GoldenTestCount,
List<ActionDto> Actions); List<ActionDto> Actions);
internal sealed record SkillDetail( internal sealed record SkillDetail(
@@ -19,9 +24,33 @@ internal sealed record SkillDetail(
string? Outputs, string? Outputs,
List<string> Tools, List<string> Tools,
List<string> Context, List<string> Context,
int GoldenTestCount, List<GoldenTestDto> GoldenTests,
string Body); string Body);
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit); internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
internal sealed record SyncResult(int Indexed); internal sealed record SyncResult(int Indexed);
/// <summary>
/// Author or version an org-owned skill from structured fields. Re-saving the same
/// (OrganizationId, SkillKey, Version) edits in place; bumping Version creates a new version.
/// </summary>
internal sealed record AuthorSkillRequest(
Guid OrganizationId,
string SkillKey,
string Name,
string Version,
string? Summary,
List<string> Roles,
string? Inputs,
string? Outputs,
List<ActionDto> Actions,
List<string> Tools,
List<string> Context,
string Visibility,
string MinTier,
string Body,
List<GoldenTestDto> GoldenTests);
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
@@ -1,11 +1,16 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing; using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeamUp.Modules.Skills.Domain; using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing; using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence; using TeamUp.Modules.Skills.Persistence;
using TeamUp.Modules.Skills.Sync; using TeamUp.Modules.Skills.Sync;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity; using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Skills.Endpoints; namespace TeamUp.Modules.Skills.Endpoints;
@@ -18,24 +23,64 @@ internal static class SkillsEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills"))); group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
group.MapGet("/", ListSkills).RequireAuthorization(); group.MapGet("/", ListSkills).RequireAuthorization();
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
group.MapGet("/{key}", GetSkill).RequireAuthorization(); group.MapGet("/{key}", GetSkill).RequireAuthorization();
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
group.MapPost("/index", IndexSkill).RequireAuthorization(); group.MapPost("/index", IndexSkill).RequireAuthorization();
group.MapPost("/sync", Sync).RequireAuthorization(); group.MapPost("/sync", Sync).RequireAuthorization();
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous(); group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
} }
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) => // Re-syncing the shared builtin library is an operator action, not a tenant one.
Results.Ok(new SyncResult(await sync.SyncAsync(ct))); private static async Task<IResult> Sync(
HttpContext http, IOptions<SkillAdminOptions> admin, SkillSyncService sync, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
// Gitea push webhook → re-sync the source. M2 re-indexes the whole source (idempotent); return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
// signature verification + changed-file-only sync via the job queue land later. }
// Gitea push webhook → re-sync the source. Re-reads only the trusted Git source (no caller
// content). Signature verification + changed-file-only sync via the job queue land later.
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) => private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct))); Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
private static async Task<IResult> ListSkills( /// <summary>
string? role, string? visibility, SkillsDbContext db, CancellationToken ct) /// Builtins (null-org, all-tenant-visible) may only be managed by a platform operator holding
/// the configured admin key. Fails closed when no key is configured.
/// </summary>
private static bool IsPlatformAdmin(HttpContext http, IOptions<SkillAdminOptions> admin)
{ {
var query = db.Skills.AsQueryable(); var configured = admin.Value.AdminKey;
if (string.IsNullOrEmpty(configured) ||
!http.Request.Headers.TryGetValue("X-Skills-Admin-Key", out var provided))
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided.ToString()), Encoding.UTF8.GetBytes(configured));
}
private static async Task<IResult> ListSkills(
Guid? organizationId, string? role, string? visibility,
IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
// The library a company sees = the shared builtin starter skills (null org) + its own.
IQueryable<Skill> query = db.Skills.Where(s => s.OrganizationId == null);
if (organizationId is { } orgId)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
query = db.Skills.Where(s => s.OrganizationId == null || s.OrganizationId == orgId);
}
if (!string.IsNullOrWhiteSpace(role)) if (!string.IsNullOrWhiteSpace(role))
{ {
@@ -55,11 +100,30 @@ internal static class SkillsEndpoints
return Results.Ok(skills.Select(ToSummary).ToList()); return Results.Ok(skills.Select(ToSummary).ToList());
} }
private static async Task<IResult> GetSkill(string key, SkillsDbContext db, CancellationToken ct) // Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
// Publishing controls and install-into-your-org land in the next step.
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
{ {
var listed = await db.Skills
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
.OrderBy(s => s.SkillKey)
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
return Results.Ok(listed.Select(ToSummary).ToList());
}
private static async Task<IResult> GetSkill(
string key, Guid? organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
var versions = await db.Skills var versions = await db.Skills
.Where(s => s.SkillKey == key) .Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
.OrderByDescending(s => s.Version) .OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
.ThenByDescending(s => s.Version)
.ToListAsync(ct); .ToListAsync(ct);
return versions.Count == 0 return versions.Count == 0
@@ -67,8 +131,75 @@ internal static class SkillsEndpoints
: Results.Ok(versions.Select(ToDetail).ToList()); : Results.Ok(versions.Select(ToDetail).ToList());
} }
private static async Task<IResult> IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct) private static async Task<IResult> AuthorSkill(
AuthorSkillRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, SkillIndexer indexer, CancellationToken ct)
{ {
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.SkillKey) ||
string.IsNullOrWhiteSpace(request.Name) ||
string.IsNullOrWhiteSpace(request.Version) ||
string.IsNullOrWhiteSpace(request.Body))
{
return Results.BadRequest("skillKey, name, version, and body are required.");
}
var manifest = ToManifest(request);
var skill = await indexer.IndexAsync(
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
await audit.WriteAsync(
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
return Results.Ok(ToDetail(skill));
}
private static async Task<IResult> ForkSkill(
string key, ForkSkillRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, SkillsDbContext db, SkillIndexer indexer, CancellationToken ct)
{
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
// Fork a builtin (or the org's own) version into an editable, org-owned Authored copy.
var source = await db.Skills.FirstOrDefaultAsync(
s => s.SkillKey == key && s.Version == request.Version
&& (s.OrganizationId == null || s.OrganizationId == request.OrganizationId),
ct);
if (source is null)
{
return Results.NotFound();
}
var manifest = ToManifest(source);
if (!string.IsNullOrWhiteSpace(request.Name))
{
manifest.Name = request.Name.Trim();
}
var skill = await indexer.IndexAsync(
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
await audit.WriteAsync(
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
return Results.Ok(ToDetail(skill));
}
// Posts raw content as a shared builtin → operator-only (otherwise any tenant could inject a
// global skill). Tenants author org-owned skills via /authored instead.
private static async Task<IResult> IndexSkill(
HttpContext http, IOptions<SkillAdminOptions> admin, IndexRequest request, SkillIndexer indexer, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.Content)) if (string.IsNullOrWhiteSpace(request.Content))
{ {
return Results.BadRequest("content is required."); return Results.BadRequest("content is required.");
@@ -86,6 +217,46 @@ internal static class SkillsEndpoints
} }
} }
private static SkillManifest ToManifest(AuthorSkillRequest request) => new()
{
Id = request.SkillKey.Trim(),
Name = request.Name.Trim(),
Version = request.Version.Trim(),
Summary = request.Summary,
Roles = request.Roles ?? [],
Inputs = request.Inputs,
Outputs = request.Outputs,
Actions = (request.Actions ?? [])
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk, Description = a.Description })
.ToList(),
Tools = request.Tools ?? [],
Context = request.Context ?? [],
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
GoldenTests = (request.GoldenTests ?? [])
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList(),
};
private static SkillManifest ToManifest(Skill skill) => new()
{
Id = skill.SkillKey,
Name = skill.Name,
Version = skill.Version,
Summary = skill.Summary,
Roles = [.. skill.Roles],
Inputs = skill.Inputs,
Outputs = skill.Outputs,
Actions = skill.Actions
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk.ToString(), Description = a.Description })
.ToList(),
Tools = [.. skill.Tools],
Context = [.. skill.Context],
Visibility = skill.Visibility.ToString(),
MinTier = skill.MinTier.ToString(),
GoldenTests = [.. skill.GoldenTests], // Skill.Index clones these onto the new row.
};
private static SkillSummary ToSummary(Skill skill) => new( private static SkillSummary ToSummary(Skill skill) => new(
skill.SkillKey, skill.SkillKey,
skill.Name, skill.Name,
@@ -95,7 +266,10 @@ internal static class SkillsEndpoints
skill.Visibility.ToString(), skill.Visibility.ToString(),
skill.MinTier.ToString(), skill.MinTier.ToString(),
skill.Status.ToString(), skill.Status.ToString(),
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList()); skill.Origin.ToString(),
skill.OrganizationId,
skill.GoldenTests.Count,
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString(), a.Description)).ToList());
private static SkillDetail ToDetail(Skill skill) => new( private static SkillDetail ToDetail(Skill skill) => new(
ToSummary(skill), ToSummary(skill),
@@ -103,6 +277,6 @@ internal static class SkillsEndpoints
skill.Outputs, skill.Outputs,
skill.Tools, skill.Tools,
skill.Context, skill.Context,
skill.GoldenTests.Count, skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
skill.Body); skill.Body);
} }
@@ -8,10 +8,24 @@ using TeamUp.Modules.Skills.Persistence;
namespace TeamUp.Modules.Skills.Indexing; namespace TeamUp.Modules.Skills.Indexing;
/// <summary>Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).</summary> /// <summary>Who owns an indexed skill row — set once, then immutable for that (org, key, version).</summary>
internal readonly record struct SkillOwnership(Guid? OrganizationId, SkillOrigin Origin, Guid? AuthoredByMemberId)
{
/// <summary>The shared Git starter library: no org, visible to everyone.</summary>
public static readonly SkillOwnership Builtin = new(null, SkillOrigin.Builtin, null);
public static SkillOwnership Authored(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Authored, memberId);
public static SkillOwnership Installed(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Installed, memberId);
}
/// <summary>Parses/projects a skill manifest, computes its embedding, and upserts the row (by org+key+version).</summary>
internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock) internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
{ {
public async Task<Skill> IndexAsync( /// <summary>Index raw SKILL.md content from the Git source (a shared builtin).</summary>
public Task<Skill> IndexAsync(
string content, string content,
string? sourceRepo, string? sourceRepo,
string? sourcePath, string? sourcePath,
@@ -19,26 +33,51 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var parsed = SkillMarkdownParser.Parse(content); var parsed = SkillMarkdownParser.Parse(content);
var manifest = parsed.Manifest; var contentHash = Hash(content);
var now = clock.GetUtcNow(); return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content))); }
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}"; /// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
public Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
SkillOwnership ownership,
CancellationToken cancellationToken = default)
{
// The content hash spans the structured manifest + body so re-authoring changes it.
var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Summary}\n{string.Join(',', manifest.Roles)}\n{body}";
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, cancellationToken);
}
private async Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
string contentHash,
SkillOwnership ownership,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{body}";
var embedding = new Vector(embedder.Embed(embeddingText)); var embedding = new Vector(embedder.Embed(embeddingText));
// M2 publish gate (structural): a skill is published only if it declares roles and carries // Publish gate (structural): a skill is published only if it declares roles and carries at
// at least one well-formed golden test. Executing the golden tests against a model — and // least one well-formed golden test. Executing the golden tests against a model — and gating
// gating on edit distance — lands in M4 when the assembler/runtime exists. // on edit distance — lands in M4 when the assembler/runtime exists.
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0 var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
? SkillStatus.Published ? SkillStatus.Published
: SkillStatus.Draft; : SkillStatus.Draft;
var skill = await db.Skills var skill = await db.Skills.FirstOrDefaultAsync(
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken); s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
cancellationToken);
var isNew = skill is null; var isNew = skill is null;
skill ??= Skill.Create(manifest.Id, manifest.Version, now); skill ??= Skill.Create(manifest.Id, manifest.Version, ownership.OrganizationId, now);
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now); skill.Index(manifest, body, contentHash, ownership.Origin, ownership.AuthoredByMemberId, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
if (isNew) if (isNew)
{ {
@@ -48,4 +87,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
await db.SaveChangesAsync(cancellationToken); await db.SaveChangesAsync(cancellationToken);
return skill; return skill;
} }
private static string Hash(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
} }
@@ -0,0 +1,203 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Skills.Persistence;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
[DbContext(typeof(SkillsDbContext))]
[Migration("20260610180442_AddSkillOwnership")]
partial class AddSkillOwnership
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skills")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<List<string>>("Context")
.IsRequired()
.HasColumnType("text[]");
b.Property<Vector>("Embedding")
.HasColumnType("vector(384)");
b.Property<DateTimeOffset>("IndexedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Inputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("MinTier")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("SkillKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceCommit")
.HasColumnType("text");
b.Property<string>("SourcePath")
.HasColumnType("text");
b.Property<string>("SourceRepo")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.PrimitiveCollection<List<string>>("Tools")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status");
b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills");
});
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Expected")
.IsRequired();
b1.Property<string>("Input")
.IsRequired();
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("GoldenTests")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Name")
.IsRequired();
b1.Property<int>("Risk");
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("Actions")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.Navigation("Actions");
b.Navigation("GoldenTests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSkillOwnership : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.AddColumn<Guid>(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
// Every pre-existing row came from Git sync, so backfill them as Builtin (an empty
// string wouldn't parse back to the SkillOrigin enum). New rows always set Origin.
migrationBuilder.AddColumn<string>(
name: "Origin",
schema: "skills",
table: "skills",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Builtin");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "OrganizationId", "SkillKey", "Version" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "Origin",
schema: "skills",
table: "skills");
migrationBuilder.CreateIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "SkillKey", "Version" },
unique: true);
}
}
}
@@ -31,6 +31,9 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body") b.Property<string>("Body")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@@ -64,6 +67,14 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.HasMaxLength(200) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs") b.Property<string>("Outputs")
.HasMaxLength(2000) .HasMaxLength(2000)
.HasColumnType("character varying(2000)"); .HasColumnType("character varying(2000)");
@@ -114,11 +125,15 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status"); b.HasIndex("Status");
b.HasIndex("SkillKey", "Version") b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique(); .IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills"); b.ToTable("skills", "skills");
}); });
@@ -17,6 +17,7 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
{ {
skill.ToTable("skills"); skill.ToTable("skills");
skill.HasKey(s => s.Id); skill.HasKey(s => s.Id);
skill.Property(s => s.Origin).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired(); skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
skill.Property(s => s.Name).HasMaxLength(200).IsRequired(); skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
skill.Property(s => s.Version).HasMaxLength(32).IsRequired(); skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
@@ -33,7 +34,12 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
skill.OwnsMany(s => s.Actions, owned => owned.ToJson()); skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson()); skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique(); // Identity is org-scoped: an org owns its (key, version); builtins share the null-org
// namespace. NULLS NOT DISTINCT so two builtins can't collide on (null, key, version).
skill.HasIndex(s => new { s.OrganizationId, s.SkillKey, s.Version })
.IsUnique()
.AreNullsDistinct(false);
skill.HasIndex(s => s.OrganizationId);
skill.HasIndex(s => s.Status); skill.HasIndex(s => s.Status);
}); });
} }
@@ -0,0 +1,14 @@
namespace TeamUp.Modules.Skills;
/// <summary>
/// Platform-operator settings for managing the shared builtin skill library (null-org skills,
/// visible to every tenant). Builtin management is NOT a tenant action — the endpoints that
/// create/sync builtins require <see cref="AdminKey"/>, which no tenant role grants.
/// </summary>
internal sealed class SkillAdminOptions
{
public const string SectionName = "Skills";
/// <summary>Operator key required to manage builtins. Null/empty ⇒ builtin management is disabled.</summary>
public string? AdminKey { get; set; }
}
@@ -24,6 +24,7 @@ public sealed class SkillsModule : IModule
var connectionString = configuration.GetConnectionString("Postgres") var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'."); ?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.Configure<SkillAdminOptions>(configuration.GetSection(SkillAdminOptions.SectionName));
services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector())); services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>()); services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>(); services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
@@ -14,6 +14,7 @@ public static class AccessPolicy
Capability.InvitePeople Capability.InvitePeople
or Capability.CreateProductsAndTeams or Capability.CreateProductsAndTeams
or Capability.ConfigureAgents or Capability.ConfigureAgents
or Capability.ManageSkills
or Capability.SetAutonomy or Capability.SetAutonomy
or Capability.ApproveHeldActions or Capability.ApproveHeldActions
or Capability.WorkTasks or Capability.WorkTasks
@@ -11,6 +11,7 @@ public enum Capability
InvitePeople, InvitePeople,
CreateProductsAndTeams, CreateProductsAndTeams,
ConfigureAgents, ConfigureAgents,
ManageSkills,
SetAutonomy, SetAutonomy,
ApproveHeldActions, ApproveHeldActions,
WorkTasks, WorkTasks,
@@ -122,6 +122,7 @@ public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<P
{ {
var client = factory.CreateClient(); var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client; return client;
} }
@@ -53,6 +53,7 @@ public sealed class AssemblerRunTests(PostgresFixture postgres) : IClassFixture<
using var client = factory.CreateClient(); using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
await client.PostAsJsonAsync("/api/orgboard/organizations", new { organizationId = owner.OrganizationId, name = "AliaSaaS" }); await client.PostAsJsonAsync("/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 team = await PostOk<TeamResponse>(client, "/api/orgboard/teams", new { organizationId = owner.OrganizationId, name = "IPNOPS" });
@@ -236,6 +236,7 @@ public sealed class ReviewFlowTests(PostgresFixture postgres) : IClassFixture<Po
{ {
var client = factory.CreateClient(); var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client; return client;
} }
@@ -0,0 +1,213 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Xunit;
namespace TeamUp.IntegrationTests;
/// <summary>
/// The dynamic per-company skill library: an org authors a skill, versions it, and forks a builtin —
/// all org-scoped (own + shared builtins visible, gated by ManageSkills), with the publish gate intact.
/// </summary>
public sealed class SkillLibraryTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
{
private const string BuiltinSkill =
"""
---
id: spec-writing
name: Spec Writing
version: 1.0.0
summary: Turn a request into a spec.
roles: [product-owner]
actions:
- name: write-spec
risk: draft
golden_tests:
- input: "Add a logout button"
expected: "A logout button in the header that ends the session."
---
# Spec Writing
Write a clear, testable spec.
""";
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 ActionDto(string Name, string Risk, string? Description);
private sealed record GoldenTestDto(string Input, string Expected);
private sealed record SkillSummary(
string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
int GoldenTestCount, List<ActionDto> Actions);
private sealed record SkillDetail(
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
List<string> Context, List<GoldenTestDto> GoldenTests, string Body);
[Fact]
public async Task Org_authors_versions_and_forks_skills_scoped_to_itself()
{
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
using var anon = factory.CreateClient();
var owner = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
{
organizationName = "AliaSaaS",
ownerEmail = "owner@alia.test",
ownerDisplayName = "Owner",
ownerPassword = "Passw0rd!",
});
using var client = Authed(factory, owner.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
// A builtin exists in the shared (null-org) namespace.
await PostOk<SkillDetail>(client, "/api/skills/index", new { content = BuiltinSkill });
// The org authors its own skill. Roles + a golden test → Published.
var authored = await PostOk<SkillDetail>(client, "/api/skills/authored", new
{
organizationId = owner.OrganizationId,
skillKey = "api-design",
name = "API Design",
version = "1.0.0",
summary = "Design an endpoint.",
roles = new[] { "engineer" },
inputs = "A story.",
outputs = "Route + shapes.",
actions = new[] { new { name = "write-design", risk = "draft", description = "Emit a design." } },
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility = "private",
minTier = "free",
body = "You are the engineer. Design the endpoint.",
goldenTests = new[] { new { input = "Delete own comment", expected = "DELETE /comments/{id} 204/403/404" } },
});
Assert.Equal("Published", authored.Skill.Status);
Assert.Equal("Authored", authored.Skill.Origin);
Assert.Equal(owner.OrganizationId, authored.Skill.OrganizationId);
// Bump the version → a new row; both coexist.
await PostOk<SkillDetail>(client, "/api/skills/authored", new
{
organizationId = owner.OrganizationId,
skillKey = "api-design",
name = "API Design",
version = "1.1.0",
summary = "Design an endpoint (v2).",
roles = new[] { "engineer" },
inputs = (string?)null,
outputs = (string?)null,
actions = Array.Empty<object>(),
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility = "private",
minTier = "free",
body = "Refined.",
goldenTests = new[] { new { input = "x", expected = "y" } },
});
var versions = await client.GetFromJsonAsync<List<SkillDetail>>(
$"/api/skills/api-design?organizationId={owner.OrganizationId}");
Assert.Equal(2, versions!.Count);
// Without roles or golden tests → Draft (publish gate holds).
var draft = await PostOk<SkillDetail>(client, "/api/skills/authored", new
{
organizationId = owner.OrganizationId,
skillKey = "rough-idea",
name = "Rough Idea",
version = "0.1.0",
summary = (string?)null,
roles = Array.Empty<string>(),
inputs = (string?)null,
outputs = (string?)null,
actions = Array.Empty<object>(),
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility = "private",
minTier = "free",
body = "WIP.",
goldenTests = Array.Empty<object>(),
});
Assert.Equal("Draft", draft.Skill.Status);
// The library lists builtins + own skills; another org sees only builtins.
var lib = await client.GetFromJsonAsync<List<SkillSummary>>($"/api/skills?organizationId={owner.OrganizationId}");
Assert.Contains(lib!, s => s.SkillKey == "spec-writing" && s.Origin == "Builtin");
Assert.Contains(lib!, s => s.SkillKey == "api-design" && s.OrganizationId == owner.OrganizationId);
// Fork the builtin into the org → an editable Authored copy under the org namespace.
var forked = await PostOk<SkillDetail>(client, "/api/skills/spec-writing/fork", new
{
organizationId = owner.OrganizationId,
version = "1.0.0",
name = (string?)null,
});
Assert.Equal("Authored", forked.Skill.Origin);
Assert.Equal(owner.OrganizationId, forked.Skill.OrganizationId);
Assert.Equal("spec-writing", forked.Skill.SkillKey);
// GET for the key now returns the org's fork AND the builtin.
var specVersions = await client.GetFromJsonAsync<List<SkillDetail>>(
$"/api/skills/spec-writing?organizationId={owner.OrganizationId}");
Assert.Contains(specVersions!, s => s.Skill.OrganizationId == owner.OrganizationId);
Assert.Contains(specVersions!, s => s.Skill.OrganizationId == null);
// A plain Member cannot author skills (ManageSkills is owner/team-owner).
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
{
email = "member@alia.test",
scopeType = "Organization",
scopeId = owner.OrganizationId,
role = "Member",
organizationId = owner.OrganizationId,
});
var member = await PostOk<AuthResponse>(anon, "/api/identity/invitations/accept",
new { token = invite.Token, displayName = "Member", password = "Passw0rd!" });
using var memberClient = Authed(factory, member.Token);
var forbidden = await memberClient.PostAsJsonAsync("/api/skills/authored", new
{
organizationId = owner.OrganizationId,
skillKey = "sneaky",
name = "Sneaky",
version = "1.0.0",
roles = Array.Empty<string>(),
actions = Array.Empty<object>(),
tools = Array.Empty<string>(),
context = Array.Empty<string>(),
visibility = "private",
minTier = "free",
body = "no",
goldenTests = Array.Empty<object>(),
});
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
// Builtin management (shared null-org skills) needs the platform admin key — an authenticated
// tenant user without it cannot inject or re-sync builtins, even as Owner.
using var noKey = Authed(factory, owner.Token);
Assert.Equal(HttpStatusCode.Forbidden,
(await noKey.PostAsJsonAsync("/api/skills/index", new { content = BuiltinSkill })).StatusCode);
Assert.Equal(HttpStatusCode.Forbidden,
(await noKey.PostAsync("/api/skills/sync", content: null)).StatusCode);
}
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!;
}
}
@@ -42,15 +42,18 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId); private sealed record BootstrapResponse(string Token, Guid MemberId, Guid OrganizationId);
private sealed record ActionDto(string Name, string Risk); private sealed record ActionDto(string Name, string Risk, string? Description);
private sealed record GoldenTestDto(string Input, string Expected);
private sealed record SkillSummary( private sealed record SkillSummary(
string SkillKey, string Name, string Version, string? Summary, List<string> Roles, string SkillKey, string Name, string Version, string? Summary, List<string> Roles,
string Visibility, string MinTier, string Status, List<ActionDto> Actions); string Visibility, string MinTier, string Status, string Origin, Guid? OrganizationId,
int GoldenTestCount, List<ActionDto> Actions);
private sealed record SkillDetail( private sealed record SkillDetail(
SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools, SkillSummary Skill, string? Inputs, string? Outputs, List<string> Tools,
List<string> Context, int GoldenTestCount, string Body); List<string> Context, List<GoldenTestDto> GoldenTests, string Body);
[Fact] [Fact]
public async Task Index_publishes_and_makes_skill_queryable_by_role() public async Task Index_publishes_and_makes_skill_queryable_by_role()
@@ -73,6 +76,7 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
using var client = factory.CreateClient(); using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
// Index the SKILL.md. // Index the SKILL.md.
var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill }); var indexResponse = await client.PostAsJsonAsync("/api/skills/index", new { content = SpecWritingSkill });
@@ -81,7 +85,9 @@ public sealed class SkillRegistryTests(PostgresFixture postgres) : IClassFixture
Assert.NotNull(indexed); Assert.NotNull(indexed);
Assert.Equal("spec-writing", indexed!.Skill.SkillKey); Assert.Equal("spec-writing", indexed!.Skill.SkillKey);
Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test Assert.Equal("Published", indexed.Skill.Status); // has roles + a golden test
Assert.Equal(1, indexed.GoldenTestCount); Assert.Equal("Builtin", indexed.Skill.Origin);
Assert.Null(indexed.Skill.OrganizationId);
Assert.Single(indexed.GoldenTests);
Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft"); Assert.Contains(indexed.Skill.Actions, a => a.Name == "write-spec" && a.Risk == "Draft");
// Queryable by its role… // Queryable by its role…
@@ -45,6 +45,7 @@ public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<Pos
using var client = factory.CreateClient(); using var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
var syncResponse = await client.PostAsync("/api/skills/sync", content: null); var syncResponse = await client.PostAsync("/api/skills/sync", content: null);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode); Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
@@ -11,12 +11,17 @@ public sealed class TeamUpWebFactory(
string connectionString, string connectionString,
IReadOnlyDictionary<string, string?>? settings = null) : WebApplicationFactory<Program> IReadOnlyDictionary<string, string?>? settings = null) : WebApplicationFactory<Program>
{ {
/// <summary>Operator key the test host accepts for builtin management (/index, /sync).</summary>
public const string PlatformAdminKey = "test-admin-key";
protected override void ConfigureWebHost(IWebHostBuilder builder) protected override void ConfigureWebHost(IWebHostBuilder builder)
{ {
builder.UseEnvironment("Development"); builder.UseEnvironment("Development");
builder.UseSetting("ConnectionStrings:Postgres", connectionString); builder.UseSetting("ConnectionStrings:Postgres", connectionString);
builder.UseSetting("Database:ApplyMigrationsOnStartup", "true"); builder.UseSetting("Database:ApplyMigrationsOnStartup", "true");
builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty); builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty);
builder.UseSetting("Skills:AdminKey", PlatformAdminKey);
if (settings is not null) if (settings is not null)
{ {
@@ -232,6 +232,7 @@ public sealed class TwoRoleLoopTests(PostgresFixture postgres) : IClassFixture<P
{ {
var client = factory.CreateClient(); var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client; return client;
} }