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 { ReviewsPage } from '@/pages/ReviewsPage'
import { SeatsPage } from '@/pages/SeatsPage'
import { SkillsPage } from '@/pages/SkillsPage'
import { StructurePage } from '@/pages/StructurePage'
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="/org" element={token ? <OrgChartPage /> : <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="*" element={<Navigate to="/" replace />} />
</Routes>
+2
View File
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react'
import { Link, useLocation } from 'react-router'
import {
BookMarked,
Bot,
Boxes,
ChartColumn,
@@ -42,6 +43,7 @@ export function AppShell({ children }: { children: ReactNode }) {
<NavItem icon={Inbox} label="Cartable" to="/cartable" />
<NavItem icon={ShieldCheck} label="Review inbox" to="/reviews" />
<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={Boxes} label="Structure" to="/structure" />
<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>
internal sealed class Skill : Entity
{
/// <summary>Owning org. Null = a shared builtin (Git starter library), visible to every org.</summary>
public Guid? OrganizationId { get; private set; }
public SkillOrigin Origin { get; private set; }
public Guid? AuthoredByMemberId { get; private set; }
public string SkillKey { get; private set; } = null!;
public string Name { get; private set; } = null!;
public string Version { get; private set; } = null!;
@@ -36,14 +40,16 @@ internal sealed class Skill : Entity
{
}
public static Skill Create(string skillKey, string version, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, IndexedAtUtc = nowUtc };
public static Skill Create(string skillKey, string version, Guid? organizationId, DateTimeOffset nowUtc) =>
new() { SkillKey = skillKey, Version = version, OrganizationId = organizationId, IndexedAtUtc = nowUtc };
/// <summary>(Re)projects a parsed manifest + body onto this row. Used for both insert and update.</summary>
public void Index(
SkillManifest manifest,
string body,
string contentHash,
SkillOrigin origin,
Guid? authoredByMemberId,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
@@ -51,6 +57,8 @@ internal sealed class Skill : Entity
SkillStatus status,
DateTimeOffset nowUtc)
{
Origin = origin;
AuthoredByMemberId = authoredByMemberId;
Name = string.IsNullOrWhiteSpace(manifest.Name) ? manifest.Id : manifest.Name;
Version = manifest.Version;
Summary = manifest.Summary;
@@ -62,7 +70,11 @@ internal sealed class Skill : Entity
.ToList();
Tools = manifest.Tools;
Context = manifest.Context;
GoldenTests = manifest.GoldenTests;
// Fresh owned-entity instances — a manifest built from another skill (fork) must not
// re-parent that skill's tracked GoldenExample rows onto this one.
GoldenTests = manifest.GoldenTests
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList();
Visibility = ParseVisibility(manifest.Visibility);
MinTier = ParseTier(manifest.MinTier);
Status = status;
@@ -31,6 +31,18 @@ internal enum SkillStatus
Published,
}
/// <summary>
/// Where a skill row came from. <c>Builtin</c> = synced from the shared Git starter library
/// (OrganizationId null, visible to every org). <c>Authored</c> = created in-app by an org.
/// <c>Installed</c> = copied from the marketplace into an org (next step).
/// </summary>
internal enum SkillOrigin
{
Builtin,
Authored,
Installed,
}
/// <summary>A risk-tagged action a skill can take. Stored as JSON on the skill.</summary>
internal sealed class SkillAction
{
@@ -1,6 +1,8 @@
namespace TeamUp.Modules.Skills.Endpoints;
internal sealed record ActionDto(string Name, string Risk);
internal sealed record ActionDto(string Name, string Risk, string? Description = null);
internal sealed record GoldenTestDto(string Input, string Expected);
internal sealed record SkillSummary(
string SkillKey,
@@ -11,6 +13,9 @@ internal sealed record SkillSummary(
string Visibility,
string MinTier,
string Status,
string Origin,
Guid? OrganizationId,
int GoldenTestCount,
List<ActionDto> Actions);
internal sealed record SkillDetail(
@@ -19,9 +24,33 @@ internal sealed record SkillDetail(
string? Outputs,
List<string> Tools,
List<string> Context,
int GoldenTestCount,
List<GoldenTestDto> GoldenTests,
string Body);
internal sealed record IndexRequest(string Content, string? SourceRepo, string? SourcePath, string? SourceCommit);
internal sealed record SyncResult(int Indexed);
/// <summary>
/// Author or version an org-owned skill from structured fields. Re-saving the same
/// (OrganizationId, SkillKey, Version) edits in place; bumping Version creates a new version.
/// </summary>
internal sealed record AuthorSkillRequest(
Guid OrganizationId,
string SkillKey,
string Name,
string Version,
string? Summary,
List<string> Roles,
string? Inputs,
string? Outputs,
List<ActionDto> Actions,
List<string> Tools,
List<string> Context,
string Visibility,
string MinTier,
string Body,
List<GoldenTestDto> GoldenTests);
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
@@ -1,11 +1,16 @@
using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using TeamUp.Modules.Skills.Domain;
using TeamUp.Modules.Skills.Indexing;
using TeamUp.Modules.Skills.Persistence;
using TeamUp.Modules.Skills.Sync;
using TeamUp.SharedKernel.Access;
using TeamUp.SharedKernel.Auditing;
using TeamUp.SharedKernel.Modularity;
namespace TeamUp.Modules.Skills.Endpoints;
@@ -18,24 +23,64 @@ internal static class SkillsEndpoints
group.MapGet("/ping", () => TypedResults.Ok(new ModulePing("skills")));
group.MapGet("/", ListSkills).RequireAuthorization();
group.MapGet("/marketplace", Marketplace).RequireAuthorization();
group.MapGet("/{key}", GetSkill).RequireAuthorization();
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
group.MapPost("/index", IndexSkill).RequireAuthorization();
group.MapPost("/sync", Sync).RequireAuthorization();
group.MapPost("/webhook/gitea", Webhook).AllowAnonymous();
}
private static async Task<IResult> Sync(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
// Re-syncing the shared builtin library is an operator action, not a tenant one.
private static async Task<IResult> Sync(
HttpContext http, IOptions<SkillAdminOptions> admin, SkillSyncService sync, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
// Gitea push webhook → re-sync the source. M2 re-indexes the whole source (idempotent);
// signature verification + changed-file-only sync via the job queue land later.
return Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
}
// Gitea push webhook → re-sync the source. Re-reads only the trusted Git source (no caller
// content). Signature verification + changed-file-only sync via the job queue land later.
private static async Task<IResult> Webhook(SkillSyncService sync, CancellationToken ct) =>
Results.Ok(new SyncResult(await sync.SyncAsync(ct)));
private static async Task<IResult> ListSkills(
string? role, string? visibility, SkillsDbContext db, CancellationToken ct)
/// <summary>
/// Builtins (null-org, all-tenant-visible) may only be managed by a platform operator holding
/// the configured admin key. Fails closed when no key is configured.
/// </summary>
private static bool IsPlatformAdmin(HttpContext http, IOptions<SkillAdminOptions> admin)
{
var query = db.Skills.AsQueryable();
var configured = admin.Value.AdminKey;
if (string.IsNullOrEmpty(configured) ||
!http.Request.Headers.TryGetValue("X-Skills-Admin-Key", out var provided))
{
return false;
}
return CryptographicOperations.FixedTimeEquals(
Encoding.UTF8.GetBytes(provided.ToString()), Encoding.UTF8.GetBytes(configured));
}
private static async Task<IResult> ListSkills(
Guid? organizationId, string? role, string? visibility,
IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
// The library a company sees = the shared builtin starter skills (null org) + its own.
IQueryable<Skill> query = db.Skills.Where(s => s.OrganizationId == null);
if (organizationId is { } orgId)
{
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
query = db.Skills.Where(s => s.OrganizationId == null || s.OrganizationId == orgId);
}
if (!string.IsNullOrWhiteSpace(role))
{
@@ -55,11 +100,30 @@ internal static class SkillsEndpoints
return Results.Ok(skills.Select(ToSummary).ToList());
}
private static async Task<IResult> GetSkill(string key, SkillsDbContext db, CancellationToken ct)
// Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
// Publishing controls and install-into-your-org land in the next step.
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
{
var listed = await db.Skills
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
.OrderBy(s => s.SkillKey)
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
return Results.Ok(listed.Select(ToSummary).ToList());
}
private static async Task<IResult> GetSkill(
string key, Guid? organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
{
if (organizationId is { } orgId && !permissions.Has(Capability.ViewBoard, ScopeRef.Org(orgId)))
{
return Results.Forbid();
}
var versions = await db.Skills
.Where(s => s.SkillKey == key)
.OrderByDescending(s => s.Version)
.Where(s => s.SkillKey == key && (s.OrganizationId == null || s.OrganizationId == organizationId))
.OrderByDescending(s => s.OrganizationId != null) // org's own first, then builtins
.ThenByDescending(s => s.Version)
.ToListAsync(ct);
return versions.Count == 0
@@ -67,8 +131,75 @@ internal static class SkillsEndpoints
: Results.Ok(versions.Select(ToDetail).ToList());
}
private static async Task<IResult> IndexSkill(IndexRequest request, SkillIndexer indexer, CancellationToken ct)
private static async Task<IResult> AuthorSkill(
AuthorSkillRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, SkillIndexer indexer, CancellationToken ct)
{
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.SkillKey) ||
string.IsNullOrWhiteSpace(request.Name) ||
string.IsNullOrWhiteSpace(request.Version) ||
string.IsNullOrWhiteSpace(request.Body))
{
return Results.BadRequest("skillKey, name, version, and body are required.");
}
var manifest = ToManifest(request);
var skill = await indexer.IndexAsync(
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
await audit.WriteAsync(
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
return Results.Ok(ToDetail(skill));
}
private static async Task<IResult> ForkSkill(
string key, ForkSkillRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, SkillsDbContext db, SkillIndexer indexer, CancellationToken ct)
{
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
{
return Results.Forbid();
}
// Fork a builtin (or the org's own) version into an editable, org-owned Authored copy.
var source = await db.Skills.FirstOrDefaultAsync(
s => s.SkillKey == key && s.Version == request.Version
&& (s.OrganizationId == null || s.OrganizationId == request.OrganizationId),
ct);
if (source is null)
{
return Results.NotFound();
}
var manifest = ToManifest(source);
if (!string.IsNullOrWhiteSpace(request.Name))
{
manifest.Name = request.Name.Trim();
}
var skill = await indexer.IndexAsync(
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
await audit.WriteAsync(
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
return Results.Ok(ToDetail(skill));
}
// Posts raw content as a shared builtin → operator-only (otherwise any tenant could inject a
// global skill). Tenants author org-owned skills via /authored instead.
private static async Task<IResult> IndexSkill(
HttpContext http, IOptions<SkillAdminOptions> admin, IndexRequest request, SkillIndexer indexer, CancellationToken ct)
{
if (!IsPlatformAdmin(http, admin))
{
return Results.Forbid();
}
if (string.IsNullOrWhiteSpace(request.Content))
{
return Results.BadRequest("content is required.");
@@ -86,6 +217,46 @@ internal static class SkillsEndpoints
}
}
private static SkillManifest ToManifest(AuthorSkillRequest request) => new()
{
Id = request.SkillKey.Trim(),
Name = request.Name.Trim(),
Version = request.Version.Trim(),
Summary = request.Summary,
Roles = request.Roles ?? [],
Inputs = request.Inputs,
Outputs = request.Outputs,
Actions = (request.Actions ?? [])
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk, Description = a.Description })
.ToList(),
Tools = request.Tools ?? [],
Context = request.Context ?? [],
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
GoldenTests = (request.GoldenTests ?? [])
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
.ToList(),
};
private static SkillManifest ToManifest(Skill skill) => new()
{
Id = skill.SkillKey,
Name = skill.Name,
Version = skill.Version,
Summary = skill.Summary,
Roles = [.. skill.Roles],
Inputs = skill.Inputs,
Outputs = skill.Outputs,
Actions = skill.Actions
.Select(a => new ManifestAction { Name = a.Name, Risk = a.Risk.ToString(), Description = a.Description })
.ToList(),
Tools = [.. skill.Tools],
Context = [.. skill.Context],
Visibility = skill.Visibility.ToString(),
MinTier = skill.MinTier.ToString(),
GoldenTests = [.. skill.GoldenTests], // Skill.Index clones these onto the new row.
};
private static SkillSummary ToSummary(Skill skill) => new(
skill.SkillKey,
skill.Name,
@@ -95,7 +266,10 @@ internal static class SkillsEndpoints
skill.Visibility.ToString(),
skill.MinTier.ToString(),
skill.Status.ToString(),
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString())).ToList());
skill.Origin.ToString(),
skill.OrganizationId,
skill.GoldenTests.Count,
skill.Actions.Select(a => new ActionDto(a.Name, a.Risk.ToString(), a.Description)).ToList());
private static SkillDetail ToDetail(Skill skill) => new(
ToSummary(skill),
@@ -103,6 +277,6 @@ internal static class SkillsEndpoints
skill.Outputs,
skill.Tools,
skill.Context,
skill.GoldenTests.Count,
skill.GoldenTests.Select(g => new GoldenTestDto(g.Input, g.Expected)).ToList(),
skill.Body);
}
@@ -8,10 +8,24 @@ using TeamUp.Modules.Skills.Persistence;
namespace TeamUp.Modules.Skills.Indexing;
/// <summary>Parses a SKILL.md, computes its embedding, and upserts the Skill row (by key+version).</summary>
/// <summary>Who owns an indexed skill row — set once, then immutable for that (org, key, version).</summary>
internal readonly record struct SkillOwnership(Guid? OrganizationId, SkillOrigin Origin, Guid? AuthoredByMemberId)
{
/// <summary>The shared Git starter library: no org, visible to everyone.</summary>
public static readonly SkillOwnership Builtin = new(null, SkillOrigin.Builtin, null);
public static SkillOwnership Authored(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Authored, memberId);
public static SkillOwnership Installed(Guid organizationId, Guid memberId) =>
new(organizationId, SkillOrigin.Installed, memberId);
}
/// <summary>Parses/projects a skill manifest, computes its embedding, and upserts the row (by org+key+version).</summary>
internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder, TimeProvider clock)
{
public async Task<Skill> IndexAsync(
/// <summary>Index raw SKILL.md content from the Git source (a shared builtin).</summary>
public Task<Skill> IndexAsync(
string content,
string? sourceRepo,
string? sourcePath,
@@ -19,26 +33,51 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
CancellationToken cancellationToken = default)
{
var parsed = SkillMarkdownParser.Parse(content);
var manifest = parsed.Manifest;
var now = clock.GetUtcNow();
var contentHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
var contentHash = Hash(content);
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
}
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{parsed.Body}";
/// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
public Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
SkillOwnership ownership,
CancellationToken cancellationToken = default)
{
// The content hash spans the structured manifest + body so re-authoring changes it.
var canonical = $"{manifest.Id}\n{manifest.Version}\n{manifest.Summary}\n{string.Join(',', manifest.Roles)}\n{body}";
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, cancellationToken);
}
private async Task<Skill> IndexAsync(
SkillManifest manifest,
string body,
string contentHash,
SkillOwnership ownership,
string? sourceRepo,
string? sourcePath,
string? sourceCommit,
CancellationToken cancellationToken)
{
var now = clock.GetUtcNow();
var embeddingText = $"{manifest.Name}\n{manifest.Summary}\n{string.Join(' ', manifest.Roles)}\n{body}";
var embedding = new Vector(embedder.Embed(embeddingText));
// M2 publish gate (structural): a skill is published only if it declares roles and carries
// at least one well-formed golden test. Executing the golden tests against a model — and
// gating on edit distance — lands in M4 when the assembler/runtime exists.
// Publish gate (structural): a skill is published only if it declares roles and carries at
// least one well-formed golden test. Executing the golden tests against a model — and gating
// on edit distance — lands in M4 when the assembler/runtime exists.
var status = manifest.Roles.Count > 0 && manifest.GoldenTests.Count > 0
? SkillStatus.Published
: SkillStatus.Draft;
var skill = await db.Skills
.FirstOrDefaultAsync(s => s.SkillKey == manifest.Id && s.Version == manifest.Version, cancellationToken);
var skill = await db.Skills.FirstOrDefaultAsync(
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
cancellationToken);
var isNew = skill is null;
skill ??= Skill.Create(manifest.Id, manifest.Version, now);
skill.Index(manifest, parsed.Body, contentHash, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
skill ??= Skill.Create(manifest.Id, manifest.Version, ownership.OrganizationId, now);
skill.Index(manifest, body, contentHash, ownership.Origin, ownership.AuthoredByMemberId, sourceRepo, sourcePath, sourceCommit, embedding, status, now);
if (isNew)
{
@@ -48,4 +87,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
await db.SaveChangesAsync(cancellationToken);
return skill;
}
private static string Hash(string content) =>
Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(content)));
}
@@ -0,0 +1,203 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using Pgvector;
using TeamUp.Modules.Skills.Persistence;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
[DbContext(typeof(SkillsDbContext))]
[Migration("20260610180442_AddSkillOwnership")]
partial class AddSkillOwnership
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("skills")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.PrimitiveCollection<List<string>>("Context")
.IsRequired()
.HasColumnType("text[]");
b.Property<Vector>("Embedding")
.HasColumnType("vector(384)");
b.Property<DateTimeOffset>("IndexedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Inputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.Property<string>("MinTier")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("SkillKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("SourceCommit")
.HasColumnType("text");
b.Property<string>("SourcePath")
.HasColumnType("text");
b.Property<string>("SourceRepo")
.HasColumnType("text");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.PrimitiveCollection<List<string>>("Tools")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status");
b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills");
});
modelBuilder.Entity("TeamUp.Modules.Skills.Domain.Skill", b =>
{
b.OwnsMany("TeamUp.Modules.Skills.Domain.GoldenExample", "GoldenTests", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Expected")
.IsRequired();
b1.Property<string>("Input")
.IsRequired();
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("GoldenTests")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.OwnsMany("TeamUp.Modules.Skills.Domain.SkillAction", "Actions", b1 =>
{
b1.Property<Guid>("SkillId");
b1.Property<int>("__synthesizedOrdinal")
.ValueGeneratedOnAdd();
b1.Property<string>("Description");
b1.Property<string>("Name")
.IsRequired();
b1.Property<int>("Risk");
b1.HasKey("SkillId", "__synthesizedOrdinal");
b1.ToTable("skills", "skills");
b1
.ToJson("Actions")
.HasColumnType("jsonb");
b1.WithOwner()
.HasForeignKey("SkillId");
});
b.Navigation("Actions");
b.Navigation("GoldenTests");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,95 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.Skills.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddSkillOwnership : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.AddColumn<Guid>(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
migrationBuilder.AddColumn<Guid>(
name: "OrganizationId",
schema: "skills",
table: "skills",
type: "uuid",
nullable: true);
// Every pre-existing row came from Git sync, so backfill them as Builtin (an empty
// string wouldn't parse back to the SkillOrigin enum). New rows always set Origin.
migrationBuilder.AddColumn<string>(
name: "Origin",
schema: "skills",
table: "skills",
type: "character varying(20)",
maxLength: 20,
nullable: false,
defaultValue: "Builtin");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills",
column: "OrganizationId");
migrationBuilder.CreateIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "OrganizationId", "SkillKey", "Version" },
unique: true)
.Annotation("Npgsql:NullsDistinct", false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropIndex(
name: "IX_skills_OrganizationId_SkillKey_Version",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "AuthoredByMemberId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "OrganizationId",
schema: "skills",
table: "skills");
migrationBuilder.DropColumn(
name: "Origin",
schema: "skills",
table: "skills");
migrationBuilder.CreateIndex(
name: "IX_skills_SkillKey_Version",
schema: "skills",
table: "skills",
columns: new[] { "SkillKey", "Version" },
unique: true);
}
}
}
@@ -31,6 +31,9 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
@@ -64,6 +67,14 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<string>("Origin")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Outputs")
.HasMaxLength(2000)
.HasColumnType("character varying(2000)");
@@ -114,11 +125,15 @@ namespace TeamUp.Modules.Skills.Persistence.Migrations
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("Status");
b.HasIndex("SkillKey", "Version")
b.HasIndex("OrganizationId", "SkillKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "SkillKey", "Version"), false);
b.ToTable("skills", "skills");
});
@@ -17,6 +17,7 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
{
skill.ToTable("skills");
skill.HasKey(s => s.Id);
skill.Property(s => s.Origin).HasConversion<string>().HasMaxLength(20);
skill.Property(s => s.SkillKey).HasMaxLength(128).IsRequired();
skill.Property(s => s.Name).HasMaxLength(200).IsRequired();
skill.Property(s => s.Version).HasMaxLength(32).IsRequired();
@@ -33,7 +34,12 @@ internal sealed class SkillsDbContext(DbContextOptions<SkillsDbContext> options)
skill.OwnsMany(s => s.Actions, owned => owned.ToJson());
skill.OwnsMany(s => s.GoldenTests, owned => owned.ToJson());
skill.HasIndex(s => new { s.SkillKey, s.Version }).IsUnique();
// Identity is org-scoped: an org owns its (key, version); builtins share the null-org
// namespace. NULLS NOT DISTINCT so two builtins can't collide on (null, key, version).
skill.HasIndex(s => new { s.OrganizationId, s.SkillKey, s.Version })
.IsUnique()
.AreNullsDistinct(false);
skill.HasIndex(s => s.OrganizationId);
skill.HasIndex(s => s.Status);
});
}
@@ -0,0 +1,14 @@
namespace TeamUp.Modules.Skills;
/// <summary>
/// Platform-operator settings for managing the shared builtin skill library (null-org skills,
/// visible to every tenant). Builtin management is NOT a tenant action — the endpoints that
/// create/sync builtins require <see cref="AdminKey"/>, which no tenant role grants.
/// </summary>
internal sealed class SkillAdminOptions
{
public const string SectionName = "Skills";
/// <summary>Operator key required to manage builtins. Null/empty ⇒ builtin management is disabled.</summary>
public string? AdminKey { get; set; }
}
@@ -24,6 +24,7 @@ public sealed class SkillsModule : IModule
var connectionString = configuration.GetConnectionString("Postgres")
?? throw new InvalidOperationException("Missing connection string 'ConnectionStrings:Postgres'.");
services.Configure<SkillAdminOptions>(configuration.GetSection(SkillAdminOptions.SectionName));
services.AddDbContext<SkillsDbContext>(options => options.UseNpgsql(connectionString, npgsql => npgsql.UseVector()));
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<SkillsDbContext>());
services.AddSingleton<ISkillEmbedder, HashingSkillEmbedder>();
@@ -14,6 +14,7 @@ public static class AccessPolicy
Capability.InvitePeople
or Capability.CreateProductsAndTeams
or Capability.ConfigureAgents
or Capability.ManageSkills
or Capability.SetAutonomy
or Capability.ApproveHeldActions
or Capability.WorkTasks
@@ -11,6 +11,7 @@ public enum Capability
InvitePeople,
CreateProductsAndTeams,
ConfigureAgents,
ManageSkills,
SetAutonomy,
ApproveHeldActions,
WorkTasks,
@@ -122,6 +122,7 @@ public sealed class AnyRoleSeatTests(PostgresFixture postgres) : IClassFixture<P
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client;
}
@@ -53,6 +53,7 @@ public sealed class AssemblerRunTests(PostgresFixture postgres) : IClassFixture<
using var client = factory.CreateClient();
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" });
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();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
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 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(
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(
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]
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();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", owner!.Token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
// Index the SKILL.md.
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.Equal("spec-writing", indexed!.Skill.SkillKey);
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");
// Queryable by its role…
@@ -45,6 +45,7 @@ public sealed class SkillSyncTests(PostgresFixture postgres) : IClassFixture<Pos
using var client = factory.CreateClient();
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);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
@@ -11,12 +11,17 @@ public sealed class TeamUpWebFactory(
string connectionString,
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)
{
builder.UseEnvironment("Development");
builder.UseSetting("ConnectionStrings:Postgres", connectionString);
builder.UseSetting("Database:ApplyMigrationsOnStartup", "true");
builder.UseSetting("OpenTelemetry:OtlpEndpoint", string.Empty);
builder.UseSetting("Skills:AdminKey", PlatformAdminKey);
if (settings is not null)
{
@@ -232,6 +232,7 @@ public sealed class TwoRoleLoopTests(PostgresFixture postgres) : IClassFixture<P
{
var client = factory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
client.DefaultRequestHeaders.Add("X-Skills-Admin-Key", TeamUpWebFactory.PlatformAdminKey);
return client;
}