From fad476f1159de691ae8d9c281e21b518f84bc09c Mon Sep 17 00:00:00 2001 From: "soroush.asadi" Date: Sat, 13 Jun 2026 11:09:02 +0330 Subject: [PATCH] Dynamic per-org skill library: in-app authoring, versioning, fork (+ marketplace seam) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/App.tsx | 2 + client/src/components/AppShell.tsx | 2 + client/src/pages/SkillsPage.tsx | 523 ++++++++++++++++++ .../TeamUp.Modules.Skills/Domain/Skill.cs | 18 +- .../Domain/SkillTypes.cs | 12 + .../Endpoints/SkillsDtos.cs | 33 +- .../Endpoints/SkillsEndpoints.cs | 200 ++++++- .../Indexing/SkillIndexer.cs | 68 ++- ...260610180442_AddSkillOwnership.Designer.cs | 203 +++++++ .../20260610180442_AddSkillOwnership.cs | 95 ++++ .../SkillsDbContextModelSnapshot.cs | 17 +- .../Persistence/SkillsDbContext.cs | 8 +- .../SkillAdminOptions.cs | 14 + .../TeamUp.Modules.Skills/SkillsModule.cs | 1 + .../Access/AccessPolicy.cs | 1 + .../TeamUp.SharedKernel/Access/Capability.cs | 1 + .../AnyRoleSeatTests.cs | 1 + .../AssemblerRunTests.cs | 1 + .../ReviewFlowTests.cs | 1 + .../SkillLibraryTests.cs | 213 +++++++ .../SkillRegistryTests.cs | 14 +- .../TeamUp.IntegrationTests/SkillSyncTests.cs | 1 + .../TeamUpWebFactory.cs | 5 + .../TwoRoleLoopTests.cs | 1 + 24 files changed, 1398 insertions(+), 37 deletions(-) create mode 100644 client/src/pages/SkillsPage.tsx create mode 100644 src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260610180442_AddSkillOwnership.Designer.cs create mode 100644 src/Modules/TeamUp.Modules.Skills/Persistence/Migrations/20260610180442_AddSkillOwnership.cs create mode 100644 src/Modules/TeamUp.Modules.Skills/SkillAdminOptions.cs create mode 100644 tests/TeamUp.IntegrationTests/SkillLibraryTests.cs diff --git a/client/src/App.tsx b/client/src/App.tsx index 4439503..3ff88fd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -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() { : } /> : } /> : } /> + : } /> : } /> } /> diff --git a/client/src/components/AppShell.tsx b/client/src/components/AppShell.tsx index cdbddef..429847f 100644 --- a/client/src/components/AppShell.tsx +++ b/client/src/components/AppShell.tsx @@ -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 }) { + diff --git a/client/src/pages/SkillsPage.tsx b/client/src/pages/SkillsPage.tsx new file mode 100644 index 0000000..945e39e --- /dev/null +++ b/client/src/pages/SkillsPage.tsx @@ -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([]) + const [marketplace, setMarketplace] = useState([]) + const [form, setForm] = useState(null) + const [busy, setBusy] = useState(false) + + const load = useCallback(async () => { + if (!organizationId) return + try { + const [lib, market] = await Promise.all([ + api.get(`/api/skills?organizationId=${organizationId}`), + api.get(`/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() + 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(`/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 ( + +
+
+
+

+ Skills +

+

+ Your company's skill library. Builtin starter skills are shared; author and version your own. +

+
+ +
+ +
+ setTab('library')} icon={BookMarked}>Library + setTab('marketplace')} icon={Store}>Marketplace +
+ + {tab === 'library' ? ( +
+ {groups.map(([key, versions]) => ( + openForm(key, v, 'version')} + onEdit={(v) => openForm(key, v, 'edit')} + onFork={(v) => fork(key, v)} + /> + ))} + {groups.length === 0 && ( +

No skills yet. Run a Git sync for builtins, or author one.

+ )} +
+ ) : ( +
+

+ Public skills shared by other organizations. One-click install lands in the next step. +

+ {marketplace.map((s) => ( + + + + {s.name} {s.version} + + {s.summary} + + + {s.roles.map((r) => {r})} + + + + ))} + {marketplace.length === 0 &&

Nothing published yet.

} +
+ )} +
+ + {form && ( + !o && setForm(null)}> + + + + {form.mode === 'new' ? 'New skill' : form.mode === 'version' ? `New version of ${form.skillKey}` : `Edit ${form.skillKey}`} + + + {willPublish + ? 'Has roles + a golden test — saves as Published.' + : 'Add ≥1 role and ≥1 golden test to publish; otherwise saved as Draft.'} + + + +
+
+ + setForm({ ...form, skillKey: e.target.value })} + placeholder="api-endpoint-design" + /> + + + setForm({ ...form, version: e.target.value })} /> + +
+ + setForm({ ...form, name: e.target.value })} placeholder="API Endpoint Design" /> + + + setForm({ ...form, summary: e.target.value })} /> + + + setForm({ ...form, roles: e.target.value })} placeholder="engineer" /> + +
+ + setForm({ ...form, inputs: e.target.value })} /> + + + setForm({ ...form, outputs: e.target.value })} /> + +
+
+ + setForm({ ...form, visibility: v })} /> + + + setForm({ ...form, minTier: v })} /> + +
+
+ + setForm({ ...form, tools: e.target.value })} /> + + + setForm({ ...form, context: e.target.value })} /> + +
+ + setForm({ ...form, actions: [...form.actions, { name: '', risk: 'draft', description: '' }] })} + > + {form.actions.map((a, i) => ( +
+ setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, name: e.target.value } : x)) })} + /> + setForm({ ...form, actions: form.actions.map((x, j) => (j === i ? { ...x, risk: v } : x)) })} + /> + +
+ ))} +
+ + setForm({ ...form, goldenTests: [...form.goldenTests, { input: '', expected: '' }] })} + > + {form.goldenTests.map((g, i) => ( +
+
+ setForm({ ...form, goldenTests: form.goldenTests.map((x, j) => (j === i ? { ...x, input: e.target.value } : x)) })} + /> + +
+