diff --git a/apps/web/src/i18n/locales/en-US/admin.json b/apps/web/src/i18n/locales/en-US/admin.json index 16ea430..59836d9 100644 --- a/apps/web/src/i18n/locales/en-US/admin.json +++ b/apps/web/src/i18n/locales/en-US/admin.json @@ -681,10 +681,6 @@ "more": "More actions", "disable": "Disable", "enable": "Enable", - "publish": "Publish to market", - "unpublish": "Cancel publishing", - "deprecate": "Mark deprecated", - "undeprecate": "Clear deprecated", "delete": "Delete", "uninstall": "Remove from workspace" }, @@ -808,8 +804,7 @@ "deleteBlocked": "Remove this capability from those Agents before deleting it.", "errors": { "nameRequired": "Enter a capability name.", - "nameTooLong": "Capability name must be at most 50 characters.", - "plaintextSecretPattern": "{{version}} content appears to contain a plaintext credential. Replace tokens / secrets with the ${PARSAR_CREDENTIAL} placeholder before publishing to the market." + "nameTooLong": "Capability name must be at most 50 characters." }, "toast": { "created": "{{name}} was added. Enable it from Agent detail to use it.", @@ -843,7 +838,7 @@ "drawer": { "breadcrumb": "Agent Management / Capabilities", "title": "Capability market", - "description": "Browse capabilities published by every workspace. Pick one, then enable it on a specific Agent from Agent detail." + "description": "Browse this workspace's capability library. Pick one, then enable it on a specific Agent from Agent detail." }, "filters": { "all": "All", @@ -858,15 +853,15 @@ "install": "+ Add to my Agent", "installed": "Enabled on {{count}} Agent(s)", "installedBadge": "Added", - "selfPublished": "Published by you" + "usedByAgents": "Used by {{count}} Agent(s)" }, "detail": { "back": "Back to market list", - "addedCount": "Workspaces added", + "agentCount": "Agents using", "contentTitle": "Capability contents", "contentDescription": "Review the exact Skill files or MCP launch configuration before adding it to an Agent.", "loadErrorTitle": "Failed to load capability contents", - "loadErrorDescription": "The latest published version could not be loaded.", + "loadErrorDescription": "The latest version could not be loaded.", "contentUnavailable": "This older version does not contain previewable Skill or MCP content.", "sourceRepository": "Source repository", "sourceCommit": "Source commit", @@ -881,7 +876,7 @@ }, "empty": { "title": "No capabilities are available in the market yet", - "description": "Capabilities published by other workspaces will appear here. You can publish your own capability from its detail page." + "description": "Capabilities created or imported in this workspace will appear here." }, "loadError": { "title": "Failed to load capability market", @@ -908,7 +903,7 @@ }, "mcpDirectory": { "title": "Connectors", - "description": "Browse curated connectors and MCP capabilities published by workspaces in one marketplace.", + "description": "Browse curated connectors and this workspace's MCP capabilities in one library.", "verified": "Verified", "securityNotice": "Import only saves the configuration and does not run it immediately. The MCP can execute in a Runtime only after you enable it and bind it to an Agent.", "filters": { @@ -989,48 +984,6 @@ "badgeSource": "Deprecated", "bannerTarget": "The source workspace marked this capability deprecated. Agents already using it can keep using the pinned version, but cannot upgrade to a newer version." }, - "marketStatus": { - "title": "Market status", - "published": "Published to market", - "unpublished": "Not published", - "installCount": "{{count}} workspace(s) added", - "actions": { - "publish": "Publish to market", - "unpublish": "Cancel publishing", - "deprecate": "Deprecate", - "undeprecate": "Restore" - }, - "deprecateTooltip": "Once deprecated, the capability no longer appears in the picker when creating or editing agents. Existing agent bindings keep working as-is.", - "undeprecateTooltip": "Restoring makes the capability appear in the picker again when creating or editing agents.", - "toast": { - "publish": "\"{{name}}\" was published to the market.", - "unpublish": "\"{{name}}\" was removed from the market.", - "deprecate": "\"{{name}}\" was marked deprecated.", - "undeprecate": "\"{{name}}\" is no longer deprecated." - }, - "dialog": { - "publish": { - "title": "Publish \"{{name}}\" to the capability market?", - "description": "After publishing, admins in other workspaces can see this capability in the market and enable the latest version on their Agents. Published versions are kept permanently for stability.", - "confirm": "Publish to market" - }, - "unpublish": { - "title": "Cancel publishing \"{{name}}\"?", - "description": "After this, the capability no longer appears in the market. The {{count}} workspace(s) that already added it are unaffected, but cannot upgrade to later versions.", - "confirm": "Cancel publishing" - }, - "deprecate": { - "title": "Mark \"{{name}}\" deprecated?", - "description": "This hides it from the market. The {{count}} workspace(s) that already added it keep using their pinned version, but will see a migration hint and cannot upgrade.", - "confirm": "Mark deprecated" - }, - "undeprecate": { - "title": "Clear deprecated state for \"{{name}}\"?", - "description": "The capability appears in the market again, migration hints disappear, and existing workspaces can upgrade again.", - "confirm": "Clear deprecated" - } - } - }, "uninstall": { "sectionTitle": "Remove from my workspace", "sectionDescription": "Removal disables this capability on every Agent in the current workspace.", @@ -1491,7 +1444,7 @@ "badge": "From market", "source": "From {{source}} · current v{{version}}", "deprecatedBanner": "The source workspace marked this capability deprecated. This Agent can keep using pinned v{{version}}, but cannot upgrade.", - "upgradeAvailable": "The source workspace published v{{version}}.", + "upgradeAvailable": "A newer version is available: v{{version}}.", "upgradeBlocked": "Source capability is deprecated; upgrade is unavailable." } }, @@ -1687,7 +1640,7 @@ "versionPicker": { "latest": "latest", "localHint": "Pick latest to auto-follow re-uploads. Pick a specific version to pin.", - "marketplaceHint": "Marketplace: latest follows the publisher's newest release, which may include breaking changes." + "marketplaceHint": "Marketplace: latest follows the newest available release, which may include breaking changes." }, "wizard": { "stepOf": "Step {{current}} of {{total}}", diff --git a/apps/web/src/i18n/locales/en-US/common.json b/apps/web/src/i18n/locales/en-US/common.json index 712da12..65a5cc2 100644 --- a/apps/web/src/i18n/locales/en-US/common.json +++ b/apps/web/src/i18n/locales/en-US/common.json @@ -92,7 +92,7 @@ "renameDescription": "Change the workspace display name. The system identifier is permanent and not editable.", "archiveTitle": "Archive workspace", "archiveDescription": "Archive \"{{name}}\"? The workspace will disappear from the switcher; existing members and agents stay in the database but become unreachable through the workspace JOIN.", - "archiveMarketplaceDependents": "If capabilities published by this workspace are still used by Agents in other workspaces, archive will be blocked. Unpublish first and wait for those workspaces to remove the capabilities.", + "archiveMarketplaceDependents": "If capabilities from this workspace are still used by Agents in other workspaces, archive will be blocked until those bindings are removed.", "namePlaceholder": "e.g. Platform Team" }, "join": { diff --git a/apps/web/src/i18n/locales/zh-CN/admin.json b/apps/web/src/i18n/locales/zh-CN/admin.json index 5b9563e..5ed559c 100644 --- a/apps/web/src/i18n/locales/zh-CN/admin.json +++ b/apps/web/src/i18n/locales/zh-CN/admin.json @@ -681,10 +681,6 @@ "more": "更多操作", "disable": "停用", "enable": "启用", - "publish": "发布到市场", - "unpublish": "取消发布", - "deprecate": "标记为废弃", - "undeprecate": "解除废弃", "delete": "删除", "uninstall": "从工作区移除" }, @@ -808,8 +804,7 @@ "deleteBlocked": "请先从这些 Agent 移除装配,再删除此能力。", "errors": { "nameRequired": "请填写能力名。", - "nameTooLong": "能力名最多 50 个字符。", - "plaintextSecretPattern": "{{version}} 内容里看起来包含明文凭据。请先把 token / secret 替换成 ${PARSAR_CREDENTIAL} 占位符,再发布到市场。" + "nameTooLong": "能力名最多 50 个字符。" }, "toast": { "created": "{{name}} 能力已添加。到 Agent 详情即可启用使用。", @@ -843,7 +838,7 @@ "drawer": { "breadcrumb": "Agent 管理 / 能力", "title": "能力市场", - "description": "浏览所有工作区已发布的能力。选择一个能力后,到 Agent 详情页装到具体 Agent 使用。" + "description": "浏览当前工作区的能力库。选择一个能力后,到 Agent 详情页装到具体 Agent 使用。" }, "filters": { "all": "全部", @@ -858,15 +853,15 @@ "install": "+ 装到我的 Agent", "installed": "已装在 {{count}} 个 Agent", "installedBadge": "已添加", - "selfPublished": "你已发布" + "usedByAgents": "{{count}} 个 Agent 使用中" }, "detail": { "back": "返回市场列表", - "addedCount": "已添加工作区数", + "agentCount": "使用中的 Agent", "contentTitle": "能力内容", "contentDescription": "添加到 Agent 前,可查看完整的 Skill 文件或 MCP 启动配置。", "loadErrorTitle": "无法加载能力内容", - "loadErrorDescription": "无法读取最新发布版本。", + "loadErrorDescription": "无法读取最新版本。", "contentUnavailable": "该旧版本未保存可预览的 Skill 或 MCP 内容。", "sourceRepository": "来源仓库", "sourceCommit": "来源提交", @@ -881,7 +876,7 @@ }, "empty": { "title": "市场暂时没有可用的能力", - "description": "其它工作区发布能力后会显示在这里。你也可以进入能力详情发布自己的能力。" + "description": "当前工作区创建或导入的能力会显示在这里。" }, "loadError": { "title": "无法加载能力市场", @@ -908,7 +903,7 @@ }, "mcpDirectory": { "title": "连接器", - "description": "在同一个能力市场中浏览精选连接器和各工作区发布的 MCP 能力。", + "description": "在同一个能力库中浏览精选连接器和当前工作区的 MCP 能力。", "verified": "已验证", "securityNotice": "导入只会保存配置,不会立即运行。启用并绑定 Agent 后,该 MCP 才可能在 Runtime 中执行。", "filters": { @@ -989,48 +984,6 @@ "badgeSource": "已废弃", "bannerTarget": "此能力已被来源工作区标记废弃,建议迁移到其它能力。本工作区已装的 Agent 仍可使用锁定版本,但无法升级到新版本。" }, - "marketStatus": { - "title": "市场状态", - "published": "已发布到市场", - "unpublished": "未发布", - "installCount": "{{count}} 个工作区已添加", - "actions": { - "publish": "发布到市场", - "unpublish": "取消发布", - "deprecate": "下架", - "undeprecate": "上架" - }, - "deprecateTooltip": "下架后该能力不再出现在新建 / 编辑 Agent 的选择列表里;已绑定的 Agent 仍可继续使用。", - "undeprecateTooltip": "重新上架,该能力会出现在新建 / 编辑 Agent 的选择列表里。", - "toast": { - "publish": "「{{name}}」已发布到市场。", - "unpublish": "「{{name}}」已从市场撤回。", - "deprecate": "「{{name}}」已标记为废弃。", - "undeprecate": "「{{name}}」已解除废弃。" - }, - "dialog": { - "publish": { - "title": "发布「{{name}}」到能力市场?", - "description": "发布后,其它工作区 admin 可以在能力市场看到此能力,并把当前最新版本装到自己的 Agent。已发布版本会永久保留,确保引用方稳定。", - "confirm": "发布到市场" - }, - "unpublish": { - "title": "从市场取消发布「{{name}}」?", - "description": "取消发布后,此能力不再出现在能力市场。已添加的 {{count}} 个工作区不受影响,但无法升级到后续新版本。", - "confirm": "取消发布" - }, - "deprecate": { - "title": "将「{{name}}」标记为废弃?", - "description": "标记后,此能力会从市场列表隐藏;已添加的 {{count}} 个工作区仍可继续使用锁定版本,但会看到迁移提示且无法升级。", - "confirm": "标记为废弃" - }, - "undeprecate": { - "title": "解除「{{name}}」废弃状态?", - "description": "解除后,此能力重新出现在能力市场,已添加工作区的迁移提示会消失,并可以再次升级。", - "confirm": "解除废弃" - } - } - }, "uninstall": { "sectionTitle": "从我的工作区移除", "sectionDescription": "移除会从当前工作区所有 Agent 上卸载此能力。", @@ -1491,7 +1444,7 @@ "badge": "来自市场", "source": "来自 {{source}} · 当前版本 v{{version}}", "deprecatedBanner": "此能力已被来源工作区标记废弃。本 Agent 可以继续使用锁定的 v{{version}},但无法升级到新版本。", - "upgradeAvailable": "来源工作区发布了新版本 v{{version}}。", + "upgradeAvailable": "有新版本可用:v{{version}}。", "upgradeBlocked": "源能力已废弃,无法升级。" } }, @@ -1687,7 +1640,7 @@ "versionPicker": { "latest": "latest", "localHint": "选 latest 时,后续重新上传会自动生效;选具体版本会锁定到该版本", - "marketplaceHint": "市场能力:latest 会自动跟随发布者的最新版本(可能引入不兼容改动)" + "marketplaceHint": "市场能力:latest 会自动跟随最新可用版本(可能引入不兼容改动)" }, "wizard": { "stepOf": "第 {{current}} / {{total}} 步", diff --git a/apps/web/src/i18n/locales/zh-CN/common.json b/apps/web/src/i18n/locales/zh-CN/common.json index 8437406..66b1b42 100644 --- a/apps/web/src/i18n/locales/zh-CN/common.json +++ b/apps/web/src/i18n/locales/zh-CN/common.json @@ -92,7 +92,7 @@ "renameDescription": "修改工作区显示名称。系统标识不可修改。", "archiveTitle": "归档工作区", "archiveDescription": "确认归档「{{name}}」?归档后将从切换器中消失,原有成员和 Agent 保留在数据库中,但通过工作区维度的查询都不会再返回。", - "archiveMarketplaceDependents": "如果这个工作区发布的市场能力仍被其它工作区的 Agent 使用,归档会被阻止。请先取消发布并等待对方移除后再归档。", + "archiveMarketplaceDependents": "如果这个工作区的能力仍被其它工作区的 Agent 使用,归档会被阻止,直到那些绑定被移除。", "namePlaceholder": "例如:平台团队" }, "join": { diff --git a/apps/web/src/lib/api-marketplace.ts b/apps/web/src/lib/api-marketplace.ts index db665b9..6a95ea7 100644 --- a/apps/web/src/lib/api-marketplace.ts +++ b/apps/web/src/lib/api-marketplace.ts @@ -136,12 +136,6 @@ interface TargetInstallsResponse { items?: TargetMarketplaceInstall[] } -interface InstallCountResponse { - count?: number - install_count?: number - workspace_count?: number -} - interface EnabledAgentsResponse { agents?: EnabledMarketplaceAgent[] items?: EnabledMarketplaceAgent[] @@ -151,7 +145,6 @@ export const KEY_MARKETPLACE_LIST = (workspaceID: string) => ["admin", "capabili export const KEY_MARKETPLACE_DETAIL = (workspaceID: string, capabilityID: string) => ["admin", "capabilityMarketplaceDetail", workspaceID, capabilityID] as const export const KEY_TARGET_MARKETPLACE_INSTALLS = (workspaceID: string) => ["admin", "targetMarketplaceInstalls", workspaceID] as const -export const KEY_INSTALL_COUNT = (workspaceID: string, capabilityID: string) => ["admin", "capabilityInstallCount", workspaceID, capabilityID] as const export const KEY_MARKETPLACE_ENABLED_AGENTS = (workspaceID: string, capabilityID: string) => ["admin", "marketplaceEnabledAgents", workspaceID, capabilityID] as const export const KEY_MCP_DIRECTORY = (workspaceID: string) => ["admin", "mcpDirectory", workspaceID] as const export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) => ["admin", "mcpDirectoryDetail", workspaceID, catalogID] as const @@ -159,7 +152,8 @@ export const KEY_MCP_DIRECTORY_DETAIL = (workspaceID: string, catalogID: string) async function listMarketplace(workspaceID: string | null): Promise { if (!workspaceID) return [] const data = await apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities`, + `/api/v1/capabilities/marketplace`, + { query: { workspace_id: workspaceID } }, ) if (Array.isArray(data)) return data return (data.capabilities ?? data.marketplace ?? data.items ?? []).map(normalizeMarketplaceCapability) @@ -171,7 +165,8 @@ async function getMarketplaceDetail( ): Promise { if (!workspaceID || !capabilityID) throw new Error("workspace and capability are required") const data = await apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/marketplace/capabilities/${encodeURIComponent(capabilityID)}`, + `/api/v1/capabilities/marketplace/${encodeURIComponent(capabilityID)}`, + { query: { workspace_id: workspaceID } }, ) return data.capability } @@ -185,14 +180,6 @@ async function listTargetInstalls(workspaceID: string | null): Promise { - if (!workspaceID || !capabilityID) return 0 - const data = await apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/${encodeURIComponent(capabilityID)}/install-count`, - ) - return data.install_count ?? data.workspace_count ?? data.count ?? 0 -} - async function listEnabledAgents(workspaceID: string | null, capabilityID: string | null): Promise { if (!workspaceID || !capabilityID) return [] const data = await apiRequest( @@ -233,13 +220,6 @@ function normalizeEnabledAgent(item: EnabledMarketplaceAgent): EnabledMarketplac return { ...item, name: item.name ?? item.agent_name ?? "—" } } -async function postWorkspaceCapability(workspaceID: string, capabilityID: string, action: "publish" | "unpublish" | "deprecate" | "undeprecate") { - return apiRequest( - `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/${encodeURIComponent(capabilityID)}/${action}`, - { method: "POST" }, - ) -} - async function uninstallMarketplace(workspaceID: string, capabilityID: string) { return apiRequest<{ uninstalled_agent_count?: number }>( `/api/v1/workspaces/${encodeURIComponent(workspaceID)}/capabilities/uninstall`, @@ -289,16 +269,6 @@ export function useTargetMarketplaceInstalls(workspaceID: string | null) { }) } -export function useInstallCount(workspaceID: string | null, capabilityID: string | null) { - return useQuery({ - queryKey: KEY_INSTALL_COUNT(workspaceID ?? "_none", capabilityID ?? "_none"), - queryFn: () => getInstallCount(workspaceID, capabilityID), - enabled: !!workspaceID && !!capabilityID, - retry: noUnreachableRetry, - staleTime: 30_000, - }) -} - export function useMarketplaceEnabledAgents(workspaceID: string | null, capabilityID: string | null) { return useQuery({ queryKey: KEY_MARKETPLACE_ENABLED_AGENTS(workspaceID ?? "_none", capabilityID ?? "_none"), @@ -359,40 +329,11 @@ function invalidateMarketplace(qc: ReturnType, workspaceI void qc.invalidateQueries({ queryKey: KEY_CAPABILITIES_WORKSPACE(workspaceID ?? "_none") }) void qc.invalidateQueries({ queryKey: ["admin", "capability"] }) if (workspaceID && capabilityID) { - void qc.invalidateQueries({ queryKey: KEY_INSTALL_COUNT(workspaceID, capabilityID) }) void qc.invalidateQueries({ queryKey: KEY_MARKETPLACE_ENABLED_AGENTS(workspaceID, capabilityID) }) void qc.invalidateQueries({ queryKey: KEY_CAPABILITY_VERSIONS(workspaceID, capabilityID) }) } } -function useWorkspaceAction(workspaceID: string | null, action: "publish" | "unpublish" | "deprecate" | "undeprecate") { - const qc = useQueryClient() - return useMutation({ - mutationFn: (capabilityID: string) => { - if (!workspaceID) throw new Error("workspace is required") - return postWorkspaceCapability(workspaceID, capabilityID, action) - }, - retry: noUnreachableRetry, - onSuccess: (capability) => invalidateMarketplace(qc, workspaceID, capability.id), - }) -} - -export function usePublish(workspaceID: string | null) { - return useWorkspaceAction(workspaceID, "publish") -} - -export function useUnpublish(workspaceID: string | null) { - return useWorkspaceAction(workspaceID, "unpublish") -} - -export function useDeprecate(workspaceID: string | null) { - return useWorkspaceAction(workspaceID, "deprecate") -} - -export function useUndeprecate(workspaceID: string | null) { - return useWorkspaceAction(workspaceID, "undeprecate") -} - // useDelete isn't a marketplace action, but shares the capabilities route so // it lives here. A 200 response means deleted_at was written; failures come in // two flavours: 409 (still bound to agents, with binding_count) / 500 (other). diff --git a/apps/web/src/lib/api-types.ts b/apps/web/src/lib/api-types.ts index 805201a..93221c9 100644 --- a/apps/web/src/lib/api-types.ts +++ b/apps/web/src/lib/api-types.ts @@ -360,7 +360,7 @@ export interface ListCapabilitiesResponse { marketplace_installs?: Capability[] // Public marketplace capabilities this workspace has NOT installed yet // — surfaced in the Agent picker's "capability marketplace" section. Filtered - // server-side to exclude installed and self-published rows. + // server-side to exclude rows already available to the workspace. marketplace_available?: MarketplaceCapability[] page?: number page_size?: number diff --git a/apps/web/src/pages/admin/CreateAgentDialog.tsx b/apps/web/src/pages/admin/CreateAgentDialog.tsx index ec5f3c1..9a7a75a 100644 --- a/apps/web/src/pages/admin/CreateAgentDialog.tsx +++ b/apps/web/src/pages/admin/CreateAgentDialog.tsx @@ -1735,7 +1735,7 @@ function DependencyCard({ title, description, href, cta }: { title: string; desc * binding to pinned mode at that version_id. * * Marketplace capabilities still get a working dropdown but the default - * stays "pinned" on a known good version: marketplace publishes may + * stays "pinned" on a known good version: newer releases may * carry breaking changes, so the existing UpgradeCapabilityDialog flow * (which prompts on every new major) stays the recommended path. Users * who explicitly opt in to "latest" via this dropdown are accepting the diff --git a/apps/web/src/pages/admin/capabilities/DeleteCapabilityDialog.tsx b/apps/web/src/pages/admin/capabilities/DeleteCapabilityDialog.tsx index 6fd7cd6..9ffc176 100644 --- a/apps/web/src/pages/admin/capabilities/DeleteCapabilityDialog.tsx +++ b/apps/web/src/pages/admin/capabilities/DeleteCapabilityDialog.tsx @@ -25,8 +25,8 @@ interface DeleteCapabilityDialogProps { // If any agent is still bound the server returns 409 with envelope // { code: "capability_in_use", message: "", binding_count: N }. // envelope.message is already a display-ready string, so we render it as-is. -// Same shape as DeprecateCapabilityDialog, but a separate dialog because the -// semantics differ: delete is one-shot, with no toggle / install count. +// Delete is one-shot: it frees the capability name only after the server +// confirms no agent is still bound. export function DeleteCapabilityDialog({ capability, pending, error, onOpenChange, onConfirm }: DeleteCapabilityDialogProps) { const { t } = useTranslation("admin") const errMsg = error instanceof ApiError ? error.envelope.message : error instanceof Error ? error.message : null diff --git a/apps/web/src/pages/admin/capabilities/DeprecateCapabilityDialog.tsx b/apps/web/src/pages/admin/capabilities/DeprecateCapabilityDialog.tsx deleted file mode 100644 index e6fe750..0000000 --- a/apps/web/src/pages/admin/capabilities/DeprecateCapabilityDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useTranslation } from "react-i18next" -import { Loader2 } from "lucide-react" - -import { - AlertDialog, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "../../../components/ui/alert-dialog" -import { Button } from "../../../components/ui/button" -import { ApiError } from "../../../lib/api-client" -import type { Capability } from "../../../lib/api-types" - -interface DeprecateCapabilityDialogProps { - action: "deprecate" | "undeprecate" | "publish" | "unpublish" | null - capability: Capability - installCount: number - pending: boolean - error: unknown - onOpenChange: (open: boolean) => void - onConfirm: () => void -} - -export function DeprecateCapabilityDialog({ action, capability, installCount, pending, error, onOpenChange, onConfirm }: DeprecateCapabilityDialogProps) { - const { t } = useTranslation("admin") - const errMsg = error instanceof ApiError ? error.envelope.message : error instanceof Error ? error.message : null - if (!action) return null - const destructive = action === "deprecate" || action === "unpublish" - return ( - - - - {t(`capabilities.marketStatus.dialog.${action}.title`, { name: capability.name })} - {t(`capabilities.marketStatus.dialog.${action}.description`, { count: installCount })} - - {errMsg &&

{errMsg}

} - - - - -
-
- ) -} diff --git a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx index 9fdfc5e..ae958e7 100644 --- a/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx +++ b/apps/web/src/pages/admin/capabilities/MarketplaceTab.tsx @@ -47,7 +47,7 @@ export function MarketplaceTab(props: MarketplaceTabProps) { } if (props.itemID) { - return + return } return } -function PublishedMarketplaceTab({ itemID, query, typeFilter, canManage, onSelectItem, onInstall, onDelete, onViewCapability }: MarketplaceTabProps) { +function WorkspaceCapabilityLibrary({ itemID, query, typeFilter, canManage, onSelectItem, onInstall, onDelete, onViewCapability }: MarketplaceTabProps) { const { t, i18n } = useTranslation("admin") const workspaceID = useWorkspaceId() const marketplaceQ = useMarketplaceList(workspaceID) @@ -75,9 +75,7 @@ function PublishedMarketplaceTab({ itemID, query, typeFilter, canManage, onSelec const needle = query.trim().toLowerCase() return items.filter((item) => { if (item.type !== typeFilter) return false - // "Hide what's already in this workspace" — both rows you published - // and rows you installed from elsewhere are available locally. - if (hideInstalled && (item.installed || item.self_published)) return false + if (hideInstalled && item.installed) return false if (!needle) return true return `${item.name} ${item.description ?? ""}`.toLowerCase().includes(needle) }) @@ -167,7 +165,6 @@ function MarketplaceCard({ capability, language, canManage, onOpen, onInstall, o

{capability.name}

- {capability.self_published && {t("capabilities.marketplace.card.selfPublished")}} {!capability.self_published && capability.installed && {t("capabilities.marketplace.card.installedBadge")}}
{source &&

{t("capabilities.marketplace.card.source", { source })}

} @@ -178,7 +175,7 @@ function MarketplaceCard({ capability, language, canManage, onOpen, onInstall, o
{t("capabilities.marketplace.card.latest", { version: capability.latest_version ?? "—" })} · - {t("capabilities.marketplace.card.added", { count })} + {t("capabilities.marketplace.card.usedByAgents", { count })} · {t("capabilities.marketplace.card.credential", { kind: requiredCredentialsLabel(capability.required_credentials, language, t("capabilities.credentials.none")) })}
@@ -231,7 +228,7 @@ function MarketplaceItemDetail({ capability, language, canManage, onBack, onInst
- +
{previewable && (
diff --git a/apps/web/src/pages/admin/capabilities/index.tsx b/apps/web/src/pages/admin/capabilities/index.tsx index 07884cc..9aeeb45 100644 --- a/apps/web/src/pages/admin/capabilities/index.tsx +++ b/apps/web/src/pages/admin/capabilities/index.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react" import { useQueries } from "@tanstack/react-query" import { useTranslation } from "react-i18next" -import { ArrowLeft, ArrowUpRight, Eye, Loader2, MoreHorizontal, PackageCheck, Pencil, Plus, Search, Share2, Trash2, Wrench } from "lucide-react" +import { ArrowLeft, ArrowUpRight, Eye, Loader2, MoreHorizontal, PackageCheck, Pencil, Plus, Search, Trash2, Wrench } from "lucide-react" import * as Tooltip from "@radix-ui/react-tooltip" import * as DropdownMenu from "@radix-ui/react-dropdown-menu" @@ -49,14 +49,9 @@ import { } from "../../../lib/api-capabilities" import { useDelete, - useDeprecate, - useInstallCount, useMarketplaceEnabledAgents, - usePublish, useTargetMarketplaceInstalls, - useUndeprecate, useUninstall, - useUnpublish, type MarketplaceCapability, type TargetMarketplaceInstall, marketplaceSourceName, @@ -68,15 +63,11 @@ import { useWorkspaceId } from "../../../lib/workspace" import { requiredCredentialsLabel } from "../../../lib/credential-kind-ui" import { MarketplaceCapabilityDetail } from "./MarketplaceCapabilityDetail" import { MarketplaceTab } from "./MarketplaceTab" -import { DeprecateCapabilityDialog } from "./DeprecateCapabilityDialog" import { DeleteCapabilityDialog } from "./DeleteCapabilityDialog" import { ImportCapabilityDialog } from "./ImportCapabilityDialog" import { AddCapabilityVersionDialog } from "./AddCapabilityVersionDialog" import { UninstallMarketplaceDialog } from "./UninstallMarketplaceDialog" -type MarketAction = "publish" | "unpublish" | "deprecate" | "undeprecate" | null -type MarketCapabilityAction = Exclude - interface AgentInstallation { agentID: string agentName: string @@ -105,23 +96,16 @@ export function CapabilitiesPage() { const agentsQ = useAgents(wid) const workspacesQ = useMyWorkspaces() const marketplaceInstallsQ = useTargetMarketplaceInstalls(wid) - const publishMut = usePublish(wid) - const unpublishMut = useUnpublish(wid) - const deprecateMut = useDeprecate(wid) - const undeprecateMut = useUndeprecate(wid) const uninstallMut = useUninstall(wid) const deleteMut = useDelete(wid) const [importOpen, setImportOpen] = useState(false) const [addVersionCapability, setAddVersionCapability] = useState(null) - const [marketTarget, setMarketTarget] = useState<{ action: MarketCapabilityAction; capability: Capability } | null>(null) - const [marketClientError, setMarketClientError] = useState(null) const [uninstallTarget, setUninstallTarget] = useState(null) const [deleteTarget, setDeleteTarget] = useState(null) const [toast, setToast] = useState(null) const workspaceRole = workspacesQ.data?.workspaces.find((w) => w.id === wid)?.role const isAdmin = workspaceRole === "owner" || workspaceRole === "admin" const canImportDirectory = isAdmin || workspaceRole === "member" - const marketInstallCountQ = useInstallCount(wid, marketTarget?.capability.id ?? null) const uninstallAgentsQ = useMarketplaceEnabledAgents(wid, uninstallTarget?.id ?? null) const agents = useMemo(() => agentsQ.data?.agents ?? [], [agentsQ.data]) @@ -188,43 +172,9 @@ export function CapabilitiesPage() { const err = capsQ.error const isUnreachable = err instanceof ApiError && err.envelope.unreachable - const marketPendingID = marketTarget && (publishMut.isPending || unpublishMut.isPending || deprecateMut.isPending || undeprecateMut.isPending) - ? marketTarget.capability.id - : null const uninstallPendingID = uninstallTarget && uninstallMut.isPending ? uninstallTarget.id : null const deletePendingID = deleteTarget && deleteMut.isPending ? deleteTarget.id : null - - const requestMarketAction = (action: MarketCapabilityAction, capability: Capability) => { - setMarketClientError(null) - if (action === "publish" && capability.type === "mcp") { - const leakingVersion = (versionSummary.byCapability.get(capability.id) ?? []).find((version) => containsPlaintextSecretPattern(JSON.stringify(version.content ?? {}))) - if (leakingVersion) { - setMarketClientError(t("capabilities.errors.plaintextSecretPattern", { version: leakingVersion.version })) - return - } - } - setMarketTarget({ action, capability }) - } - - const submitMarketAction = () => { - if (!marketTarget) return - const { action, capability } = marketTarget - const mutation = action === "publish" - ? publishMut - : action === "unpublish" - ? unpublishMut - : action === "deprecate" - ? deprecateMut - : undeprecateMut - mutation.mutate(capability.id, { - onSuccess: () => { - setToast(t(`capabilities.marketStatus.toast.${action}`, { name: capability.name })) - setMarketTarget(null) - }, - }) - } - return ( {toast && } - {marketClientError && } {!marketplaceItem && ( navigate("capabilities", { id: cap.id, from: fromMarketplace ? "marketplace" : null })} onAddVersion={() => setAddVersionCapability(cap)} - onMarketAction={(action) => requestMarketAction(action, cap)} onUninstall={() => setUninstallTarget(marketCap)} onDelete={() => setDeleteTarget(cap)} /> @@ -450,24 +397,6 @@ export function CapabilitiesPage() { }} /> )} - {marketTarget && ( - { - if (open) return - setMarketTarget(null) - publishMut.reset() - unpublishMut.reset() - deprecateMut.reset() - undeprecateMut.reset() - }} - onConfirm={submitMarketAction} - /> - )} void onAddVersion: () => void - onMarketAction: (action: MarketCapabilityAction) => void onUninstall: () => void onDelete: () => void }) { const { t } = useTranslation("admin") - const published = capability.visibility === "public" || capability.scope === "public" const disabledByRole = !isAdmin // Marketplace rows only support uninstall. @@ -679,7 +603,6 @@ function CapabilityRowActions({ // Edit-as-new-version: clicking the primary action opens AddCapabilityVersionDialog, // which now carries name/description fields too. The old standalone Pencil // (PATCH-only metadata edit) was removed in favor of this single surface. - const someMenuPending = marketPending || deletePending return ( @@ -691,11 +614,9 @@ function CapabilityRowActions({ onClick={onAddVersion} /> @@ -703,22 +624,17 @@ function CapabilityRowActions({ } /** - * "More actions" menu for cross-workspace marketplace actions (publish, - * deprecate). View-detail also lives here as a fallback entry point. + * "More actions" menu for capability detail and delete. */ function CapabilityRowMoreMenu({ - published, disabledByRole, menuPending, onView, - onMarketAction, onDelete, }: { - published: boolean disabledByRole: boolean menuPending: boolean onView: () => void - onMarketAction: (action: MarketCapabilityAction) => void onDelete: () => void }) { const { t } = useTranslation("admin") @@ -745,21 +661,6 @@ function CapabilityRowMoreMenu({ <> - onMarketAction(published ? "unpublish" : "publish")} - /> - {/* - "Delete" releases the capability.name workspace-unique index, - allowing a same-name capability to be re-imported. The server - rejects deletes that still have bound agents (409). - "Deprecate" is a separate concept — the author signals that - maintenance has stopped and existing installs freeze on the - current version. That entry point lives on the detail page's - marketplace panel. - */} (null) - const [marketClientError, setMarketClientError] = useState(null) const [viewVersion, setViewVersion] = useState(null) const [toast, setToast] = useState(null) const workspaceRole = workspacesQ.data?.workspaces.find((w) => w.id === wid)?.role @@ -856,39 +750,6 @@ export function CapabilityDetailPage({ id }: { id: string }) { ) } - const submitMarketAction = () => { - const mutation = marketAction === "publish" - ? publishMut - : marketAction === "unpublish" - ? unpublishMut - : marketAction === "deprecate" - ? deprecateMut - : marketAction === "undeprecate" - ? undeprecateMut - : null - if (!mutation) return - const action = marketAction - if (!action) return - mutation.mutate(capability.id, { - onSuccess: () => { - setToast(t(`capabilities.marketStatus.toast.${action}`, { name: capability.name })) - setMarketAction(null) - }, - }) - } - - const requestMarketAction = (action: MarketAction) => { - setMarketClientError(null) - if (action === "publish" && capability.type === "mcp") { - const leakingVersion = (versionsQ.data?.versions ?? []).find((version) => containsPlaintextSecretPattern(JSON.stringify(version.content ?? {}))) - if (leakingVersion) { - setMarketClientError(t("capabilities.errors.plaintextSecretPattern", { version: leakingVersion.version })) - return - } - } - setMarketAction(action) - } - return ( {toast && } - {marketClientError && }
@@ -977,57 +837,6 @@ export function CapabilityDetailPage({ id }: { id: string }) {
)} - - {isAdmin && ( - -
-
-
- - {capability.visibility === "public" || capability.scope === "public" ? t("capabilities.marketStatus.published") : t("capabilities.marketStatus.unpublished")} - - {capability.deprecated_at && {t("capabilities.deprecated.badgeSource")}} -
-

{t("capabilities.marketStatus.installCount", { count: installCountQ.data ?? 0 })}

-
-
- {/* - The deprecate / undeprecate toggle now applies to ALL - capabilities, not just published-marketplace ones — - it's the single "stop offering this" admin signal - since the standalone disable button was removed. - Existing agent bindings keep working either way; - the tooltip below makes that contract explicit so - admins don't fear they're about to break running - agents. Marketplace publish / unpublish remain a - separate concern. - */} - - - - - - - - - - {capability.deprecated_at ? t("capabilities.marketStatus.undeprecateTooltip") : t("capabilities.marketStatus.deprecateTooltip")} - - - - - - {(capability.visibility === "public" || capability.scope === "public") ? ( - - ) : ( - - )} -
-
-
- )}
- !open && setMarketAction(null)} - onConfirm={submitMarketAction} - /> !open && setViewVersion(null)} /> ) @@ -1349,19 +1149,6 @@ function countCapabilityInstalls(groups: AgentCapability[][]) { return counts } -const plaintextSecretPatternRes = [ - /github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,}/i, - /xoxb-[A-Za-z0-9-]{20,}/, - /AKIA[A-Z0-9]{16}/, - /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, - /postgres(?:ql)?:\/\/[^\s:@/]+:[^\s:@/]+@/i, - /(api[_-]?key|access[_-]?token|secret)["'\s:=]+[A-Za-z0-9_./+=-]{32,}/i, -] - -function containsPlaintextSecretPattern(value: string) { - return plaintextSecretPatternRes.some((pattern) => pattern.test(value)) -} - function FormField({ label, help, required, children }: { label: string; help?: string; required?: boolean; children: React.ReactNode }) { return } diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx index 49179a6..248640d 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectory.tsx @@ -67,7 +67,7 @@ export function MCPDirectory({ () => filterMCPDirectoryItems(items, { query, category, verifiedOnly, sort }), [items, query, category, verifiedOnly, sort], ) - const publishedMCPs = useMemo(() => { + const workspaceMCPs = useMemo(() => { if (category || verifiedOnly) return [] const installedIDs = new Set(items.flatMap((item) => item.installed_capability_id ? [item.installed_capability_id] : [])) const needle = query.trim().toLocaleLowerCase() @@ -84,10 +84,10 @@ export function MCPDirectory({ | { kind: "marketplace"; item: MarketplaceCapability } > = [ ...filtered.map((item) => ({ kind: "directory" as const, item })), - ...publishedMCPs.map((item) => ({ kind: "marketplace" as const, item })), + ...workspaceMCPs.map((item) => ({ kind: "marketplace" as const, item })), ] return sort === "name" ? merged.sort((left, right) => left.item.name.localeCompare(right.item.name)) : merged - }, [filtered, publishedMCPs, sort]) + }, [filtered, workspaceMCPs, sort]) const selectedSummary = items.find((item) => item.id === itemID) ?? null const selected = detailQ.data?.id === itemID ? detailQ.data : selectedSummary const confirmItem = detailQ.data?.id === confirmID ? detailQ.data : items.find((item) => item.id === confirmID) ?? null diff --git a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx index 70bdba3..0594df8 100644 --- a/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx +++ b/apps/web/src/pages/admin/capabilities/mcp-directory/MCPDirectoryCard.tsx @@ -83,7 +83,6 @@ export function MarketplaceMCPCard({ capability, canManage, onOpen, onInstall, o {capability.description ?

{capability.description}

: null}
MCP - {capability.self_published ? {t("capabilities.marketplace.card.selfPublished")} : null} {!capability.self_published && capability.installed ? {t("capabilities.marketplace.card.installedBadge")} : null} v{capability.latest_version ?? "—"}
diff --git a/docs/openapi/openapi.yaml b/docs/openapi/openapi.yaml index 5e4df97..992a02b 100644 --- a/docs/openapi/openapi.yaml +++ b/docs/openapi/openapi.yaml @@ -3102,6 +3102,89 @@ paths: summary: Bootstrap status tags: - bootstrap + /api/v1/capabilities/marketplace: + get: + description: Lists MCP and Skill capabilities owned by the caller's workspace. + Workspace is taken from ?workspace_id or the X-Parsar-Workspace-ID header. + operationId: listDevMarketplaceCapabilities + parameters: + - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) + in: query + name: workspace_id + type: string + produces: + - application/json + responses: + "200": + description: Workspace capabilities visible in the library + schema: + additionalProperties: true + type: object + "400": + description: workspace_id must be a valid uuid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "503": + description: Database-backed capability APIs are disabled + schema: + additionalProperties: + type: string + type: object + summary: List marketplace capabilities + tags: + - capabilities + /api/v1/capabilities/marketplace/{capabilityID}: + get: + description: Returns the latest workspace MCP or Skill definition. Inline secret + IDs are redacted. + operationId: getDevMarketplaceCapabilityDetail + parameters: + - description: Capability UUID + in: path + name: capabilityID + required: true + type: string + - description: Workspace UUID (falls back to X-Parsar-Workspace-ID header) + in: query + name: workspace_id + type: string + produces: + - application/json + responses: + "200": + description: Marketplace capability detail + schema: + additionalProperties: true + type: object + "400": + description: workspace_id or capabilityID is invalid + schema: + additionalProperties: + type: string + type: object + "403": + description: Caller is not an active workspace member + schema: + additionalProperties: + type: string + type: object + "404": + description: Capability is not available in this workspace + schema: + additionalProperties: + type: string + type: object + summary: Get marketplace capability detail + tags: + - capabilities /api/v1/connections/github/callback: get: description: Verifies CSRF state, exchanges the code for a GitHub access token, @@ -4860,9 +4943,8 @@ paths: post: consumes: - application/json - description: Enables (installs) a capability version on the agent. Cross-workspace - enable requires the source capability to be public and non-deprecated. Workspace - owner, admin, or member only. + description: Enables (installs) a workspace capability version on the agent. + Workspace owner, admin, or member only. operationId: enableDevAgentCapability parameters: - description: Workspace UUID @@ -5761,8 +5843,8 @@ paths: - capabilities /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/deprecate: post: - description: Marks a published capability as deprecated so it stays reachable - for existing installs but is hidden from new installs. Owner/admin only. + description: Marks a workspace capability as deprecated so existing Agent bindings + keep working but new Agent picks hide it. Owner/admin only. operationId: deprecateDevWorkspaceCapability parameters: - description: Workspace UUID @@ -5807,60 +5889,14 @@ paths: additionalProperties: type: string type: object - summary: Deprecate a marketplace capability + summary: Deprecate a workspace capability tags: - capabilities /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/enabled-agents: get: - description: Lists agents in the workspace that have this marketplace capability - enabled. + description: Lists agents in the workspace that have this capability enabled. operationId: listDevMarketplaceEnabledAgents parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - - description: Source marketplace capability UUID - in: path - name: capabilityID - required: true - type: string - produces: - - application/json - responses: - "200": - description: capability_id and enabled agents - schema: - additionalProperties: true - type: object - "400": - description: workspace_id or capability_id is not a valid uuid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: List agents using a marketplace capability - tags: - - capabilities - /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/install-count: - get: - description: Returns the marketplace install count for a capability the workspace - has published. - operationId: getDevCapabilityInstallCount - parameters: - description: Workspace UUID in: path name: workspaceID @@ -5875,7 +5911,7 @@ paths: - application/json responses: "200": - description: capability_id and install_count + description: capability_id and enabled agents schema: additionalProperties: true type: object @@ -5891,75 +5927,18 @@ paths: additionalProperties: type: string type: object - "404": - description: Capability not found in this workspace - schema: - additionalProperties: - type: string - type: object "503": description: Database-backed capability APIs are disabled schema: additionalProperties: type: string type: object - summary: Get marketplace install count - tags: - - capabilities - /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/publish: - post: - description: Promotes an active capability to the marketplace. Owner/admin only. - Rejects with 400 if any capability version contains a plaintext-looking secret. - operationId: publishDevWorkspaceCapability - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - - description: Capability UUID - in: path - name: capabilityID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Updated capability - schema: - additionalProperties: true - type: object - "400": - description: Plaintext secret detected or invalid UUID - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not workspace owner/admin - schema: - additionalProperties: - type: string - type: object - "404": - description: Capability not found in this workspace - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: Publish a capability to the marketplace + summary: List agents using a capability tags: - capabilities /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/undeprecate: post: - description: Clears the deprecated flag on a previously deprecated marketplace + description: Clears the deprecated flag on a previously deprecated workspace capability. Owner/admin only. operationId: undeprecateDevWorkspaceCapability parameters: @@ -6005,58 +5984,7 @@ paths: additionalProperties: type: string type: object - summary: Undeprecate a marketplace capability - tags: - - capabilities - /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/unpublish: - post: - description: Retracts a previously published capability from the marketplace. - Owner/admin only. - operationId: unpublishDevWorkspaceCapability - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - - description: Capability UUID - in: path - name: capabilityID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Updated capability - schema: - additionalProperties: true - type: object - "400": - description: Invalid UUID - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not workspace owner/admin - schema: - additionalProperties: - type: string - type: object - "404": - description: Capability not found in this workspace - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: Unpublish a capability from the marketplace + summary: Undeprecate a workspace capability tags: - capabilities /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/versions: @@ -6925,91 +6853,6 @@ paths: summary: Resolve a pending approval or user question tags: - interactions - /api/v1/workspaces/{workspaceID}/marketplace/capabilities: - get: - description: Lists active MCP and Skill capabilities in the requested workspace - marketplace. - operationId: listDevMarketplaceCapabilities - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capabilities visible to the workspace - schema: - additionalProperties: true - type: object - "400": - description: workspace_id must be a valid uuid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "503": - description: Database-backed capability APIs are disabled - schema: - additionalProperties: - type: string - type: object - summary: List marketplace capabilities - tags: - - capabilities - /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}: - get: - description: Returns the latest workspace-scoped MCP or Skill definition. Inline - secret IDs are redacted. - operationId: getDevMarketplaceCapabilityDetail - parameters: - - description: Workspace UUID - in: path - name: workspaceID - required: true - type: string - - description: Capability UUID - in: path - name: capabilityID - required: true - type: string - produces: - - application/json - responses: - "200": - description: Marketplace capability detail - schema: - additionalProperties: true - type: object - "400": - description: workspace_id or capabilityID is invalid - schema: - additionalProperties: - type: string - type: object - "403": - description: Caller is not an active workspace member - schema: - additionalProperties: - type: string - type: object - "404": - description: Capability is not available in the Marketplace - schema: - additionalProperties: - type: string - type: object - summary: Get marketplace capability detail - tags: - - capabilities /api/v1/workspaces/{workspaceID}/mcp-directory: get: parameters: diff --git a/server/internal/db/queries/store.sql b/server/internal/db/queries/store.sql index af6d6b2..df15f58 100644 --- a/server/internal/db/queries/store.sql +++ b/server/internal/db/queries/store.sql @@ -3642,10 +3642,9 @@ returning id::text as id, workspace_id::text as workspace_id, type, name, description, visibility, status, creator_id::text as creator_id, created_at, updated_at, deleted_at, deprecated_at; --- name: UpdateCapabilityMarketplaceState :one +-- name: UpdateCapabilityDeprecation :one update capability -set visibility = @visibility, - deprecated_at = sqlc.narg('deprecated_at')::timestamptz, +set deprecated_at = sqlc.narg('deprecated_at')::timestamptz, updated_at = @now where id = @id::uuid and workspace_id = @workspace_id::uuid @@ -3731,18 +3730,10 @@ join lateral ( ) latest on true where a.workspace_id = @target_workspace_id::uuid and c.workspace_id != @target_workspace_id::uuid - and c.visibility = 'public' + and false and c.deleted_at is null order by c.name asc, cv.version asc; --- name: CountInstalls :one -select count(distinct a.workspace_id)::bigint -from agent_capabilities ac -join agents a on ac.agent_id = a.id -join capability c on c.id = ac.capability_id -where ac.capability_id = @source_capability_id::uuid - and a.workspace_id != c.workspace_id; - -- Counts every agent_capabilities reference -- including in-workspace -- agent bindings and cross-workspace marketplace installs. Used as a -- delete gate: a capability bound by any agent cannot be deleted, @@ -3951,7 +3942,6 @@ where ac.agent_id = @agent_id::uuid and cv.id = @new_version_id::uuid and cv.capability_id = ac.capability_id and c.id = ac.capability_id - and c.visibility = 'public' and c.status = 'active' and c.deleted_at is null and c.deprecated_at is null @@ -4018,8 +4008,8 @@ join lateral ( from capability_version where capability_id = c.id -- After a capability is deprecated, latest bindings should freeze - -- on the newest version published before deprecation rather than - -- keep auto-tracking versions published afterwards (those are no + -- on the newest version created before deprecation rather than + -- keep auto-tracking versions created afterwards (those are no -- longer supported, matching UpgradeAgentCapability's explicit -- rejection of deprecated upgrades). When c.deprecated_at IS NULL -- this predicate is always true and behaves like the previous diff --git a/server/internal/db/sqlc/store.sql.go b/server/internal/db/sqlc/store.sql.go index f912c2e..af978e8 100644 --- a/server/internal/db/sqlc/store.sql.go +++ b/server/internal/db/sqlc/store.sql.go @@ -1539,22 +1539,6 @@ func (q *Queries) CountInFlightRunsByAgent(ctx context.Context, agentID pgtype.U return column_1, err } -const countInstalls = `-- name: CountInstalls :one -select count(distinct a.workspace_id)::bigint -from agent_capabilities ac -join agents a on ac.agent_id = a.id -join capability c on c.id = ac.capability_id -where ac.capability_id = $1::uuid - and a.workspace_id != c.workspace_id -` - -func (q *Queries) CountInstalls(ctx context.Context, sourceCapabilityID pgtype.UUID) (int64, error) { - row := q.db.QueryRow(ctx, countInstalls, sourceCapabilityID) - var column_1 int64 - err := row.Scan(&column_1) - return column_1, err -} - const countPendingJoinRequests = `-- name: CountPendingJoinRequests :one select count(*)::bigint as pending_count from workspace_members @@ -5262,8 +5246,8 @@ join lateral ( from capability_version where capability_id = c.id -- After a capability is deprecated, latest bindings should freeze - -- on the newest version published before deprecation rather than - -- keep auto-tracking versions published afterwards (those are no + -- on the newest version created before deprecation rather than + -- keep auto-tracking versions created afterwards (those are no -- longer supported, matching UpgradeAgentCapability's explicit -- rejection of deprecated upgrades). When c.deprecated_at IS NULL -- this predicate is always true and behaves like the previous @@ -10055,7 +10039,7 @@ join lateral ( ) latest on true where a.workspace_id = $1::uuid and c.workspace_id != $1::uuid - and c.visibility = 'public' + and false and c.deleted_at is null order by c.name asc, cv.version asc ` @@ -12139,28 +12123,26 @@ func (q *Queries) UpdateCapability(ctx context.Context, arg UpdateCapabilityPara return i, err } -const updateCapabilityMarketplaceState = `-- name: UpdateCapabilityMarketplaceState :one +const updateCapabilityDeprecation = `-- name: UpdateCapabilityDeprecation :one update capability -set visibility = $1, - deprecated_at = $2::timestamptz, - updated_at = $3 -where id = $4::uuid - and workspace_id = $5::uuid +set deprecated_at = $1::timestamptz, + updated_at = $2 +where id = $3::uuid + and workspace_id = $4::uuid and deleted_at is null returning id::text as id, workspace_id::text as workspace_id, type, name, description, visibility, status, creator_id::text as creator_id, created_at, updated_at, deleted_at, deprecated_at ` -type UpdateCapabilityMarketplaceStateParams struct { - Visibility string `json:"visibility"` +type UpdateCapabilityDeprecationParams struct { DeprecatedAt pgtype.Timestamptz `json:"deprecated_at"` Now pgtype.Timestamptz `json:"now"` ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` } -type UpdateCapabilityMarketplaceStateRow struct { +type UpdateCapabilityDeprecationRow struct { ID string `json:"id"` WorkspaceID string `json:"workspace_id"` Type string `json:"type"` @@ -12175,15 +12157,14 @@ type UpdateCapabilityMarketplaceStateRow struct { DeprecatedAt pgtype.Timestamptz `json:"deprecated_at"` } -func (q *Queries) UpdateCapabilityMarketplaceState(ctx context.Context, arg UpdateCapabilityMarketplaceStateParams) (UpdateCapabilityMarketplaceStateRow, error) { - row := q.db.QueryRow(ctx, updateCapabilityMarketplaceState, - arg.Visibility, +func (q *Queries) UpdateCapabilityDeprecation(ctx context.Context, arg UpdateCapabilityDeprecationParams) (UpdateCapabilityDeprecationRow, error) { + row := q.db.QueryRow(ctx, updateCapabilityDeprecation, arg.DeprecatedAt, arg.Now, arg.ID, arg.WorkspaceID, ) - var i UpdateCapabilityMarketplaceStateRow + var i UpdateCapabilityDeprecationRow err := row.Scan( &i.ID, &i.WorkspaceID, @@ -12755,7 +12736,6 @@ where ac.agent_id = $4::uuid and cv.id = $1::uuid and cv.capability_id = ac.capability_id and c.id = ac.capability_id - and c.visibility = 'public' and c.status = 'active' and c.deleted_at is null and c.deprecated_at is null diff --git a/server/internal/dev/capability_routes.go b/server/internal/dev/capability_routes.go index 4e95b60..eccf314 100644 --- a/server/internal/dev/capability_routes.go +++ b/server/internal/dev/capability_routes.go @@ -7,7 +7,6 @@ import ( "io" "net/http" "os" - "regexp" "sort" "strconv" "strings" @@ -161,18 +160,6 @@ type userCredentialResponse struct { UpdatedAt time.Time `json:"updated_at"` } -var plaintextSecretPatterns = []struct { - name string - pattern *regexp.Regexp -}{ - {name: "github personal access token", pattern: regexp.MustCompile(`(?i)(github_pat_[A-Za-z0-9_]{20,}|ghp_[A-Za-z0-9]{20,})`)}, - {name: "slack bot token", pattern: regexp.MustCompile(`xoxb-[A-Za-z0-9-]{20,}`)}, - {name: "aws access key", pattern: regexp.MustCompile(`AKIA[A-Z0-9]{16}`)}, - {name: "jwt", pattern: regexp.MustCompile(`eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}`)}, - {name: "postgres password url", pattern: regexp.MustCompile(`(?i)postgres(?:ql)?://[^\s:@/]+:[^\s:@/]+@`)}, - {name: "generic api key", pattern: regexp.MustCompile(`(?i)(api[_-]?key|access[_-]?token|secret)["'\s:=]+[A-Za-z0-9_./+=-]{32,}`)}, -} - // listWorkspaceCapabilities returns own capabilities plus marketplace installs // and available marketplace items for the workspace. // @@ -248,8 +235,9 @@ func listWorkspaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { filteredInstalls = append(filteredInstalls, item) } marketplaceInstalls = filteredInstalls - // Picker only wants discoverable rows — drop self-published and - // already-installed so they don't double up with the workspace section. + // Picker only wants extra library rows. In workspace-scoped mode these + // rows are normally the same capability records already returned above, + // so keep them out of the secondary bucket to avoid duplicates. filteredAvailable := marketplaceAvailable[:0] for _, item := range marketplaceAvailable { if !isListedCapabilityType(item.Type) { @@ -349,24 +337,35 @@ func listWorkspaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { } } -// listMarketplaceCapabilities lists active capabilities in the caller's -// workspace marketplace. +// listMarketplaceCapabilities lists the workspace-scoped capability library. // // @Summary List marketplace capabilities -// @Description Lists active MCP and Skill capabilities in the requested workspace marketplace. +// @Description Lists MCP and Skill capabilities owned by the caller's workspace. Workspace is taken from ?workspace_id or the X-Parsar-Workspace-ID header. // @Tags capabilities // @ID listDevMarketplaceCapabilities // @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Success 200 {object} map[string]interface{} "Marketplace capabilities visible to the workspace" +// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" +// @Success 200 {object} map[string]interface{} "Workspace capabilities visible in the library" // @Failure 400 {object} map[string]string "workspace_id must be a valid uuid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" // @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities [get] +// @Router /api/v1/capabilities/marketplace [get] func listMarketplaceCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) - if !ok { + workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if workspaceID == "" { + workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) + } + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) + return + } + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) return } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) @@ -393,25 +392,29 @@ func isListedCapabilityType(capabilityType string) bool { } } -// getMarketplaceCapabilityDetail returns the latest workspace marketplace -// MCP/Skill body on demand so list responses stay lightweight. +// getMarketplaceCapabilityDetail returns the latest workspace MCP/Skill body on +// demand so list responses stay lightweight. // // @Summary Get marketplace capability detail -// @Description Returns the latest workspace-scoped MCP or Skill definition. Inline secret IDs are redacted. +// @Description Returns the latest workspace MCP or Skill definition. Inline secret IDs are redacted. // @Tags capabilities // @ID getDevMarketplaceCapabilityDetail // @Produce json -// @Param workspaceID path string true "Workspace UUID" // @Param capabilityID path string true "Capability UUID" +// @Param workspace_id query string false "Workspace UUID (falls back to X-Parsar-Workspace-ID header)" // @Success 200 {object} map[string]interface{} "Marketplace capability detail" // @Failure 400 {object} map[string]string "workspace_id or capabilityID is invalid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" -// @Failure 404 {object} map[string]string "Capability is not available in the Marketplace" -// @Router /api/v1/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID} [get] +// @Failure 404 {object} map[string]string "Capability is not available in this workspace" +// @Router /api/v1/capabilities/marketplace/{capabilityID} [get] func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) - if !ok { + workspaceID := strings.TrimSpace(r.URL.Query().Get("workspace_id")) + if workspaceID == "" { + workspaceID = strings.TrimSpace(r.Header.Get("X-Parsar-Workspace-ID")) + } + if !isUUID(workspaceID) { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workspace_id must be a valid uuid"}) return } capabilityID := strings.TrimSpace(chi.URLParam(r, "capabilityID")) @@ -419,6 +422,14 @@ func getMarketplaceCapabilityDetail(runtimeStore RuntimeStore) http.HandlerFunc writeJSON(w, http.StatusBadRequest, map[string]string{"error": "capabilityID must be a valid uuid"}) return } + if runtimeStore == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "database-backed capability APIs are disabled"}) + return + } + if err := requireWorkspaceMember(r, runtimeStore, workspaceID); err != nil { + writeRBACError(w, err) + return + } capabilities, err := runtimeStore.ListMarketplaceCapabilities(r.Context(), workspaceID) if err != nil { @@ -556,47 +567,16 @@ func listWorkspaceMarketplaceInstalls(runtimeStore RuntimeStore) http.HandlerFun } } -// getCapabilityInstallCount returns the marketplace install count for a -// capability the workspace authored. -// -// @Summary Get marketplace install count -// @Description Returns the marketplace install count for a capability the workspace has published. -// @Tags capabilities -// @ID getDevCapabilityInstallCount -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param capabilityID path string true "Capability UUID" -// @Success 200 {object} map[string]interface{} "capability_id and install_count" -// @Failure 400 {object} map[string]string "workspace_id or capability_id is not a valid uuid" -// @Failure 403 {object} map[string]string "Caller is not an active workspace member" -// @Failure 404 {object} map[string]string "Capability not found in this workspace" -// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/install-count [get] -func getCapabilityInstallCount(runtimeStore RuntimeStore) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - _, capabilityID, ok := requireWorkspaceCapabilityByID(w, r, runtimeStore, false) - if !ok { - return - } - count, err := runtimeStore.CountInstalls(r.Context(), capabilityID) - if err != nil { - writeCapabilityError(w, err, "failed to count marketplace installs") - return - } - writeJSON(w, http.StatusOK, map[string]any{"capability_id": capabilityID, "install_count": count}) - } -} - // listMarketplaceEnabledAgents lists agents in the target workspace that have -// this marketplace capability enabled. +// this workspace capability enabled. // -// @Summary List agents using a marketplace capability -// @Description Lists agents in the workspace that have this marketplace capability enabled. +// @Summary List agents using a capability +// @Description Lists agents in the workspace that have this capability enabled. // @Tags capabilities // @ID listDevMarketplaceEnabledAgents // @Produce json // @Param workspaceID path string true "Workspace UUID" -// @Param capabilityID path string true "Source marketplace capability UUID" +// @Param capabilityID path string true "Capability UUID" // @Success 200 {object} map[string]interface{} "capability_id and enabled agents" // @Failure 400 {object} map[string]string "workspace_id or capability_id is not a valid uuid" // @Failure 403 {object} map[string]string "Caller is not an active workspace member" @@ -604,15 +584,10 @@ func getCapabilityInstallCount(runtimeStore RuntimeStore) http.HandlerFunc { // @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/enabled-agents [get] func listMarketplaceEnabledAgents(runtimeStore RuntimeStore) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - workspaceID, ok := requireWorkspaceCapabilityRead(w, r, runtimeStore) + workspaceID, capabilityID, ok := requireWorkspaceCapabilityByID(w, r, runtimeStore, false) if !ok { return } - capabilityID := chi.URLParam(r, "capabilityID") - if !isUUID(capabilityID) { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "capability_id must be a valid uuid"}) - return - } agents, err := runtimeStore.ListEnabledAgents(r.Context(), workspaceID, capabilityID) if err != nil { writeCapabilityError(w, err, "failed to list enabled marketplace agents") @@ -758,48 +733,10 @@ func patchWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { } } -// publishWorkspaceCapability promotes a capability to the marketplace. +// deprecateWorkspaceCapability marks a workspace capability as deprecated. // -// @Summary Publish a capability to the marketplace -// @Description Promotes an active capability to the marketplace. Owner/admin only. Rejects with 400 if any capability version contains a plaintext-looking secret. -// @Tags capabilities -// @ID publishDevWorkspaceCapability -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param capabilityID path string true "Capability UUID" -// @Success 200 {object} map[string]interface{} "Updated capability" -// @Failure 400 {object} map[string]string "Plaintext secret detected or invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Capability not found in this workspace" -// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/publish [post] -func publishWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { - return marketplaceStateCapability(runtimeStore, "publish") -} - -// unpublishWorkspaceCapability retracts a capability from the marketplace. -// -// @Summary Unpublish a capability from the marketplace -// @Description Retracts a previously published capability from the marketplace. Owner/admin only. -// @Tags capabilities -// @ID unpublishDevWorkspaceCapability -// @Produce json -// @Param workspaceID path string true "Workspace UUID" -// @Param capabilityID path string true "Capability UUID" -// @Success 200 {object} map[string]interface{} "Updated capability" -// @Failure 400 {object} map[string]string "Invalid UUID" -// @Failure 403 {object} map[string]string "Caller is not workspace owner/admin" -// @Failure 404 {object} map[string]string "Capability not found in this workspace" -// @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" -// @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/unpublish [post] -func unpublishWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { - return marketplaceStateCapability(runtimeStore, "unpublish") -} - -// deprecateWorkspaceCapability marks a marketplace capability as deprecated. -// -// @Summary Deprecate a marketplace capability -// @Description Marks a published capability as deprecated so it stays reachable for existing installs but is hidden from new installs. Owner/admin only. +// @Summary Deprecate a workspace capability +// @Description Marks a workspace capability as deprecated so existing Agent bindings keep working but new Agent picks hide it. Owner/admin only. // @Tags capabilities // @ID deprecateDevWorkspaceCapability // @Produce json @@ -812,14 +749,14 @@ func unpublishWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { // @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" // @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/deprecate [post] func deprecateWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { - return marketplaceStateCapability(runtimeStore, "deprecate") + return capabilityAvailabilityState(runtimeStore, "deprecate") } -// undeprecateWorkspaceCapability clears the deprecated flag on a marketplace +// undeprecateWorkspaceCapability clears the deprecated flag on a workspace // capability. // -// @Summary Undeprecate a marketplace capability -// @Description Clears the deprecated flag on a previously deprecated marketplace capability. Owner/admin only. +// @Summary Undeprecate a workspace capability +// @Description Clears the deprecated flag on a previously deprecated workspace capability. Owner/admin only. // @Tags capabilities // @ID undeprecateDevWorkspaceCapability // @Produce json @@ -832,43 +769,28 @@ func deprecateWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { // @Failure 503 {object} map[string]string "Database-backed capability APIs are disabled" // @Router /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/undeprecate [post] func undeprecateWorkspaceCapability(runtimeStore RuntimeStore) http.HandlerFunc { - return marketplaceStateCapability(runtimeStore, "undeprecate") + return capabilityAvailabilityState(runtimeStore, "undeprecate") } -func marketplaceStateCapability(runtimeStore RuntimeStore, action string) http.HandlerFunc { +func capabilityAvailabilityState(runtimeStore RuntimeStore, action string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { workspaceID, capabilityID, ok := requireWorkspaceCapabilityByID(w, r, runtimeStore, true) if !ok { return } - if action == "publish" { - versions, err := runtimeStore.ListCapabilityVersions(r.Context(), capabilityID) - if err != nil { - writeCapabilityError(w, err, "failed to list capability versions") - return - } - if err := rejectPlaintextSecretsInCapabilityVersions(versions); err != nil { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) - return - } - } var updated store.CapabilityRead var err error switch action { - case "publish": - updated, err = runtimeStore.PublishCapability(r.Context(), workspaceID, capabilityID) - case "unpublish": - updated, err = runtimeStore.UnpublishCapability(r.Context(), workspaceID, capabilityID) case "deprecate": updated, err = runtimeStore.DeprecateCapability(r.Context(), workspaceID, capabilityID) case "undeprecate": updated, err = runtimeStore.UndeprecateCapability(r.Context(), workspaceID, capabilityID) default: - writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unknown marketplace action"}) + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "unknown availability action"}) return } if err != nil { - writeCapabilityError(w, err, "failed to update marketplace state") + writeCapabilityError(w, err, "failed to update capability availability") return } writeJSON(w, http.StatusOK, updated) @@ -1343,10 +1265,11 @@ func listAgentCapabilities(runtimeStore RuntimeStore) http.HandlerFunc { } // enableAgentCapability enables (installs) a capability version on the agent. -// Cross-workspace requires the source capability to be public + non-deprecated. +// Cross-workspace capability binding is not allowed; capabilities must belong +// to the agent's workspace. // // @Summary Enable a capability on an agent -// @Description Enables (installs) a capability version on the agent. Cross-workspace enable requires the source capability to be public and non-deprecated. Workspace owner, admin, or member only. +// @Description Enables (installs) a workspace capability version on the agent. Workspace owner, admin, or member only. // @Tags capabilities // @ID enableDevAgentCapability // @Accept json @@ -1383,11 +1306,8 @@ func enableAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc { writeCapabilityError(w, err, "failed to get capability") return } - // Cross-workspace enable is only allowed for capabilities still - // on offer in the marketplace: public visibility + not soft- - // removed via deprecated_at. - if capability.WorkspaceID != agent.WorkspaceID && (capability.Visibility != "public" || capability.DeprecatedAt != nil) { - writeJSON(w, http.StatusForbidden, map[string]string{"error": "marketplace capability is unavailable"}) + if capability.WorkspaceID != agent.WorkspaceID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "capability is not in this workspace"}) return } var body agentCapabilityBody @@ -1564,6 +1484,15 @@ func upgradeAgentCapability(runtimeStore RuntimeStore) http.HandlerFunc { writeCapabilityError(w, err, "failed to get agent") return } + capability, err := runtimeStore.GetCapability(r.Context(), capabilityID) + if err != nil { + writeCapabilityError(w, err, "failed to get capability") + return + } + if capability.WorkspaceID != agent.WorkspaceID { + writeJSON(w, http.StatusForbidden, map[string]string{"error": "capability is not in this workspace"}) + return + } bindings, err := runtimeStore.ListAgentCapabilities(r.Context(), agentID) if err != nil { writeCapabilityError(w, err, "failed to list agent capabilities") @@ -1773,22 +1702,6 @@ func credentialResponse(credential store.UserCredentialRead) userCredentialRespo return userCredentialResponse{ID: credential.ID, Kind: credential.Kind, DisplayName: credential.DisplayName, LastUsedAt: credential.LastUsedAt, CreatedAt: credential.CreatedAt, UpdatedAt: credential.UpdatedAt} } -func rejectPlaintextSecretsInCapabilityVersions(versions []store.CapabilityVersionRead) error { - for _, version := range versions { - raw, err := json.Marshal(version.Content) - if err != nil { - return fmt.Errorf("capability version %s content is invalid", version.ID) - } - text := string(raw) - for _, secretPattern := range plaintextSecretPatterns { - if secretPattern.pattern.MatchString(text) { - return fmt.Errorf("capability version %s contains plaintext secret pattern: %s", version.ID, secretPattern.name) - } - } - } - return nil -} - func optionalString(value *string) string { if value == nil { return "" diff --git a/server/internal/dev/routes.go b/server/internal/dev/routes.go index ee3963a..2f89440 100644 --- a/server/internal/dev/routes.go +++ b/server/internal/dev/routes.go @@ -47,14 +47,11 @@ type RuntimeStore interface { ListCapabilities(ctx context.Context, workspaceID string, filter store.ListCapabilityFilter) ([]store.CapabilityRead, error) ListMarketplaceCapabilities(ctx context.Context, targetWorkspaceID string) ([]store.MarketplaceCapabilityRead, error) ListWorkspaceMarketplaceInstalls(ctx context.Context, targetWorkspaceID string) ([]store.MarketplaceInstallRead, error) - CountInstalls(ctx context.Context, sourceCapabilityID string) (int64, error) ListEnabledAgents(ctx context.Context, targetWorkspaceID string, sourceCapabilityID string) ([]store.EnabledMarketplaceAgentRead, error) CreateCapability(ctx context.Context, input store.CreateCapabilityInput) (store.CapabilityRead, error) GetCapability(ctx context.Context, capabilityID string) (store.CapabilityRead, error) UpdateCapability(ctx context.Context, input store.UpdateCapabilityInput) (store.CapabilityRead, error) SoftDeleteCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) - PublishCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) - UnpublishCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) DeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) UndeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) ListCapabilityVersions(ctx context.Context, capabilityID string) ([]store.CapabilityVersionRead, error) @@ -638,9 +635,9 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro // mode this server is wired for and (for sandbox mode) // whether the provider is reachable. r.Get("/workspaces/{workspaceID}/runtime/status", gateWorkspaceMember(runtimeStore, runtimeStatus(cfg.runtimeStatus))) + r.Get("/capabilities/marketplace", listMarketplaceCapabilities(runtimeStore)) + r.Get("/capabilities/marketplace/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities", listWorkspaceCapabilities(runtimeStore)) - r.Get("/workspaces/{workspaceID}/marketplace/capabilities", listMarketplaceCapabilities(runtimeStore)) - r.Get("/workspaces/{workspaceID}/marketplace/capabilities/{capabilityID}", getMarketplaceCapabilityDetail(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities", createWorkspaceCapability(runtimeStore)) // Capability import — preview is a pure parse; commit runs // the all-or-nothing materialization. @@ -660,12 +657,9 @@ func RegisterRoutesWithStore(r chi.Router, runtimeStore RuntimeStore, opts ...Ro r.Get("/workspaces/{workspaceID}/capabilities/marketplace-installs", listWorkspaceMarketplaceInstalls(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities/uninstall", uninstallWorkspaceMarketplaceCapability(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities/{capabilityID}", getWorkspaceCapability(runtimeStore)) - r.Get("/workspaces/{workspaceID}/capabilities/{capabilityID}/install-count", getCapabilityInstallCount(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities/{capabilityID}/enabled-agents", listMarketplaceEnabledAgents(runtimeStore)) r.Patch("/workspaces/{workspaceID}/capabilities/{capabilityID}", patchWorkspaceCapability(runtimeStore)) r.Delete("/workspaces/{workspaceID}/capabilities/{capabilityID}", deleteWorkspaceCapability(runtimeStore)) - r.Post("/workspaces/{workspaceID}/capabilities/{capabilityID}/publish", publishWorkspaceCapability(runtimeStore)) - r.Post("/workspaces/{workspaceID}/capabilities/{capabilityID}/unpublish", unpublishWorkspaceCapability(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities/{capabilityID}/deprecate", deprecateWorkspaceCapability(runtimeStore)) r.Post("/workspaces/{workspaceID}/capabilities/{capabilityID}/undeprecate", undeprecateWorkspaceCapability(runtimeStore)) r.Get("/workspaces/{workspaceID}/capabilities/{capabilityID}/versions", listWorkspaceCapabilityVersions(runtimeStore)) diff --git a/server/internal/dev/routes_agents.go b/server/internal/dev/routes_agents.go index 993d8cb..9125e21 100644 --- a/server/internal/dev/routes_agents.go +++ b/server/internal/dev/routes_agents.go @@ -579,9 +579,8 @@ func syncAgentCapabilities( } // 2. Resolve desired names. A name can come from this workspace's own - // capabilities, OR from the workspace-scoped marketplace pool surfaced - // in the agent picker. Local capabilities win on name collision: a user - // shadowing a marketplace name with a private one should keep using their own. + // capabilities, or from the workspace-scoped capability library surfaced + // in the agent picker. Local capability rows win on name collision. allCaps, err := rs.ListCapabilities(ctx, workspaceID, store.ListCapabilityFilter{}) if err != nil { return fmt.Errorf("syncAgentCapabilities: list capabilities: %w", err) @@ -647,12 +646,12 @@ func syncAgentCapabilities( } // Default pinning_mode depends on the source: - // * Local capability: user's expectation is "check it and follow the latest". After reupload, - // no need to re-edit the agent, and skill iteration in the local workshop has no - // breaking-change risk (owned by the same team). - // * Marketplace: the publisher's new version may carry breaking changes, - // keep pinned so the UpgradeCapabilityDialog explicit-confirm path remains - // valid; the user must explicitly pick latest from the picker to auto-follow. + // * Workspace capability: user's expectation is "check it and follow the latest". After reupload, + // no need to re-edit the agent, and skill iteration inside the workspace has no + // cross-team breaking-change risk. + // * Library compatibility row: keep pinned so the UpgradeCapabilityDialog + // explicit-confirm path remains valid; the user must explicitly pick latest + // from the picker to auto-follow. mode := store.PinningModeLatest if cap.fromMarketplace { mode = store.PinningModePinned diff --git a/server/internal/dev/routes_capability_test.go b/server/internal/dev/routes_capability_test.go index cd6e4cc..0f3cacb 100644 --- a/server/internal/dev/routes_capability_test.go +++ b/server/internal/dev/routes_capability_test.go @@ -205,7 +205,7 @@ func TestCapabilityAgentWriteRBACWorkspaceAndUniqueUpdate(t *testing.T) { } foreign := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+ownedPA+"/capabilities/"+foreignV1+"/enable", `{}`, testUserAID) if foreign.Code != http.StatusForbidden { - t.Fatalf("unpublished foreign capability expected 403, got %d: %s", foreign.Code, foreign.Body.String()) + t.Fatalf("foreign workspace capability expected 403, got %d: %s", foreign.Code, foreign.Body.String()) } updated := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+ownedPA+"/capabilities/"+v2+"/enable", `{}`, testUserAID) if updated.Code != http.StatusOK || !strings.Contains(updated.Body.String(), v2) { @@ -363,124 +363,100 @@ func TestAgentVisibilityRejectsPersonalCapabilityCredential(t *testing.T) { } } -func TestCapabilityMarketplacePublishLifecycleSecretCheckAndDeleteRollback(t *testing.T) { +func TestCapabilityWorkspaceAvailabilityAndDeleteRollback(t *testing.T) { r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}, nil) - capID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Marketplace Secret") - if _, err := db.Exec(context.Background(), `update capability_version set content = '{"mcpServers":{"github":{"command":"npx","env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"ghp_123456789012345678901234567890123456"}}}}' where capability_id = $1`, capID); err != nil { - t.Fatal(err) - } - rejected := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/publish", `{}`, store.DefaultDevFixtureIDs().UserID) - if rejected.Code != http.StatusBadRequest || !strings.Contains(rejected.Body.String(), "plaintext secret pattern") { - t.Fatalf("publish with plaintext secret expected 400, got %d: %s", rejected.Code, rejected.Body.String()) + capID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Workspace Availability") + + publish := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/publish", "{}", store.DefaultDevFixtureIDs().UserID) + if publish.Code != http.StatusNotFound { + t.Fatalf("publish endpoint expected 404, got %d: %s", publish.Code, publish.Body.String()) } - if _, err := db.Exec(context.Background(), `update capability_version set content = '{"mcpServers":{"github":{"command":"npx","env":{"GITHUB_PERSONAL_ACCESS_TOKEN":"${PARSAR_CREDENTIAL:github_pat}"}}}}' where capability_id = $1`, capID); err != nil { - t.Fatal(err) + unpublish := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/unpublish", "{}", store.DefaultDevFixtureIDs().UserID) + if unpublish.Code != http.StatusNotFound { + t.Fatalf("unpublish endpoint expected 404, got %d: %s", unpublish.Code, unpublish.Body.String()) } - published := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/publish", `{}`, store.DefaultDevFixtureIDs().UserID) - if published.Code != http.StatusOK || !strings.Contains(published.Body.String(), `"visibility":"public"`) { - t.Fatalf("publish expected 200/public, got %d: %s", published.Code, published.Body.String()) + installCount := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/install-count", "", store.DefaultDevFixtureIDs().UserID) + if installCount.Code != http.StatusNotFound { + t.Fatalf("install-count endpoint expected 404, got %d: %s", installCount.Code, installCount.Body.String()) } - deprecated := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/deprecate", `{}`, store.DefaultDevFixtureIDs().UserID) - if deprecated.Code != http.StatusOK || !strings.Contains(deprecated.Body.String(), "deprecated_at") { - t.Fatalf("deprecate expected timestamp, got %d: %s", deprecated.Code, deprecated.Body.String()) + + deprecated := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/deprecate", "{}", store.DefaultDevFixtureIDs().UserID) + if deprecated.Code != http.StatusOK || !strings.Contains(deprecated.Body.String(), "deprecated_at") || !strings.Contains(deprecated.Body.String(), "\"visibility\":\"workspace\"") { + t.Fatalf("deprecate expected workspace capability timestamp, got %d: %s", deprecated.Code, deprecated.Body.String()) } - undeprecated := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/undeprecate", `{}`, store.DefaultDevFixtureIDs().UserID) - if undeprecated.Code != http.StatusOK || strings.Contains(undeprecated.Body.String(), "deprecated_at") { + undeprecated := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/undeprecate", "{}", store.DefaultDevFixtureIDs().UserID) + if undeprecated.Code != http.StatusOK || strings.Contains(undeprecated.Body.String(), "deprecated_at") || !strings.Contains(undeprecated.Body.String(), "\"visibility\":\"workspace\"") { t.Fatalf("undeprecate expected no deprecated_at, got %d: %s", undeprecated.Code, undeprecated.Body.String()) } - unpublished := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/unpublish", `{}`, store.DefaultDevFixtureIDs().UserID) - if unpublished.Code != http.StatusOK || !strings.Contains(unpublished.Body.String(), `"visibility":"workspace"`) { - t.Fatalf("unpublish expected workspace, got %d: %s", unpublished.Code, unpublished.Body.String()) - } - deleted := serveCapabilityRoute(t, r, http.MethodDelete, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID, ``, store.DefaultDevFixtureIDs().UserID) - if deleted.Code != http.StatusOK || !strings.Contains(deleted.Body.String(), `"deleted_at"`) { + deleted := serveCapabilityRoute(t, r, http.MethodDelete, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID, "", store.DefaultDevFixtureIDs().UserID) + if deleted.Code != http.StatusOK || !strings.Contains(deleted.Body.String(), "\"deleted_at\"") { t.Fatalf("delete expected 200 with deleted_at, got %d: %s", deleted.Code, deleted.Body.String()) } } -func TestWorkspaceMarketplaceListIsWorkspaceScopedAndIgnoresVisibility(t *testing.T) { - foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" - r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "member"}, nil) - workspaceCapID, _, _ := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Workspace Private MCP") - insertForeignWorkspace(t, db, foreignWorkspaceID) - foreignCapID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public MCP") - publishForeignCapability(t, db, foreignCapID) - - list := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) - if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "Workspace Private MCP") || strings.Contains(list.Body.String(), "Foreign Public MCP") || strings.Contains(list.Body.String(), foreignWorkspaceID) { - t.Fatalf("workspace marketplace list expected only local capabilities regardless of visibility, got %d: %s", list.Code, list.Body.String()) - } - - detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+workspaceCapID, ``, testUserAID) - if detail.Code != http.StatusOK { - t.Fatalf("workspace marketplace detail expected private local capability, got %d: %s", detail.Code, detail.Body.String()) - } - - foreignDetail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+foreignCapID, ``, testUserAID) - if foreignDetail.Code != http.StatusNotFound { - t.Fatalf("foreign marketplace detail expected 404, got %d: %s", foreignDetail.Code, foreignDetail.Body.String()) - } - - nonMemberID := "00000000-0000-0000-0000-000000000777" - blocked := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, nonMemberID) - if blocked.Code != http.StatusNotFound { - t.Fatalf("non-member marketplace list expected 404, got %d: %s", blocked.Code, blocked.Body.String()) - } -} - -func TestCapabilityMarketplaceCrossWorkspaceEnableUpgradeUninstallAndReverseQueries(t *testing.T) { +func TestCapabilityMarketplaceIsWorkspaceScopedAndRejectsCrossWorkspaceBinding(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "admin"}, map[string]string{testUserAID: "member"}) insertForeignWorkspace(t, db, foreignWorkspaceID) capID, v1, v2 := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public MCP") - publishForeignCapability(t, db, capID) hiddenPluginID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public Plugin") - if _, err := db.Exec(context.Background(), `update capability set type = 'plugin' where id = $1`, hiddenPluginID); err != nil { + if _, err := db.Exec(context.Background(), "update capability set type = 'plugin' where id = $1", hiddenPluginID); err != nil { t.Fatal(err) } - publishForeignCapability(t, db, hiddenPluginID) + if _, err := db.Exec(context.Background(), "update capability set visibility = 'public' where id in ($1, $2)", capID, hiddenPluginID); err != nil { + t.Fatal(err) + } + localCapID, localV1, localV2 := insertCapabilityVersions(t, db, store.DefaultDevFixtureIDs().WorkspaceID, "Workspace Library MCP") agentA := insertAgentForOwner(t, db, testUserAID, "market-agent-a") agentB := insertAgentForOwner(t, db, testUserAID, "market-agent-b") - enabledA := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+v1+"/enable", `{}`, testUserAID) + foreignEnable := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+v1+"/enable", "{}", testUserAID) + if foreignEnable.Code != http.StatusForbidden || !strings.Contains(foreignEnable.Body.String(), "capability is not in this workspace") { + t.Fatalf("cross-workspace enable expected 403, got %d: %s", foreignEnable.Code, foreignEnable.Body.String()) + } + enabledA := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+localV1+"/enable", "{}", testUserAID) if enabledA.Code != http.StatusOK { - t.Fatalf("enable marketplace A expected 200, got %d: %s", enabledA.Code, enabledA.Body.String()) + t.Fatalf("enable workspace capability A expected 200, got %d: %s", enabledA.Code, enabledA.Body.String()) } - enabledB := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentB+"/capabilities/"+v1+"/enable", `{}`, testUserAID) + enabledB := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentB+"/capabilities/"+localV1+"/enable", "{}", testUserAID) if enabledB.Code != http.StatusOK { - t.Fatalf("enable marketplace B expected 200, got %d: %s", enabledB.Code, enabledB.Body.String()) + t.Fatalf("enable workspace capability B expected 200, got %d: %s", enabledB.Code, enabledB.Body.String()) + } + list := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities", "", testUserAID) + if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), "\"enabled_agent_count\":2") || strings.Contains(list.Body.String(), "\"from_marketplace\":true") { + t.Fatalf("workspace capability list expected count=2 without marketplace install rows, got %d: %s", list.Code, list.Body.String()) } - list := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities", ``, testUserAID) - if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), `"enabled_agent_count":2`) || !strings.Contains(list.Body.String(), `"from_marketplace":true`) { - t.Fatalf("reverse marketplace list expected count=2, got %d: %s", list.Code, list.Body.String()) + market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, "", testUserAID) + if market.Code != http.StatusOK || !strings.Contains(market.Body.String(), localCapID) || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public MCP") || strings.Contains(market.Body.String(), "Foreign Public Plugin") { + t.Fatalf("workspace marketplace list expected only current workspace capabilities, got %d: %s", market.Code, market.Body.String()) } - market := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities", ``, testUserAID) - if market.Code != http.StatusOK || strings.Contains(market.Body.String(), "Foreign Public MCP") || strings.Contains(market.Body.String(), foreignWorkspaceID) || strings.Contains(market.Body.String(), "Foreign Public Plugin") { - t.Fatalf("workspace marketplace list expected no foreign capabilities, got %d: %s", market.Code, market.Body.String()) + foreignDetail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capID+"?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, "", testUserAID) + if foreignDetail.Code != http.StatusNotFound { + t.Fatalf("foreign marketplace detail expected 404, got %d: %s", foreignDetail.Code, foreignDetail.Body.String()) } - if _, err := db.Exec(context.Background(), `update capability_version set creator_id = null where id = $1`, v2); err != nil { + if _, err := db.Exec(context.Background(), "update capability_version set creator_id = null where id = $1", localV2); err != nil { t.Fatal(err) } - detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/marketplace/capabilities/"+capID, ``, testUserAID) - if detail.Code != http.StatusNotFound { - t.Fatalf("workspace marketplace detail for foreign capability expected 404, got %d: %s", detail.Code, detail.Body.String()) + detail := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+localCapID+"?workspace_id="+store.DefaultDevFixtureIDs().WorkspaceID, "", testUserAID) + if detail.Code != http.StatusOK || !strings.Contains(detail.Body.String(), "\"version\":\"v2\"") { + t.Fatalf("marketplace detail with nullable creator expected 200/v2, got %d: %s", detail.Code, detail.Body.String()) } - upgraded := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+capID+"/upgrade", `{"new_version_id":"`+v2+`"}`, testUserAID) - if upgraded.Code != http.StatusOK || !strings.Contains(upgraded.Body.String(), v2) { - t.Fatalf("upgrade expected 200/v2, got %d: %s", upgraded.Code, upgraded.Body.String()) + upgraded := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+localCapID+"/upgrade", "{\"new_version_id\":\""+localV2+"\"}", testUserAID) + if upgraded.Code != http.StatusOK || !strings.Contains(upgraded.Body.String(), localV2) { + t.Fatalf("upgrade workspace capability expected 200/v2, got %d: %s", upgraded.Code, upgraded.Body.String()) } - assertSingleAgentCapability(t, db, agentA, capID, v2) - if _, err := db.Exec(context.Background(), `update capability set deprecated_at = now() where id = $1`, capID); err != nil { + assertSingleAgentCapability(t, db, agentA, localCapID, localV2) + foreignUpgrade := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+capID+"/upgrade", "{\"new_version_id\":\""+v2+"\"}", testUserAID) + if foreignUpgrade.Code != http.StatusForbidden || !strings.Contains(foreignUpgrade.Body.String(), "capability is not in this workspace") { + t.Fatalf("cross-workspace upgrade expected 403, got %d: %s", foreignUpgrade.Code, foreignUpgrade.Body.String()) + } + if _, err := db.Exec(context.Background(), "update capability set deprecated_at = now() where id = $1", localCapID); err != nil { t.Fatal(err) } - blocked := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentB+"/capabilities/"+capID+"/upgrade", `{"new_version_id":"`+v2+`"}`, testUserAID) + blocked := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentB+"/capabilities/"+localCapID+"/upgrade", "{\"new_version_id\":\""+localV2+"\"}", testUserAID) if blocked.Code != http.StatusForbidden { t.Fatalf("upgrade deprecated expected 403, got %d: %s", blocked.Code, blocked.Body.String()) } - uninstalled := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/uninstall", `{"source_capability_id":"`+capID+`"}`, testUserAID) - if uninstalled.Code != http.StatusOK || !strings.Contains(uninstalled.Body.String(), `"removed_agent_count":2`) { - t.Fatalf("uninstall expected remove 2, got %d: %s", uninstalled.Code, uninstalled.Body.String()) - } } type marketplaceDetailStore struct { @@ -526,7 +502,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), "Render a clear SVG") || !strings.Contains(res.Body.String(), "references/svg.md") { t.Fatalf("skill detail expected content, got %d: %s", res.Code, res.Body.String()) } @@ -556,7 +532,7 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) body := res.Body.String() if res.Code != http.StatusOK || !strings.Contains(body, `"redacted":true`) || !strings.Contains(body, `"credential_kind_code":"github_pat"`) || !strings.Contains(body, "https://api.example.com") { t.Fatalf("MCP detail expected sanitized config, got %d: %s", res.Code, body) @@ -566,16 +542,16 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } }) - t.Run("workspace visibility capability is visible", func(t *testing.T) { + t.Run("workspace capability is visible", func(t *testing.T) { runtimeStore := marketplaceDetailStore{ capability: store.MarketplaceCapabilityRead{CapabilityID: capabilityID, Type: "skill", Visibility: "workspace", Status: "active", LatestVersionID: versionID}, version: store.CapabilityVersionRead{ID: versionID, CapabilityID: capabilityID, Version: "v1"}, } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK { - t.Fatalf("workspace visibility capability expected 200, got %d: %s", res.Code, res.Body.String()) + t.Fatalf("workspace capability expected 200, got %d: %s", res.Code, res.Body.String()) } }) @@ -586,51 +562,13 @@ func TestMarketplaceCapabilityDetailShowsSkillAndRedactsMCPSecret(t *testing.T) } r := chi.NewRouter() RegisterRoutesWithStore(r, runtimeStore) - res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+workspaceID+"/marketplace/capabilities/"+capabilityID, "", store.DefaultDevFixtureIDs().UserID) + res := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/capabilities/marketplace/"+capabilityID+"?workspace_id="+workspaceID, "", store.DefaultDevFixtureIDs().UserID) if res.Code != http.StatusOK || !strings.Contains(res.Body.String(), `"git_repo_url":"https://example.com/legacy"`) { t.Fatalf("legacy detail expected source metadata, got %d: %s", res.Code, res.Body.String()) } }) } -// TestInstallCountRejectsCrossWorkspace verifies that GET -// /api/v1/workspaces/{workspaceID}/capabilities/{capabilityID}/install-count -// rejects with 404 when the capability does not belong to the URL -// workspace, even if the caller is a legitimate member of that -// workspace. Prevents leaking marketplace install counts of foreign -// workspaces' capabilities (Decision #7 isolation). -func TestInstallCountRejectsCrossWorkspace(t *testing.T) { - foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" - r, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin", testUserAID: "admin"}, map[string]string{testUserAID: "member"}) - insertForeignWorkspace(t, db, foreignWorkspaceID) - capID, v1, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Public MCP install-count") - publishForeignCapability(t, db, capID) - agentA := insertAgentForOwner(t, db, testUserAID, "market-agent-install-count") - enabled := serveCapabilityRoute(t, r, http.MethodPost, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/agents/"+agentA+"/capabilities/"+v1+"/enable", `{}`, testUserAID) - if enabled.Code != http.StatusOK { - t.Fatalf("enable marketplace cap expected 200, got %d: %s", enabled.Code, enabled.Body.String()) - } - - // The caller (testUserAID) is admin of DefaultDevFixtureIDs().WorkspaceID - // but the capability is owned by foreignWorkspaceID. The install-count - // endpoint MUST reject with 404 — caller cannot pivot through their own - // workspace URL to read foreign capability metrics. - leaked := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+store.DefaultDevFixtureIDs().WorkspaceID+"/capabilities/"+capID+"/install-count", ``, testUserAID) - if leaked.Code != http.StatusNotFound { - t.Fatalf("cross-workspace install-count leak: expected 404, got %d: %s", leaked.Code, leaked.Body.String()) - } - - // Source workspace owner (DefaultDevFixtureIDs().UserID is admin of foreignWorkspaceID - // via the test setup) querying via foreign workspace URL must work. - if _, err := db.Exec(context.Background(), `insert into workspace_members(id, workspace_id, user_id, role, created_at, updated_at) values (gen_random_uuid(), $1, $2, 'admin', now(), now())`, foreignWorkspaceID, store.DefaultDevFixtureIDs().UserID); err != nil { - t.Fatal(err) - } - owned := serveCapabilityRoute(t, r, http.MethodGet, "/api/v1/workspaces/"+foreignWorkspaceID+"/capabilities/"+capID+"/install-count", ``, store.DefaultDevFixtureIDs().UserID) - if owned.Code != http.StatusOK || !strings.Contains(owned.Body.String(), `"install_count":1`) { - t.Fatalf("source workspace owner install-count expected 200/install_count=1, got %d: %s", owned.Code, owned.Body.String()) - } -} - func capabilityTestRouter(t *testing.T, workspaceRoles map[string]string, _ map[string]string) (http.Handler, *pgxpool.Pool) { t.Helper() oldKey := os.Getenv("PARSAR_MASTER_KEY") @@ -726,13 +664,6 @@ func insertCapabilityVersions(t *testing.T, db *pgxpool.Pool, workspaceID, name return capID, v1, v2 } -func publishForeignCapability(t *testing.T, db *pgxpool.Pool, capabilityID string) { - t.Helper() - if _, err := db.Exec(context.Background(), `update capability set visibility = 'public', deprecated_at = null, status = 'active', deleted_at = null where id = $1`, capabilityID); err != nil { - t.Fatal(err) - } -} - func insertAgentForOwner(t *testing.T, db *pgxpool.Pool, userID, slug string) string { t.Helper() var agentID string @@ -961,22 +892,26 @@ func insertAgentCapability(t *testing.T, db *pgxpool.Pool, agentID, capabilityID } } -// TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName covers the -// workspace-scoped marketplace model: a public capability from another -// workspace is no longer part of this workspace's marketplace pool. -func TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName(t *testing.T) { +// TestSyncAgentCapabilitiesBindsWorkspaceCapabilityByName covers the edit-dialog +// path where the user checks a capability by name. The agent payload only +// carries names, so syncAgentCapabilities has to resolve the name against the +// workspace-scoped capability library without reaching into other workspaces. +func TestSyncAgentCapabilitiesBindsWorkspaceCapabilityByName(t *testing.T) { foreignWorkspaceID := "00000000-0000-0000-0000-000000000099" _, db := capabilityTestRouter(t, map[string]string{store.DefaultDevFixtureIDs().UserID: "admin"}, nil) insertForeignWorkspace(t, db, foreignWorkspaceID) - capID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") - publishForeignCapability(t, db, capID) + foreignCapID, _, _ := insertCapabilityVersions(t, db, foreignWorkspaceID, "Foreign Marketplace MCP") + if _, err := db.Exec(context.Background(), "update capability set visibility = 'public' where id = $1", foreignCapID); err != nil { + t.Fatal(err) + } wid := store.DefaultDevFixtureIDs().WorkspaceID uid := store.DefaultDevFixtureIDs().UserID - agentID := insertAgentForSyncTest(t, db, wid, uid, "sync-marketplace-target") + capID, _, v2 := insertCapabilityVersions(t, db, wid, "Workspace Library MCP") + agentID := insertAgentForSyncTest(t, db, wid, uid, "sync-workspace-library-target") s := store.New(db) - if err := syncAgentCapabilities(context.Background(), s, wid, agentID, []string{"Foreign Marketplace MCP"}); err != nil { + if err := syncAgentCapabilities(context.Background(), s, wid, agentID, []string{"Foreign Marketplace MCP", "Workspace Library MCP"}); err != nil { t.Fatalf("sync: %v", err) } @@ -984,10 +919,15 @@ func TestSyncAgentCapabilitiesDoesNotBindForeignMarketplaceByName(t *testing.T) if err != nil { t.Fatalf("list bindings: %v", err) } - for _, b := range bindings { - if b.CapabilityID == capID { - t.Fatalf("foreign marketplace capability binding should not be created (capID=%s); bindings=%+v", capID, bindings) - } + if len(bindings) != 1 { + t.Fatalf("expected exactly one workspace binding, got %+v", bindings) + } + matched := bindings[0] + if matched.CapabilityID != capID { + t.Fatalf("expected workspace capability %s, got %+v", capID, matched) + } + if matched.CapabilityVersionID != v2 { + t.Fatalf("expected latest workspace version %s, got %s", v2, matched.CapabilityVersionID) } } diff --git a/server/internal/dev/routes_test.go b/server/internal/dev/routes_test.go index 8e3f0b9..29d352f 100644 --- a/server/internal/dev/routes_test.go +++ b/server/internal/dev/routes_test.go @@ -2632,10 +2632,6 @@ func (stubRuntimeStore) ListWorkspaceMarketplaceInstalls(ctx context.Context, ta return []store.MarketplaceInstallRead{}, nil } -func (stubRuntimeStore) CountInstalls(ctx context.Context, sourceCapabilityID string) (int64, error) { - return 0, nil -} - func (stubRuntimeStore) ListEnabledAgents(ctx context.Context, targetWorkspaceID string, sourceCapabilityID string) ([]store.EnabledMarketplaceAgentRead, error) { return []store.EnabledMarketplaceAgentRead{}, nil } @@ -2656,21 +2652,13 @@ func (stubRuntimeStore) SoftDeleteCapability(ctx context.Context, workspaceID st return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Type: "mcp", Name: "GitHub", Visibility: "workspace", Status: "active"}, nil } -func (stubRuntimeStore) PublishCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) { - return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "public", Status: "active"}, nil -} - -func (stubRuntimeStore) UnpublishCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) { - return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "workspace", Status: "active"}, nil -} - func (stubRuntimeStore) DeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) { now := time.Now().UTC() - return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "public", Status: "active", DeprecatedAt: &now}, nil + return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "workspace", Status: "active", DeprecatedAt: &now}, nil } func (stubRuntimeStore) UndeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (store.CapabilityRead, error) { - return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "public", Status: "active"}, nil + return store.CapabilityRead{ID: capabilityID, WorkspaceID: workspaceID, Visibility: "workspace", Status: "active"}, nil } func (stubRuntimeStore) ListCapabilityVersions(ctx context.Context, capabilityID string) ([]store.CapabilityVersionRead, error) { diff --git a/server/internal/store/capabilities.go b/server/internal/store/capabilities.go index 53ad649..fb6b823 100644 --- a/server/internal/store/capabilities.go +++ b/server/internal/store/capabilities.go @@ -8,9 +8,9 @@ import ( "strings" "time" + "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" - "github.com/MiniMax-AI-Dev/parsar/server/internal/db/sqlc" ) type RequiredCredential struct { @@ -20,10 +20,10 @@ type RequiredCredential struct { } type EnabledCapabilityRead struct { - AgentCapabilityID string `json:"agent_capability_id"` - AgentID string `json:"agent_id"` - Enabled bool `json:"enabled"` - Configuration map[string]any `json:"configuration"` + AgentCapabilityID string `json:"agent_capability_id"` + AgentID string `json:"agent_id"` + Enabled bool `json:"enabled"` + Configuration map[string]any `json:"configuration"` // PinningMode is 'latest' or 'pinned'. In 'latest' mode the daemon // resolver ignores OssKey/SHA256/CanonicalSpec/Version on this struct // and uses LatestOssKey/LatestSHA256/LatestCanonicalSpec/LatestVersion @@ -333,14 +333,6 @@ func (s *Store) ListWorkspaceMarketplaceInstalls(ctx context.Context, targetWork return out, nil } -func (s *Store) CountInstalls(ctx context.Context, sourceCapabilityID string) (int64, error) { - capabilityUUID, err := uuid(sourceCapabilityID) - if err != nil { - return 0, err - } - return sqlc.New(s.db).CountInstalls(ctx, capabilityUUID) -} - func (s *Store) ListEnabledAgents(ctx context.Context, targetWorkspaceID string, sourceCapabilityID string) ([]EnabledMarketplaceAgentRead, error) { workspaceUUID, err := uuid(targetWorkspaceID) if err != nil { @@ -466,28 +458,16 @@ func (s *Store) UpdateCapability(ctx context.Context, input UpdateCapabilityInpu return updated, nil } -func (s *Store) PublishCapability(ctx context.Context, workspaceID string, capabilityID string) (CapabilityRead, error) { - return s.updateCapabilityMarketplaceState(ctx, workspaceID, capabilityID, "public", nil) -} - -func (s *Store) UnpublishCapability(ctx context.Context, workspaceID string, capabilityID string) (CapabilityRead, error) { - return s.updateCapabilityMarketplaceState(ctx, workspaceID, capabilityID, "workspace", nil) -} - func (s *Store) DeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (CapabilityRead, error) { now := time.Now().UTC() - return s.updateCapabilityMarketplaceState(ctx, workspaceID, capabilityID, "public", &now) + return s.updateCapabilityDeprecation(ctx, workspaceID, capabilityID, &now) } func (s *Store) UndeprecateCapability(ctx context.Context, workspaceID string, capabilityID string) (CapabilityRead, error) { - existing, err := s.GetCapability(ctx, capabilityID) - if err != nil { - return CapabilityRead{}, err - } - return s.updateCapabilityMarketplaceState(ctx, workspaceID, capabilityID, existing.Visibility, nil) + return s.updateCapabilityDeprecation(ctx, workspaceID, capabilityID, nil) } -func (s *Store) updateCapabilityMarketplaceState(ctx context.Context, workspaceID string, capabilityID string, visibility string, deprecatedAt *time.Time) (CapabilityRead, error) { +func (s *Store) updateCapabilityDeprecation(ctx context.Context, workspaceID string, capabilityID string, deprecatedAt *time.Time) (CapabilityRead, error) { workspaceUUID, err := uuid(workspaceID) if err != nil { return CapabilityRead{}, err @@ -500,14 +480,14 @@ func (s *Store) updateCapabilityMarketplaceState(ctx context.Context, workspaceI if deprecatedAt != nil { deprecated = timestamptz(*deprecatedAt) } - row, err := sqlc.New(s.db).UpdateCapabilityMarketplaceState(ctx, sqlc.UpdateCapabilityMarketplaceStateParams{ID: capabilityUUID, WorkspaceID: workspaceUUID, Visibility: normalizeCapabilityVisibility(visibility), DeprecatedAt: deprecated, Now: timestamptz(time.Now().UTC())}) + row, err := sqlc.New(s.db).UpdateCapabilityDeprecation(ctx, sqlc.UpdateCapabilityDeprecationParams{ID: capabilityUUID, WorkspaceID: workspaceUUID, DeprecatedAt: deprecated, Now: timestamptz(time.Now().UTC())}) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return CapabilityRead{}, fmt.Errorf("%w: %s", ErrUnknownCapability, capabilityID) } return CapabilityRead{}, err } - return capabilityFromMarketplaceStateRow(row), nil + return capabilityFromDeprecationRow(row), nil } // SoftDeleteCapability writes capability.deleted_at atomically: the UPDATE carries @@ -1015,7 +995,7 @@ func capabilityFromSoftDeleteRow(row sqlc.SoftDeleteCapabilityRow) CapabilityRea return CapabilityRead{ID: row.ID, WorkspaceID: row.WorkspaceID, Type: row.Type, Name: row.Name, Description: row.Description, Visibility: row.Visibility, Status: row.Status, RequiredCredentials: []RequiredCredential{}, CreatorID: row.CreatorID, CreatedAt: pgTime(row.CreatedAt), UpdatedAt: pgTime(row.UpdatedAt), DeletedAt: pgOptionalTime(row.DeletedAt), DeprecatedAt: pgOptionalTime(row.DeprecatedAt)} } -func capabilityFromMarketplaceStateRow(row sqlc.UpdateCapabilityMarketplaceStateRow) CapabilityRead { +func capabilityFromDeprecationRow(row sqlc.UpdateCapabilityDeprecationRow) CapabilityRead { return CapabilityRead{ID: row.ID, WorkspaceID: row.WorkspaceID, Type: row.Type, Name: row.Name, Description: row.Description, Visibility: row.Visibility, Status: row.Status, RequiredCredentials: []RequiredCredential{}, CreatorID: row.CreatorID, CreatedAt: pgTime(row.CreatedAt), UpdatedAt: pgTime(row.UpdatedAt), DeletedAt: pgOptionalTime(row.DeletedAt), DeprecatedAt: pgOptionalTime(row.DeprecatedAt)} }