Merge: skill marketplace (publish/install across orgs) + review hardening
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { BookMarked, GitFork, Pencil, Plus, Store, Trash2 } from 'lucide-react'
|
import { BookMarked, Download, GitFork, Pencil, Plus, Store, Trash2, Upload } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { AppShell } from '@/components/AppShell'
|
import { AppShell } from '@/components/AppShell'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
@@ -32,6 +32,7 @@ interface GoldenTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SkillSummary {
|
interface SkillSummary {
|
||||||
|
id: string
|
||||||
skillKey: string
|
skillKey: string
|
||||||
name: string
|
name: string
|
||||||
version: string
|
version: string
|
||||||
@@ -56,6 +57,11 @@ interface SkillDetail {
|
|||||||
body: string
|
body: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface MarketplaceEntry {
|
||||||
|
skill: SkillSummary
|
||||||
|
alreadyInLibrary: boolean
|
||||||
|
}
|
||||||
|
|
||||||
type Mode = 'new' | 'version' | 'edit'
|
type Mode = 'new' | 'version' | 'edit'
|
||||||
|
|
||||||
interface FormState {
|
interface FormState {
|
||||||
@@ -92,7 +98,7 @@ const emptyForm = (): FormState => ({
|
|||||||
outputs: '',
|
outputs: '',
|
||||||
tools: '',
|
tools: '',
|
||||||
context: '',
|
context: '',
|
||||||
visibility: 'public',
|
visibility: 'private',
|
||||||
minTier: 'free',
|
minTier: 'free',
|
||||||
body: '',
|
body: '',
|
||||||
actions: [],
|
actions: [],
|
||||||
@@ -120,7 +126,7 @@ export function SkillsPage() {
|
|||||||
const organizationId = useAuth((s) => s.organizationId)
|
const organizationId = useAuth((s) => s.organizationId)
|
||||||
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
const [tab, setTab] = useState<'library' | 'marketplace'>('library')
|
||||||
const [skills, setSkills] = useState<SkillSummary[]>([])
|
const [skills, setSkills] = useState<SkillSummary[]>([])
|
||||||
const [marketplace, setMarketplace] = useState<SkillSummary[]>([])
|
const [marketplace, setMarketplace] = useState<MarketplaceEntry[]>([])
|
||||||
const [form, setForm] = useState<FormState | null>(null)
|
const [form, setForm] = useState<FormState | null>(null)
|
||||||
const [busy, setBusy] = useState(false)
|
const [busy, setBusy] = useState(false)
|
||||||
|
|
||||||
@@ -129,7 +135,7 @@ export function SkillsPage() {
|
|||||||
try {
|
try {
|
||||||
const [lib, market] = await Promise.all([
|
const [lib, market] = await Promise.all([
|
||||||
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
api.get<SkillSummary[]>(`/api/skills?organizationId=${organizationId}`),
|
||||||
api.get<SkillSummary[]>(`/api/skills/marketplace`),
|
api.get<MarketplaceEntry[]>(`/api/skills/marketplace?organizationId=${organizationId}`),
|
||||||
])
|
])
|
||||||
setSkills(lib)
|
setSkills(lib)
|
||||||
setMarketplace(market)
|
setMarketplace(market)
|
||||||
@@ -193,6 +199,32 @@ export function SkillsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const setListed = async (key: string, version: string, listed: boolean) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post(`/api/skills/${key}/${listed ? 'publish' : 'unpublish'}`, { organizationId, version })
|
||||||
|
toast.success(listed ? `Published ${key}@${version} to the marketplace.` : `Unlisted ${key}@${version}.`)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const install = async (sourceSkillId: string, name: string) => {
|
||||||
|
setBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post('/api/skills/install', { organizationId, sourceSkillId })
|
||||||
|
toast.success(`Installed ${name} into your library.`)
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message)
|
||||||
|
} finally {
|
||||||
|
setBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!form) return
|
if (!form) return
|
||||||
setBusy(true)
|
setBusy(true)
|
||||||
@@ -260,6 +292,8 @@ export function SkillsPage() {
|
|||||||
onNewVersion={(v) => openForm(key, v, 'version')}
|
onNewVersion={(v) => openForm(key, v, 'version')}
|
||||||
onEdit={(v) => openForm(key, v, 'edit')}
|
onEdit={(v) => openForm(key, v, 'edit')}
|
||||||
onFork={(v) => fork(key, v)}
|
onFork={(v) => fork(key, v)}
|
||||||
|
onPublish={(v) => setListed(key, v, true)}
|
||||||
|
onUnpublish={(v) => setListed(key, v, false)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{groups.length === 0 && (
|
{groups.length === 0 && (
|
||||||
@@ -269,23 +303,38 @@ export function SkillsPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Public skills shared by other organizations. One-click install lands in the next step.
|
Published skills shared by other organizations. Install a copy into your library — it lands private,
|
||||||
|
so you can edit or version it freely.
|
||||||
</p>
|
</p>
|
||||||
{marketplace.map((s) => (
|
{marketplace.map(({ skill: s, alreadyInLibrary }) => (
|
||||||
<Card key={`${s.organizationId}-${s.skillKey}-${s.version}`}>
|
<Card key={s.id}>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center gap-2 text-base">
|
<CardTitle className="flex items-center gap-2 text-base">
|
||||||
{s.name} <Badge variant="outline">{s.version}</Badge>
|
{s.name} <Badge variant="outline">{s.version}</Badge>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{s.skillKey}</span>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>{s.summary}</CardDescription>
|
<CardDescription>{s.summary}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="flex items-center gap-2">
|
<CardContent className="flex flex-wrap items-center gap-2">
|
||||||
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
{s.roles.map((r) => <Badge key={r} variant="secondary">{r}</Badge>)}
|
||||||
<Button size="sm" variant="outline" disabled className="ml-auto">Install (soon)</Button>
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{s.goldenTestCount} golden test{s.goldenTestCount === 1 ? '' : 's'}
|
||||||
|
</span>
|
||||||
|
{alreadyInLibrary ? (
|
||||||
|
<Badge variant="secondary" className="ml-auto">In your library</Badge>
|
||||||
|
) : (
|
||||||
|
<Button size="sm" disabled={busy} className="ml-auto" onClick={() => install(s.id, s.name)}>
|
||||||
|
<Download data-icon="inline-start" /> Install
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
{marketplace.length === 0 && <p className="text-sm text-muted-foreground">Nothing published yet.</p>}
|
{marketplace.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nothing published yet. Publish one of your own skills to share it here.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -420,16 +469,22 @@ function SkillGroupCard({
|
|||||||
onNewVersion,
|
onNewVersion,
|
||||||
onEdit,
|
onEdit,
|
||||||
onFork,
|
onFork,
|
||||||
|
onPublish,
|
||||||
|
onUnpublish,
|
||||||
}: {
|
}: {
|
||||||
versions: SkillSummary[]
|
versions: SkillSummary[]
|
||||||
busy: boolean
|
busy: boolean
|
||||||
onNewVersion: (version: string) => void
|
onNewVersion: (version: string) => void
|
||||||
onEdit: (version: string) => void
|
onEdit: (version: string) => void
|
||||||
onFork: (version: string) => void
|
onFork: (version: string) => void
|
||||||
|
onPublish: (version: string) => void
|
||||||
|
onUnpublish: (version: string) => void
|
||||||
}) {
|
}) {
|
||||||
const [selected, setSelected] = useState(versions[0].version)
|
const [selected, setSelected] = useState(versions[0].version)
|
||||||
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
const current = versions.find((v) => v.version === selected) ?? versions[0]
|
||||||
const isBuiltin = current.origin === 'Builtin'
|
const isBuiltin = current.origin === 'Builtin'
|
||||||
|
const isListed = current.visibility === 'Public'
|
||||||
|
const canPublish = !isBuiltin && current.status === 'Published'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -456,6 +511,7 @@ function SkillGroupCard({
|
|||||||
)}
|
)}
|
||||||
{current.roles.map((r) => <Badge key={r} variant="secondary">{r}</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>
|
<span className="text-xs text-muted-foreground">{current.goldenTestCount} golden test{current.goldenTestCount === 1 ? '' : 's'}</span>
|
||||||
|
{isListed && <Badge variant="default" className="gap-1"><Store className="size-3" /> Listed</Badge>}
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{isBuiltin ? (
|
{isBuiltin ? (
|
||||||
@@ -463,9 +519,20 @@ function SkillGroupCard({
|
|||||||
<GitFork data-icon="inline-start" /> Fork to my org
|
<GitFork data-icon="inline-start" /> Fork to my org
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onEdit(current.version)}>
|
||||||
<Pencil data-icon="inline-start" /> Edit
|
<Pencil data-icon="inline-start" /> Edit
|
||||||
</Button>
|
</Button>
|
||||||
|
{isListed ? (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onUnpublish(current.version)}>
|
||||||
|
Unlist
|
||||||
|
</Button>
|
||||||
|
) : canPublish ? (
|
||||||
|
<Button size="sm" variant="outline" disabled={busy} onClick={() => onPublish(current.version)}>
|
||||||
|
<Upload data-icon="inline-start" /> Publish
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
<Button size="sm" disabled={busy} onClick={() => onNewVersion(current.version)}>
|
||||||
<Plus data-icon="inline-start" /> New version
|
<Plus data-icon="inline-start" /> New version
|
||||||
|
|||||||
@@ -75,9 +75,12 @@ internal sealed class Skill : Entity
|
|||||||
GoldenTests = manifest.GoldenTests
|
GoldenTests = manifest.GoldenTests
|
||||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||||
.ToList();
|
.ToList();
|
||||||
Visibility = ParseVisibility(manifest.Visibility);
|
|
||||||
MinTier = ParseTier(manifest.MinTier);
|
|
||||||
Status = status;
|
Status = status;
|
||||||
|
// Invariant: only a published (golden-tested) skill may be Public. This holds on every
|
||||||
|
// (re)projection, so re-authoring a listed version without golden tests can't leave it
|
||||||
|
// Public+Draft — the marketplace/listing gate can never be decoupled from the eval gate.
|
||||||
|
Visibility = status == SkillStatus.Published ? ParseVisibility(manifest.Visibility) : SkillVisibility.PrivateToOrg;
|
||||||
|
MinTier = ParseTier(manifest.MinTier);
|
||||||
Body = body;
|
Body = body;
|
||||||
ContentHash = contentHash;
|
ContentHash = contentHash;
|
||||||
SourceRepo = sourceRepo;
|
SourceRepo = sourceRepo;
|
||||||
@@ -87,6 +90,13 @@ internal sealed class Skill : Entity
|
|||||||
UpdatedAtUtc = nowUtc;
|
UpdatedAtUtc = nowUtc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Lists/unlists this version on the marketplace (Public ⇔ PrivateToOrg).</summary>
|
||||||
|
public void SetVisibility(SkillVisibility visibility, DateTimeOffset nowUtc)
|
||||||
|
{
|
||||||
|
Visibility = visibility;
|
||||||
|
UpdatedAtUtc = nowUtc;
|
||||||
|
}
|
||||||
|
|
||||||
private static string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
|
private static string Normalize(string value) => value.Trim().Replace("-", string.Empty).Replace("_", string.Empty);
|
||||||
|
|
||||||
private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch
|
private static ActionRisk ParseRisk(string value) => Normalize(value).ToLowerInvariant() switch
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ internal sealed record ActionDto(string Name, string Risk, string? Description =
|
|||||||
internal sealed record GoldenTestDto(string Input, string Expected);
|
internal sealed record GoldenTestDto(string Input, string Expected);
|
||||||
|
|
||||||
internal sealed record SkillSummary(
|
internal sealed record SkillSummary(
|
||||||
|
Guid Id,
|
||||||
string SkillKey,
|
string SkillKey,
|
||||||
string Name,
|
string Name,
|
||||||
string Version,
|
string Version,
|
||||||
@@ -54,3 +55,12 @@ internal sealed record AuthorSkillRequest(
|
|||||||
|
|
||||||
/// <summary>Copy a builtin/other skill into an org as an editable Authored skill.</summary>
|
/// <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);
|
internal sealed record ForkSkillRequest(Guid OrganizationId, string Version, string? Name);
|
||||||
|
|
||||||
|
/// <summary>List/unlist one of an org's own (published) skill versions on the marketplace.</summary>
|
||||||
|
internal sealed record PublishSkillRequest(Guid OrganizationId, string Version);
|
||||||
|
|
||||||
|
/// <summary>Install a public marketplace skill (by its row id) into an org as an Installed copy.</summary>
|
||||||
|
internal sealed record InstallSkillRequest(Guid OrganizationId, Guid SourceSkillId);
|
||||||
|
|
||||||
|
/// <summary>A marketplace listing plus whether the requesting org already holds that skill key.</summary>
|
||||||
|
internal sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary);
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ internal static class SkillsEndpoints
|
|||||||
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
group.MapGet("/{key}", GetSkill).RequireAuthorization();
|
||||||
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
group.MapPost("/authored", AuthorSkill).RequireAuthorization();
|
||||||
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
group.MapPost("/{key}/fork", ForkSkill).RequireAuthorization();
|
||||||
|
group.MapPost("/{key}/publish", PublishSkill).RequireAuthorization();
|
||||||
|
group.MapPost("/{key}/unpublish", UnpublishSkill).RequireAuthorization();
|
||||||
|
group.MapPost("/install", InstallSkill).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();
|
||||||
@@ -102,14 +105,143 @@ internal static class SkillsEndpoints
|
|||||||
|
|
||||||
// Marketplace seam (read-only groundwork): publicly-shared, org-authored skills from any org.
|
// 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.
|
// Publishing controls and install-into-your-org land in the next step.
|
||||||
private static async Task<IResult> Marketplace(SkillsDbContext db, CancellationToken ct)
|
// The marketplace: published skills other orgs have listed publicly. Excludes your own skills
|
||||||
|
// and flags any whose key already exists in your library (installed or authored).
|
||||||
|
private static async Task<IResult> Marketplace(
|
||||||
|
Guid organizationId, IPermissionService permissions, SkillsDbContext db, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(organizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
var listed = await db.Skills
|
var listed = await db.Skills
|
||||||
.Where(s => s.Origin == SkillOrigin.Authored && s.Visibility == SkillVisibility.Public)
|
.Where(s => s.Origin == SkillOrigin.Authored
|
||||||
|
&& s.Visibility == SkillVisibility.Public
|
||||||
|
&& s.Status == SkillStatus.Published
|
||||||
|
&& s.OrganizationId != null
|
||||||
|
&& s.OrganizationId != organizationId)
|
||||||
.OrderBy(s => s.SkillKey)
|
.OrderBy(s => s.SkillKey)
|
||||||
.ThenByDescending(s => s.Version)
|
.ThenByDescending(s => s.Version)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return Results.Ok(listed.Select(ToSummary).ToList());
|
|
||||||
|
// Flag by (key, version) — matching the install conflict rule — so a newer, not-yet-owned
|
||||||
|
// version of a key you already hold still shows as installable.
|
||||||
|
var owned = (await db.Skills
|
||||||
|
.Where(s => s.OrganizationId == organizationId)
|
||||||
|
.Select(s => new { s.SkillKey, s.Version })
|
||||||
|
.ToListAsync(ct))
|
||||||
|
.Select(s => (s.SkillKey, s.Version))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
return Results.Ok(listed
|
||||||
|
.Select(s => new MarketplaceEntry(ToSummary(s), owned.Contains((s.SkillKey, s.Version))))
|
||||||
|
.ToList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> PublishSkill(
|
||||||
|
string key, PublishSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
IAuditLog audit, SkillsDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||||
|
s => s.OrganizationId == request.OrganizationId && s.SkillKey == key && s.Version == request.Version, ct);
|
||||||
|
if (skill is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only golden-tested (published) skills may be listed — the marketplace inherits the eval gate.
|
||||||
|
if (skill.Status != SkillStatus.Published)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("Only a published (golden-tested) skill can be listed on the marketplace.");
|
||||||
|
}
|
||||||
|
|
||||||
|
skill.SetVisibility(SkillVisibility.Public, clock.GetUtcNow());
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(
|
||||||
|
new AuditEvent("skill.published", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||||
|
return Results.Ok(ToDetail(skill));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> UnpublishSkill(
|
||||||
|
string key, PublishSkillRequest request, ICurrentUser user, IPermissionService permissions,
|
||||||
|
IAuditLog audit, SkillsDbContext db, TimeProvider clock, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (!permissions.Has(Capability.ManageSkills, ScopeRef.Org(request.OrganizationId)))
|
||||||
|
{
|
||||||
|
return Results.Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
var skill = await db.Skills.FirstOrDefaultAsync(
|
||||||
|
s => s.OrganizationId == request.OrganizationId && s.SkillKey == key && s.Version == request.Version, ct);
|
||||||
|
if (skill is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
skill.SetVisibility(SkillVisibility.PrivateToOrg, clock.GetUtcNow());
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
await audit.WriteAsync(
|
||||||
|
new AuditEvent("skill.unpublished", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||||
|
return Results.Ok(ToDetail(skill));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy a publicly-listed skill into the caller's org as a private Installed copy.
|
||||||
|
private static async Task<IResult> InstallSkill(
|
||||||
|
InstallSkillRequest 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();
|
||||||
|
}
|
||||||
|
|
||||||
|
var source = await db.Skills.FirstOrDefaultAsync(s => s.Id == request.SourceSkillId, ct);
|
||||||
|
if (source is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.Origin != SkillOrigin.Authored
|
||||||
|
|| source.Visibility != SkillVisibility.Public
|
||||||
|
|| source.Status != SkillStatus.Published)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("That skill is not published to the marketplace.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source.OrganizationId == request.OrganizationId)
|
||||||
|
{
|
||||||
|
return Results.BadRequest("That skill already belongs to your organization.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await db.Skills.AnyAsync(
|
||||||
|
s => s.OrganizationId == request.OrganizationId && s.SkillKey == source.SkillKey && s.Version == source.Version, ct))
|
||||||
|
{
|
||||||
|
return Results.Conflict("This skill version is already in your library.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifest = ToManifest(source);
|
||||||
|
manifest.Visibility = "private"; // an installed copy is private until the installer chooses to publish it
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// insertOnly: the DB unique index is the source of truth, so a race with a concurrent
|
||||||
|
// install/author of the same (org, key, version) becomes a clean 409, never a clobber.
|
||||||
|
var skill = await indexer.IndexAsync(
|
||||||
|
manifest, source.Body, SkillOwnership.Installed(request.OrganizationId, user.MemberId),
|
||||||
|
insertOnly: true, cancellationToken: ct);
|
||||||
|
await audit.WriteAsync(
|
||||||
|
new AuditEvent("skill.installed", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||||
|
return Results.Ok(ToDetail(skill));
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
return Results.Conflict("This skill version is already in your library.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetSkill(
|
private static async Task<IResult> GetSkill(
|
||||||
@@ -150,7 +282,7 @@ internal static class SkillsEndpoints
|
|||||||
|
|
||||||
var manifest = ToManifest(request);
|
var manifest = ToManifest(request);
|
||||||
var skill = await indexer.IndexAsync(
|
var skill = await indexer.IndexAsync(
|
||||||
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
manifest, request.Body.Trim(), SkillOwnership.Authored(request.OrganizationId, user.MemberId), cancellationToken: ct);
|
||||||
|
|
||||||
await audit.WriteAsync(
|
await audit.WriteAsync(
|
||||||
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
new AuditEvent("skill.authored", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||||
@@ -183,7 +315,7 @@ internal static class SkillsEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
var skill = await indexer.IndexAsync(
|
var skill = await indexer.IndexAsync(
|
||||||
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), ct);
|
manifest, source.Body, SkillOwnership.Authored(request.OrganizationId, user.MemberId), cancellationToken: ct);
|
||||||
|
|
||||||
await audit.WriteAsync(
|
await audit.WriteAsync(
|
||||||
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
new AuditEvent("skill.forked", "Skill", skill.Id, user.MemberId, $"{skill.SkillKey}@{skill.Version}"), ct);
|
||||||
@@ -231,7 +363,8 @@ internal static class SkillsEndpoints
|
|||||||
.ToList(),
|
.ToList(),
|
||||||
Tools = request.Tools ?? [],
|
Tools = request.Tools ?? [],
|
||||||
Context = request.Context ?? [],
|
Context = request.Context ?? [],
|
||||||
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "public" : request.Visibility,
|
// Authored skills are private by default; listing on the marketplace is an explicit publish step.
|
||||||
|
Visibility = string.IsNullOrWhiteSpace(request.Visibility) ? "private" : request.Visibility,
|
||||||
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
|
MinTier = string.IsNullOrWhiteSpace(request.MinTier) ? "free" : request.MinTier,
|
||||||
GoldenTests = (request.GoldenTests ?? [])
|
GoldenTests = (request.GoldenTests ?? [])
|
||||||
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
.Select(g => new GoldenExample { Input = g.Input, Expected = g.Expected })
|
||||||
@@ -258,6 +391,7 @@ internal static class SkillsEndpoints
|
|||||||
};
|
};
|
||||||
|
|
||||||
private static SkillSummary ToSummary(Skill skill) => new(
|
private static SkillSummary ToSummary(Skill skill) => new(
|
||||||
|
skill.Id,
|
||||||
skill.SkillKey,
|
skill.SkillKey,
|
||||||
skill.Name,
|
skill.Name,
|
||||||
skill.Version,
|
skill.Version,
|
||||||
|
|||||||
@@ -34,19 +34,25 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
|||||||
{
|
{
|
||||||
var parsed = SkillMarkdownParser.Parse(content);
|
var parsed = SkillMarkdownParser.Parse(content);
|
||||||
var contentHash = Hash(content);
|
var contentHash = Hash(content);
|
||||||
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, cancellationToken);
|
return IndexAsync(parsed.Manifest, parsed.Body, contentHash, SkillOwnership.Builtin, sourceRepo, sourcePath, sourceCommit, insertOnly: false, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Index a manifest authored/installed in-app — same pipeline, org-owned.</summary>
|
/// <summary>
|
||||||
|
/// Index a manifest authored/installed in-app — same pipeline, org-owned. When
|
||||||
|
/// <paramref name="insertOnly"/> is true the row must not already exist: it is always inserted
|
||||||
|
/// and a colliding (org, key, version) trips the unique index (DbUpdateException) rather than
|
||||||
|
/// silently overwriting — the install path uses this so a race can't clobber an existing skill.
|
||||||
|
/// </summary>
|
||||||
public Task<Skill> IndexAsync(
|
public Task<Skill> IndexAsync(
|
||||||
SkillManifest manifest,
|
SkillManifest manifest,
|
||||||
string body,
|
string body,
|
||||||
SkillOwnership ownership,
|
SkillOwnership ownership,
|
||||||
|
bool insertOnly = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
// The content hash spans the structured manifest + body so re-authoring changes it.
|
// 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}";
|
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);
|
return IndexAsync(manifest, body, Hash(canonical), ownership, sourceRepo: null, sourcePath: null, sourceCommit: null, insertOnly, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<Skill> IndexAsync(
|
private async Task<Skill> IndexAsync(
|
||||||
@@ -57,6 +63,7 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
|||||||
string? sourceRepo,
|
string? sourceRepo,
|
||||||
string? sourcePath,
|
string? sourcePath,
|
||||||
string? sourceCommit,
|
string? sourceCommit,
|
||||||
|
bool insertOnly,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
@@ -71,7 +78,9 @@ internal sealed class SkillIndexer(SkillsDbContext db, ISkillEmbedder embedder,
|
|||||||
? SkillStatus.Published
|
? SkillStatus.Published
|
||||||
: SkillStatus.Draft;
|
: SkillStatus.Draft;
|
||||||
|
|
||||||
var skill = await db.Skills.FirstOrDefaultAsync(
|
var skill = insertOnly
|
||||||
|
? null
|
||||||
|
: await db.Skills.FirstOrDefaultAsync(
|
||||||
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
|
s => s.OrganizationId == ownership.OrganizationId && s.SkillKey == manifest.Id && s.Version == manifest.Version,
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using TeamUp.Modules.Skills.Domain;
|
||||||
|
using TeamUp.Modules.Skills.Indexing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace TeamUp.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The skill marketplace, end to end: the publish gate (only golden-tested may list), own-org
|
||||||
|
/// exclusion, another org's published skill listed and installed as a private copy, the duplicate
|
||||||
|
/// conflict, and ManageSkills authorization. One flow per class — bootstrap is single-use.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SkillMarketplaceTests(PostgresFixture postgres) : IClassFixture<PostgresFixture>
|
||||||
|
{
|
||||||
|
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(
|
||||||
|
Guid Id, 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);
|
||||||
|
|
||||||
|
private sealed record MarketplaceEntry(SkillSummary Skill, bool AlreadyInLibrary);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Publishes_lists_installs_with_gate_own_exclusion_and_authorization()
|
||||||
|
{
|
||||||
|
await using var factory = new TeamUpWebFactory(postgres.ConnectionString);
|
||||||
|
using var anon = factory.CreateClient();
|
||||||
|
|
||||||
|
var orgA = await PostOk<BootstrapResponse>(anon, "/api/identity/bootstrap", new
|
||||||
|
{
|
||||||
|
organizationName = "OrgA",
|
||||||
|
ownerEmail = "owner@alia.test",
|
||||||
|
ownerDisplayName = "Owner",
|
||||||
|
ownerPassword = "Passw0rd!",
|
||||||
|
});
|
||||||
|
using var client = Authed(factory, orgA.Token);
|
||||||
|
|
||||||
|
// Gate: a Draft (no golden test) cannot be listed on the marketplace.
|
||||||
|
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "draft-skill", goldenTested: false));
|
||||||
|
var cantPublish = await client.PostAsJsonAsync("/api/skills/draft-skill/publish",
|
||||||
|
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, cantPublish.StatusCode);
|
||||||
|
|
||||||
|
// A published skill lists fine; visibility flips to Public, but it never shows in its own org's marketplace.
|
||||||
|
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "prod-skill", goldenTested: true));
|
||||||
|
var published = await PostOk<SkillDetail>(client, "/api/skills/prod-skill/publish",
|
||||||
|
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||||
|
Assert.Equal("Public", published.Skill.Visibility);
|
||||||
|
|
||||||
|
var ownMarket = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||||
|
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||||
|
Assert.DoesNotContain(ownMarket!, e => e.Skill.SkillKey == "prod-skill");
|
||||||
|
|
||||||
|
// You can't install your own skill.
|
||||||
|
var prod = await client.GetFromJsonAsync<List<SkillDetail>>(
|
||||||
|
$"/api/skills/prod-skill?organizationId={orgA.OrganizationId}");
|
||||||
|
var installOwn = await client.PostAsJsonAsync("/api/skills/install",
|
||||||
|
new { organizationId = orgA.OrganizationId, sourceSkillId = prod!.Single().Skill.Id });
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, installOwn.StatusCode);
|
||||||
|
|
||||||
|
// Another org's published skill (no second-org onboarding yet — seed it via the indexer).
|
||||||
|
var orgB = Guid.NewGuid();
|
||||||
|
var sourceId = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief");
|
||||||
|
|
||||||
|
// Org A sees org B's skill on the marketplace, not yet in its library.
|
||||||
|
var market = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||||
|
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||||
|
var entry = Assert.Single(market!, e => e.Skill.SkillKey == "research-brief");
|
||||||
|
Assert.False(entry.AlreadyInLibrary);
|
||||||
|
Assert.Equal(sourceId, entry.Skill.Id);
|
||||||
|
|
||||||
|
// Installing lands a private, Installed copy in org A's namespace.
|
||||||
|
var installed = await PostOk<SkillDetail>(client, "/api/skills/install",
|
||||||
|
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
|
||||||
|
Assert.Equal("Installed", installed.Skill.Origin);
|
||||||
|
Assert.Equal(orgA.OrganizationId, installed.Skill.OrganizationId);
|
||||||
|
Assert.Equal("PrivateToOrg", installed.Skill.Visibility);
|
||||||
|
Assert.Equal("research-brief", installed.Skill.SkillKey);
|
||||||
|
|
||||||
|
// Now it's in the library (flagged), and re-installing the same version is a conflict.
|
||||||
|
var market2 = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||||
|
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||||
|
Assert.True(Assert.Single(market2!, e => e.Skill.SkillKey == "research-brief").AlreadyInLibrary);
|
||||||
|
|
||||||
|
var dup = await client.PostAsJsonAsync("/api/skills/install",
|
||||||
|
new { organizationId = orgA.OrganizationId, sourceSkillId = sourceId });
|
||||||
|
Assert.Equal(HttpStatusCode.Conflict, dup.StatusCode);
|
||||||
|
|
||||||
|
// A newer version of an already-installed key stays installable — the flag is per (key, version).
|
||||||
|
var sourceV2 = await SeedPublishedSkillAsync(factory, orgB, "research-brief", "Research Brief", "2.0.0");
|
||||||
|
var market3 = await client.GetFromJsonAsync<List<MarketplaceEntry>>(
|
||||||
|
$"/api/skills/marketplace?organizationId={orgA.OrganizationId}");
|
||||||
|
Assert.False(Assert.Single(market3!, e => e.Skill.Id == sourceV2).AlreadyInLibrary);
|
||||||
|
Assert.True(Assert.Single(market3!, e => e.Skill.SkillKey == "research-brief" && e.Skill.Version == "1.0.0").AlreadyInLibrary);
|
||||||
|
|
||||||
|
// Invariant: re-authoring a listed version without golden tests cannot leave it Public.
|
||||||
|
await PostOk<SkillDetail>(client, "/api/skills/authored", Authored(orgA.OrganizationId, "regate-skill", goldenTested: true));
|
||||||
|
await PostOk<SkillDetail>(client, "/api/skills/regate-skill/publish",
|
||||||
|
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||||
|
var degated = await PostOk<SkillDetail>(client, "/api/skills/authored",
|
||||||
|
Authored(orgA.OrganizationId, "regate-skill", goldenTested: false, visibility: "public"));
|
||||||
|
Assert.Equal("Draft", degated.Skill.Status);
|
||||||
|
Assert.Equal("PrivateToOrg", degated.Skill.Visibility);
|
||||||
|
|
||||||
|
// A plain Member cannot publish/unpublish (ManageSkills).
|
||||||
|
var invite = await PostOk<InviteResponse>(client, "/api/identity/invitations", new
|
||||||
|
{
|
||||||
|
email = "member@alia.test",
|
||||||
|
scopeType = "Organization",
|
||||||
|
scopeId = orgA.OrganizationId,
|
||||||
|
role = "Member",
|
||||||
|
organizationId = orgA.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/prod-skill/unpublish",
|
||||||
|
new { organizationId = orgA.OrganizationId, version = "1.0.0" });
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, forbidden.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static object Authored(Guid organizationId, string key, bool goldenTested, string visibility = "private") => new
|
||||||
|
{
|
||||||
|
organizationId,
|
||||||
|
skillKey = key,
|
||||||
|
name = key,
|
||||||
|
version = "1.0.0",
|
||||||
|
summary = "x",
|
||||||
|
roles = new[] { "engineer" },
|
||||||
|
inputs = (string?)null,
|
||||||
|
outputs = (string?)null,
|
||||||
|
actions = Array.Empty<ActionDto>(),
|
||||||
|
tools = Array.Empty<string>(),
|
||||||
|
context = Array.Empty<string>(),
|
||||||
|
visibility,
|
||||||
|
minTier = "free",
|
||||||
|
body = "Do the thing.",
|
||||||
|
goldenTests = goldenTested
|
||||||
|
? new[] { new GoldenTestDto("in", "out") }
|
||||||
|
: Array.Empty<GoldenTestDto>(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// No public second-org onboarding yet, so seed the "other org's" published skill directly through
|
||||||
|
// the same indexer the authoring endpoint uses (InternalsVisibleTo grants the test access).
|
||||||
|
private static async Task<Guid> SeedPublishedSkillAsync(
|
||||||
|
TeamUpWebFactory factory, Guid orgId, string key, string name, string version = "1.0.0")
|
||||||
|
{
|
||||||
|
using var scope = factory.Services.CreateScope();
|
||||||
|
var indexer = scope.ServiceProvider.GetRequiredService<SkillIndexer>();
|
||||||
|
var manifest = new SkillManifest
|
||||||
|
{
|
||||||
|
Id = key,
|
||||||
|
Name = name,
|
||||||
|
Version = version,
|
||||||
|
Summary = "A shared, published skill.",
|
||||||
|
Roles = ["analyst"],
|
||||||
|
Visibility = "public",
|
||||||
|
GoldenTests = [new GoldenExample { Input = "a topic", Expected = "a brief" }],
|
||||||
|
};
|
||||||
|
var skill = await indexer.IndexAsync(manifest, "Write a research brief.", SkillOwnership.Authored(orgId, Guid.NewGuid()));
|
||||||
|
return skill.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user