import type { IntegrationChip } from "../components/capabilities/integration-view-model"; import { apiErrorFacts, userErrorText } from "../../ui/api-error"; import type { CapabilityCatalogItem, CapabilityKind, CapabilitySource, ConnectionMetadata, ConnectionOwnership, CreateCapabilityInput, SocialConnection, } from "../types"; export type CapabilityFilter = "all" | CapabilityKind; // Human copy for every taxonomy value that used to leak enum slugs into the UI // (doctrine: no internal taxonomy in user-facing labels). Codes appear at most // as fallbacks for values we don't recognize. /** Singular human label for a capability kind ("MCP server", "API"…). */ export function capabilityKindLabel(kind: CapabilityKind): string { switch (kind) { case "mcp": return "MCP server"; case "api": return "API"; case "skill": return "Skill"; case "plugin": return "Plugin"; default: return kind; } } export function capabilityItemKindLabel(item: CapabilityCatalogItem): string { return item.surfaceType === "provider_integration" ? "Integration" : capabilityKindLabel(item.kind); } /** Human label for where a catalog item came from. */ export function capabilitySourceLabel(source: CapabilitySource | string): string { switch (source) { case "built_in": return "Built in"; case "library": return "Curated library"; case "configured": return "Configured"; case "public_registry": case "registry": return "Public registry"; case "manual": return "Added"; default: return String(source).replaceAll("_", " "); } } export type CuratedSkillProvenance = { libraryId: string | null; version: string | null; contentSha256: string | null; sourceCommit: string | null; provenance: string | null; sourceUrl: string | null; license: string | null; documentationUrl: string | null; artifactPath: string | null; status: "enabled" | "not_enabled"; effectiveSelection: string; }; /** * Return the public, immutable identity and effective selection state for a * curated skill. Credentials and other runtime configuration are deliberately * not part of this projection. */ export function curatedSkillProvenance(item: CapabilityCatalogItem): CuratedSkillProvenance | null { if (item.kind !== "skill" || item.source !== "library") { return null; } const metadata = item.metadata; return { libraryId: stringValue(metadata.libraryId), version: stringValue(metadata.version), contentSha256: stringValue(metadata.contentSha256), sourceCommit: stringValue(metadata.sourceCommit), provenance: stringValue(metadata.provenance) ?? item.provenance, sourceUrl: stringValue(metadata.sourceUrl), license: stringValue(metadata.license), documentationUrl: stringValue(metadata.documentationUrl), artifactPath: stringValue(metadata.artifactPath), status: item.enabled ? "enabled" : "not_enabled", effectiveSelection: item.enabled ? (item.enabledReason ?? "enabled") : "not selected", }; } export type CapabilityFormState = { kind: "mcp"; name: string; description: string; category: string; tags: string; endpointUrl: string; homepageUrl: string; installUrl: string; enableAfterAdd: boolean; }; export function emptyCapabilityForm(): CapabilityFormState { return { kind: "mcp", name: "", description: "", category: "custom", tags: "", endpointUrl: "", homepageUrl: "", installUrl: "", enableAfterAdd: true, }; } export function isConnectorCatalogItem(item: CapabilityCatalogItem): boolean { return item.kind === "mcp" || item.kind === "api"; } export function filterCapabilityCatalogItems( items: CapabilityCatalogItem[], filter: CapabilityFilter, query: string, ): CapabilityCatalogItem[] { const normalized = query.trim().toLowerCase(); return items.filter((item) => { if (filter !== "all" && item.kind !== filter) { return false; } if (!normalized) { return true; } return [ item.name, item.description, item.kind, item.source, item.category, item.endpointUrl, item.homepageUrl, item.installUrl, ...item.tags, // Curation flags are presentation facts, not search terms; typing // "official" must not match every curated connector. JSON.stringify(metadataForSearch(item.metadata)), ] .filter(Boolean) .join(" ") .toLowerCase() .includes(normalized); }); } export function capabilityErrorToast( error: unknown, fallbackTitle: string, ): { title: string; description: string } { // The raw "requires credentials; pass them in the enable request 'headers' // field" 422 is an API-only contract detail — never surface it verbatim. The // UI collects credentials in the connect sheet before enabling, so this only // fires as a fallback; translate it to plain language. if (isMissingCredentialsError(error)) { return { title: "Credentials needed", description: "This integration needs to be connected before it can be enabled.", }; } const serverMessage = cleanApiErrorMessage(apiErrorFacts(error).serverMessage ?? ""); const probe = /^MCP capability ".+" could not be enabled because (?:OpenGeni|Opengeni|the workspace) could not initialize (\S+?)\.?(?:\s|$)/u.exec( serverMessage, ); if (probe) { return { title: "Connection failed", description: `Opengeni couldn't connect to ${probe[1]}. Check the endpoint address, then try again.`, }; } // Never the raw "Opengeni API 422: ... Reference: ." string (DESIGN.md // section 6): API errors become advice, an app error keeps its own sentence. return { title: fallbackTitle, description: userErrorText(error) }; } /** * The enable-path 422 raised when a credentialed MCP is enabled without a * connection ref or headers. The connect sheet catches this to route the user * into the credential form instead of showing the raw API string. */ export function isMissingCredentialsError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /requires credentials/i.test(message) && /headers|credential|connection/i.test(message); } export function cleanApiErrorMessage(message: string): string { return message.replace(/^API\s+\d+:\s*/i, "").trim(); } // --- Connect plan ---------------------------------------------------------------------------- // How a catalog item becomes usable, derived entirely from the catalog fields. // Only MCP servers carry credentials; every other kind just tracks/enables. export type RequiredHeaderField = { /** The wire header name the broker injects (e.g. "X-API-Key"). */ name: string; /** Human label for the input — never the word "headers". */ label: string; }; export type CapabilityConnectPlan = | { mode: "setup_required" } | { mode: "enable" } | { mode: "dedicated" } | { mode: "social_oauth"; provider: "x" | "reddit" } | { mode: "fiken_api_token" } | { mode: "oauth"; providerDomain: string; mcpUrl: string | null; requestedScopes: string[]; } | { mode: "api_key"; providerDomain: string; fields: RequiredHeaderField[] }; export function capabilityConnectPlan(item: CapabilityCatalogItem): CapabilityConnectPlan { if ( item.surfaceType === "first_party_social" || (item.surfaceType === "provider_integration" && item.metadata.providerAdapter === "social") ) { const provider = stringValue(item.metadata.provider); if (provider === "x" || provider === "reddit") { return { mode: "social_oauth", provider }; } } // First-party Fiken connects through the verified paste-a-token install // route, not the generic MCP api-key/enable path. if (item.surfaceType === "first_party_fiken") { return { mode: "fiken_api_token" }; } // Every non-MCP concept owns a dedicated lifecycle. The generic catalog // plan is only allowed to connect or enable MCP servers. if (item.kind !== "mcp") { return { mode: "dedicated" }; } const providerDomain = item.providerDomain ?? domainFromUrl(item.mcpUrl ?? item.endpointUrl) ?? ""; if (item.authKind === "oauth2") { return { mode: "oauth", providerDomain, mcpUrl: item.mcpUrl ?? item.endpointUrl, requestedScopes: stringArray(item.metadata.scopesHint), }; } const requiredHeaders = stringArray((item.metadata as Record).requiredHeaders); if (requiredHeaders.length > 0) { return { mode: "api_key", providerDomain, fields: requiredHeaders.map(headerField), }; } // Imported / credential-gated MCPs carry authKind "api_key" (or authModel // "credential_ref") but usually NO explicit requiredHeaders in metadata. They // still need a credential before enable — the API 422s otherwise — so offer the // api-key form with a single generic field instead of dead-ending on Enable // then a bare "credentials needed" notice (the original Supabase-422 UX). if (item.authKind === "api_key" || item.authModel === "credential_ref") { return { mode: "api_key", providerDomain, fields: [GENERIC_API_KEY_FIELD] }; } if ( item.metadata.authDiscovery === "unknown" || item.metadata.authDiscovery === "checking" || ((item.source === "public_registry" || item.source === "manual") && item.authKind == null && item.metadata.authDiscovery !== "none") ) { return { mode: "setup_required" }; } return { mode: "enable" }; } /** * Workspace-shared is the default for a new connection; the explicit * "Connect only for me" choice (and every personal-only connector) overrides it. */ export const DEFAULT_CONNECTION_OWNERSHIP: ConnectionOwnership = "workspace"; /** * The one-click / one-dialog connect fast path offered by a row or tile icon, * derived purely from the catalog item so it can be asserted without React. * * `null` means "no fast path": the full detail sheet owns it. That deliberately * covers dedicated lifecycles (Skills, Plugins, Fiken, social) AND any api-key * connector declaring more than one required header, which a single-field * dialog would silently half-connect. * * Ownership is never assumed: a personal-only connector (official Gmail, * Slack's hosted MCP) resolves to `personal` here exactly as the detail sheet * does, so the fast path can never start a workspace-owned binding for one. */ export type CapabilityQuickConnectPlan = | { mode: "enable" } | { mode: "oauth"; ownership: ConnectionOwnership; providerDomain: string; mcpUrl: string | null; /** True when the connector is not curated-official, so one confirming dialog is shown first. */ confirm: boolean; } | { mode: "api_key"; ownership: ConnectionOwnership; providerDomain: string; field: RequiredHeaderField; }; export function capabilityQuickConnectPlan( item: CapabilityCatalogItem, ): CapabilityQuickConnectPlan | null { const plan = capabilityConnectPlan(item); const ownership = defaultCapabilityConnectionOwnership(item); if (plan.mode === "enable") return { mode: "enable" }; if (plan.mode === "oauth") { return { mode: "oauth", ownership, providerDomain: plan.providerDomain, mcpUrl: plan.mcpUrl, confirm: !capabilityCuration(item).official, }; } if (plan.mode === "api_key") { // More than one required header cannot be collected by the single-field // quick dialog; hand the whole connector to the detail sheet instead of // storing half its credential. if (plan.fields.length > 1) return null; return { mode: "api_key", ownership, providerDomain: plan.providerDomain, field: plan.fields[0] ?? GENERIC_API_KEY_FIELD, }; } return null; } /** * The stored credential for an api-key connect. Keyed by the field's WIRE * header name (what the broker injects), never by its human label - "API key" * is not even a legal HTTP header token, so keying by the label silently * stores an unusable credential that only fails later as a 401. */ export function apiKeyCredential( field: RequiredHeaderField, value: string, ): { headers: Record } { return { headers: { [field.name]: value } }; } export function defaultCapabilityConnectionOwnership( item: CapabilityCatalogItem, ): ConnectionOwnership { const preferred = item.metadata.defaultConnectionOwnership ?? recordValue(item.metadata.oauthProfile)?.defaultOwnership; return preferred === "personal" ? "personal" : DEFAULT_CONNECTION_OWNERSHIP; } /** * Curation facts written by the catalog import from `data/catalog/curated.json`. * `curated` records that a row has a reviewed presentation overlay; `featured` * means we chose to promote it. `official` is the separate evidence-backed * claim that the provider publishes the server on its own domain. None is a * security review and must never be rendered as "reviewed" or "verified". */ export function capabilityCuration(item: Pick): { curated: boolean; featured: boolean; official: boolean; } { const raw = item.metadata.curation; const record = raw && typeof raw === "object" && !Array.isArray(raw) ? (raw as Record) : null; return { curated: record?.curated === true, featured: record?.featured === true, official: record?.official === true, }; } /** Stable order: featured first, everything else in its existing order. */ export function sortFeaturedFirst>( items: readonly T[], ): T[] { const featured: T[] = []; const rest: T[] = []; for (const item of items) { (capabilityCuration(item).featured ? featured : rest).push(item); } return [...featured, ...rest]; } function metadataForSearch(metadata: Record): Record { if (!("curation" in metadata)) return metadata; const { curation: _curation, ...rest } = metadata; return rest; } const CATEGORY_LABELS: Readonly> = { analytics: "Analytics", automation: "Automation", communication: "Communication", configured: "Configured", data: "Data", design: "Design", "developer-tools": "Developer tools", files: "Files", finance: "Finance", integrations: "Integrations", marketing: "Marketing", productivity: "Productivity", "project-management": "Project management", "public-mcp": "Public MCP", sales: "Sales", scheduling: "Scheduling", "social-media": "Social media", "source-control": "Source control", web: "Web", }; /** Human label for a catalog category slug; unknown slugs are title-cased. */ export function capabilityCategoryLabel(category: string | null | undefined): string | null { if (!category || category === "custom") return null; const known = CATEGORY_LABELS[category]; if (known) return known; return category .split(/[-_\s]+/) .filter(Boolean) .map((word, index) => index === 0 ? word.charAt(0).toUpperCase() + word.slice(1) : word.toLowerCase(), ) .join(" "); } /** Short auth hint for a tile ("OAuth" / "API key"), or null when none applies. */ export function capabilityAuthHint(item: CapabilityCatalogItem): string | null { const plan = capabilityConnectPlan(item); if (plan.mode === "oauth" || plan.mode === "social_oauth") return "OAuth"; if (plan.mode === "api_key" || plan.mode === "fiken_api_token") return "API key"; return null; } /** * The same four-state chip Integrations rows use, derived for a Connectors * catalog item so both surfaces render the identical compact state indicator. * `health` is optional: tiles that only ever show not-yet-enabled items (the * Browse grid, registry results) can omit it since every item there is * necessarily "Not connected". */ export function capabilityStateChip( item: Pick, health?: ConnectionHealth, ): IntegrationChip { if (!item.enabled) return { label: "Not connected", tone: "idle" }; if (health?.state === "unverified") return { label: "Loading", tone: "plain" }; if (health?.state === "attention") return { label: "Needs attention", tone: "warn" }; return { label: "Connected", tone: "ok" }; } export function preferredSocialConnection( connections: SocialConnection[], provider: "x" | "reddit", ): SocialConnection | null { return ( socialConnectionsForOwnership( connections.filter((connection) => connection.provider === provider), )[0] ?? null ); } export function socialConnectionsForOwnership( connections: SocialConnection[], ownership?: ConnectionOwnership, ): SocialConnection[] { const statusRank = (status: SocialConnection["status"]): number => status === "connected" ? 0 : status === "needs_reauth" ? 1 : 2; return connections .filter((connection) => ownership === undefined || connection.ownership === ownership) .sort( (left, right) => statusRank(left.status) - statusRank(right.status) || right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id), ); } function headerField(name: string): RequiredHeaderField { return { name, label: headerFieldLabel(name) }; } const headerLabelAcronyms = new Set(["API", "URL", "JWT", "SSO", "OTP", "PAT"]); function headerFieldLabel(name: string): string { if (/api[\s_-]?key/i.test(name) || /^authorization$/i.test(name)) { return "API key"; } const cleaned = name.replace(/^x-/i, "").replaceAll(/[-_]+/g, " ").trim(); // Sentence-case all-caps words so DD-APPLICATION-KEY reads "DD Application // Key"; two-letter vendor prefixes and real acronyms keep their caps. const titled = cleaned .split(/\s+/) .map((word) => word === word.toUpperCase() && word.length > 2 && !headerLabelAcronyms.has(word) ? word[0] + word.slice(1).toLowerCase() : word.replace(/^\w/, (character) => character.toUpperCase()), ) .join(" "); return titled || "Credential"; } export function domainFromUrl(url: string | null): string | null { if (!url) return null; try { return new URL(url).hostname || null; } catch { return null; } } /** * The connection ref the API records on an enabled catalog item's installation * — the authoritative link between the installation and its connection row. * `null` means the item was enabled without a connection (headers-enabled or * credential-free), which is healthy. */ export function installedConnectionRef( item: CapabilityCatalogItem, ): CapabilityCatalogItem["connectionRef"] { return item.connectionRef ?? null; } /** * The detail sheet's selection: the id it's bound to, whether it came from the * public registry (drives persist-on-connect), whether the snapshot is an * authoritative fallback, and the snapshot taken at open time. * * `snapshotFallback` is true when the id may legitimately be absent from the live * `items` list even though the row exists — a registry result not yet persisted, * OR a just-created custom item opened before (or across a failed) refresh. For * those the snapshot renders until the live row appears; for a normal catalog * selection it stays false so a vanished row closes the sheet instead of ghosting. */ export type SheetSelection = { id: string; registry: boolean; snapshotFallback: boolean; snapshot: CapabilityCatalogItem; }; /** * The item the detail sheet should render, derived from the LIVE catalog by id so * a mutation elsewhere (disable, refresh) re-derives it instead of leaving a stale * snapshot. A snapshot-fallback selection (registry result, or freshly created and * not yet in `items`) falls back to its snapshot; any other selection absent from * the catalog resolves to null so the caller closes the sheet rather than ghost. */ export function resolveSheetItem( selected: SheetSelection | null, items: CapabilityCatalogItem[], ): CapabilityCatalogItem | null { if (!selected) return null; return ( items.find((entry) => entry.id === selected.id) ?? (selected.snapshotFallback ? selected.snapshot : null) ); } export type ConnectionHealth = | { state: "none" } | { state: "unverified" } | { state: "connected"; connection: ConnectionMetadata } | { state: "attention"; connection: ConnectionMetadata | null }; /** * Health of an enabled item. Workspace bindings resolve by their exact id; * subject bindings intentionally store no id and resolve only among the current * caller's visible personal rows by provider and kind. `loaded` distinguishes a * failed connection-list read from a genuinely missing or inactive row. * Host bindings are not represented in that native list and therefore remain * enabled without a native health or repair action. * * The `loaded` gate matters because listConnections needs a distinct * `connections:read` scope: a grant with catalog access but not that sctracking-403s, * and a failed load must not read as "every connection was deleted" and paint * healthy integrations amber. */ /** * The workspace-shared Fiken connection the first-party fiken tools resolve: * usable status first, then newest update. Mirrors the server-side selection. */ export function fikenWorkspaceConnection( connections: ConnectionMetadata[], ): ConnectionMetadata | null { const statusRank = (status: ConnectionMetadata["status"]): number => status === "active" ? 0 : status === "needs_reauth" ? 1 : 2; return ( connections .filter( (connection) => connection.subjectId === null && // Both verified lanes: pasted personal API token and registered-app // OAuth. Must stay in sync with `isFikenConnection` in @opengeni/core. (connection.kind === "api_key" || connection.kind === "oauth2") && normalizeProviderDomain(connection.providerDomain) === "fiken.no" && connection.metadata.credentialRole === "fiken_api_token", ) .sort( (left, right) => statusRank(left.status) - statusRank(right.status) || right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id), )[0] ?? null ); } export function connectionHealth( item: CapabilityCatalogItem, connections: ConnectionMetadata[], loaded: boolean, ): ConnectionHealth { // The Fiken tile carries no installation connectionRef; its health is the // workspace-shared Fiken row itself. if (item.surfaceType === "first_party_fiken") { if (!loaded) return { state: "unverified" }; const connection = fikenWorkspaceConnection(connections); if (!connection) return { state: "none" }; if (connection.status !== "active") return { state: "attention", connection }; return { state: "connected", connection }; } const ref = installedConnectionRef(item); if (!ref) return { state: "none" }; if (ref.authoritySource === "host") return { state: "none" }; if (!loaded) return { state: "unverified" }; if (ref.accountSelection === "all_eligible" && !ref.connectionId) { const matching = connections.filter( (candidate) => candidate.kind === ref.kind && normalizeProviderDomain(candidate.providerDomain) === normalizeProviderDomain(ref.providerDomain), ); const connection = matching.find((candidate) => candidate.status === "active") ?? matching[0] ?? null; return connection?.status === "active" ? { state: "connected", connection } : { state: "attention", connection }; } const connection = ref.subjectScope === "subject" ? (connections.find( (candidate) => candidate.subjectId !== null && normalizeProviderDomain(candidate.providerDomain) === normalizeProviderDomain(ref.providerDomain) && candidate.kind === ref.kind, ) ?? null) : (connections.find((candidate) => candidate.id === ref.connectionId) ?? null); if (!connection || connection.status !== "active") return { state: "attention", connection }; return { state: "connected", connection }; } /** * How to repair an enabled item whose connection needs attention, driven by the * installation's OWN connectionRef.kind — NOT the current catalog plan, which can * drift (an item enabled via a connection may later read as plain "enable" in the * catalog). Returns null when there's nothing to repair. `connectionId` is the * surviving row to reuse, or null when it was deleted (repair mints a fresh one). */ export type ReconnectPlan = | { kind: "oauth"; connectionId: string | null; ownership: ConnectionOwnership; } | { kind: "api_key"; connectionId: string | null; ownership: ConnectionOwnership; }; export function capabilityReconnectPlan( item: CapabilityCatalogItem, health: ConnectionHealth, ): ReconnectPlan | null { if (!item.enabled || health.state !== "attention") return null; const ref = item.connectionRef; if (!ref) return null; if (ref.authoritySource === "host") return null; const connectionId = health.connection?.id ?? null; return ref.kind === "oauth2" ? { kind: "oauth", connectionId, ownership: ref.subjectScope === "subject" ? "personal" : "workspace", } : { kind: "api_key", connectionId, ownership: ref.subjectScope === "subject" ? "personal" : "workspace", }; } /** The generic single credential field for an api-key reconnect when the catalog * no longer supplies requiredHeaders (drift). The wire name defaults to the most * common bearer header; the label never leaks the word "headers". */ export const GENERIC_API_KEY_FIELD: RequiredHeaderField = { name: "Authorization", label: "API key", }; /** * The workspace-shared connection (subjectId null) for a provider domain, used * to reuse an existing row on an API-key connect retry instead of creating a * duplicate. Both sides normalized (the catalog domain is raw; the row's is * API-canonicalized) so the match holds. */ export function workspaceConnectionForDomain( connections: ConnectionMetadata[], providerDomain: string, ): ConnectionMetadata | null { const target = normalizeProviderDomain(providerDomain); return ( connections.find( (connection) => connection.subjectId === null && normalizeProviderDomain(connection.providerDomain) === target, ) ?? null ); } /** * The existing workspace connection id to reuse on an API-key connect/retry * instead of creating a duplicate: the installation's own ref first, then a * workspace-shared row for the domain. `null` → none exists, so mint a new one. * Keeps a retry after a create-then-enable failure from piling up dead rows. */ export function connectionToReuseForApiKey( item: CapabilityCatalogItem, connections: ConnectionMetadata[], providerDomain: string, ownership: ConnectionOwnership = "workspace", ): string | null { const target = normalizeProviderDomain(providerDomain); const matching = connections.find( (connection) => (ownership === "personal" ? connection.subjectId !== null : connection.subjectId === null) && connection.kind === "api_key" && normalizeProviderDomain(connection.providerDomain) === target, ); return ( (ownership === "workspace" ? item.connectionRef?.connectionId : null) ?? matching?.id ?? null ); } /** * Public-registry results to show for the CURRENT query. Registry hits are held * in state after an explicit search; once the user edits the query they no * longer describe what's on screen, so gate them on the searched term matching * the live (trimmed) query — otherwise a stale search renders against a new one. */ export function registryResultsForQuery( query: string, searched: string | null, results: CapabilityCatalogItem[], ): CapabilityCatalogItem[] { return searched !== null && searched === query.trim() ? results : []; } /** * Client mirror of the API's `canonicalProviderDomain` (apps/api oauth-client): * trim, lowercase, strip a single leading "www.". Used only to match/dedup rows * client-side — connectionRefs sent to the API are always built from the * connection row the API returns, never from a domain the client canonicalized. */ export function normalizeProviderDomain(domain: string): string { return domain .trim() .toLowerCase() .replace(/^www\./, ""); } /** * What to do when an OAuth round-trip returns, once the item has been resolved * from a FRESH catalog fetch (a just-created registry item may be absent from * the pre-redirect snapshot). Pure so the decision is unit-testable: * - missing: success but the item is no longer in the catalog → can't enable. * - no_connection: success but no connection id came back → can't enable. * - reconnect: already enabled and OAuth refreshed the SAME connection the * installation already references → nothing to re-enable. * - enable: a fresh connect, OR a reconnect whose old row was gone so OAuth * minted a new one → (re-)enable to point the installation at it. */ export type OAuthResumeAction = "missing" | "no_connection" | "reconnect" | "enable"; export function oauthResumeAction( item: CapabilityCatalogItem | null, connectionId: string | null, ): OAuthResumeAction { if (!item) return "missing"; if (!connectionId) return "no_connection"; // Subject-owned installations are generic by design: whether OAuth refreshed // or recreated the caller's row, the existing provider/kind binding remains // valid and must never be rewritten with a private UUID. if (item.enabled && item.connectionRef?.subjectScope === "subject") return "reconnect"; if (item.enabled && item.connectionRef?.connectionId === connectionId) return "reconnect"; return "enable"; } /** Generic per-subject OAuth binding; never persist the returned personal row id. */ export function subjectOAuthConnectionRef(providerDomain: string): { providerDomain: string; kind: "oauth2"; subjectScope: "subject"; } { return { providerDomain, kind: "oauth2", subjectScope: "subject" }; } /** New catalog connects opt into all eligible accounts; reconnects preserve * existing exact refs and retain an explicitly installed selector. */ export function catalogConnectionAccountSelection( item: Pick, ): "all_eligible" | undefined { if (item.connectionRef?.connectionId) return undefined; return !item.enabled || item.connectionRef?.accountSelection === "all_eligible" ? "all_eligible" : undefined; } /** Build the capability binding that matches the OAuth row's explicit ownership. */ export function oauthConnectionRef( ownership: ConnectionOwnership, connectionId: string, providerDomain: string, accountSelection?: "all_eligible", ): | ReturnType | { providerDomain: string; kind: "oauth2"; subjectScope: "workspace" | "subject"; accountSelection: "all_eligible"; } | { connectionId: string; providerDomain: string; kind: "oauth2"; subjectScope: "workspace"; } { if (accountSelection) return { providerDomain, kind: "oauth2", subjectScope: ownership === "personal" ? "subject" : "workspace", accountSelection, }; return ownership === "personal" ? subjectOAuthConnectionRef(providerDomain) : { connectionId, providerDomain, kind: "oauth2", subjectScope: "workspace", }; } export function apiKeyConnectionRef( ownership: ConnectionOwnership, connectionId: string, providerDomain: string, accountSelection?: "all_eligible", ): | { providerDomain: string; kind: "api_key"; subjectScope: "subject" } | { providerDomain: string; kind: "api_key"; subjectScope: "workspace" | "subject"; accountSelection: "all_eligible"; } | { connectionId: string; providerDomain: string; kind: "api_key"; subjectScope: "workspace"; } { if (accountSelection) return { providerDomain, kind: "api_key", subjectScope: ownership === "personal" ? "subject" : "workspace", accountSelection, }; return ownership === "personal" ? { providerDomain, kind: "api_key", subjectScope: "subject" } : { connectionId, providerDomain, kind: "api_key", subjectScope: "workspace", }; } export function oauthConnectionOwnership(value: string | null): ConnectionOwnership | null { return value === "workspace" || value === "personal" ? value : null; } /** First one or two initials for the logo monogram fallback. */ export function capabilityMonogram(name: string): string { const words = name.trim().split(/\s+/).filter(Boolean); if (words.length === 0) return "?"; if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase(); return (words[0]![0]! + words[1]![0]!).toUpperCase(); } export function scheduleSummaryForMetadata(value: unknown): string { const schedule = recordValue(value); if (!schedule) { return "Custom schedule"; } const type = stringValue(schedule.type); if (type === "calendar") { const hour = numberValue(schedule.hour); const minute = numberValue(schedule.minute); const timeZone = stringValue(schedule.timeZone) ?? "UTC"; if (hour !== null && minute !== null) { return `Calendar at ${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")} ${timeZone}`; } return `Calendar schedule in ${timeZone}`; } if (type === "interval") { const everySeconds = numberValue(schedule.everySeconds); return everySeconds ? `Every ${everySeconds} seconds` : "Interval schedule"; } if (type === "once") { return stringValue(schedule.runAt) ? `Once at ${stringValue(schedule.runAt)}` : "One-time schedule"; } return type ? `${type} schedule` : "Custom schedule"; } function recordValue(value: unknown): Record | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.map(stringValue).filter((entry): entry is string => Boolean(entry)) : []; } function stringValue(value: unknown): string | null { return typeof value === "string" && value.trim() ? value.trim() : null; } function numberValue(value: unknown): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } export function capabilityCounts(items: CapabilityCatalogItem[]): Record { return { all: items.length, mcp: items.filter((item) => item.kind === "mcp").length, api: items.filter((item) => item.kind === "api").length, skill: items.filter((item) => item.kind === "skill").length, plugin: items.filter((item) => item.kind === "plugin").length, }; } export function capabilityFilterLabel(kind: CapabilityFilter): string { switch (kind) { case "all": return "All"; case "mcp": return "MCP servers"; case "api": return "APIs"; case "skill": return "Skills"; case "plugin": return "Plugins"; default: return kind; } } export function createInputFromCatalogItem(item: CapabilityCatalogItem): CreateCapabilityInput { if (item.kind !== "mcp") { throw new Error( `${capabilityKindLabel(item.kind)} catalog items must use their dedicated install flow`, ); } return { id: item.id, kind: item.kind, source: item.source, name: item.name, ...(item.description ? { description: item.description } : {}), category: item.category, tags: item.tags, ...(item.homepageUrl ? { homepageUrl: item.homepageUrl } : {}), ...(item.endpointUrl ? { endpointUrl: item.endpointUrl } : {}), ...(item.installUrl ? { installUrl: item.installUrl } : {}), ...(item.authModel ? { authModel: item.authModel } : {}), metadata: item.metadata, }; } /** Validate the MCP-only "Add custom" dialog. */ export function capabilityFormError(form: CapabilityFormState): string | null { if (!form.name.trim()) { return "Give it a name."; } const url = form.endpointUrl.trim(); if (!url) { return "Enter the MCP server URL."; } if (!isLikelyUrl(url)) { return "Enter a valid URL, including https://."; } return null; } function isLikelyUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } export function capabilityInputFromForm(form: CapabilityFormState): CreateCapabilityInput | null { const name = form.name.trim(); if (!name) { return null; } return { kind: form.kind, source: "manual", name, ...(form.description.trim() ? { description: form.description.trim() } : {}), category: form.category.trim() || "custom", tags: form.tags .split(",") .map((tag) => tag.trim()) .filter(Boolean), ...(form.endpointUrl.trim() ? { endpointUrl: form.endpointUrl.trim() } : {}), ...(form.homepageUrl.trim() ? { homepageUrl: form.homepageUrl.trim() } : {}), ...(form.installUrl.trim() ? { installUrl: form.installUrl.trim() } : {}), }; }