From c2dc72976996a29d177790b2ccdf479372785880 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 21 Jul 2026 17:35:41 +0000 Subject: [PATCH] feat(connections): cut gallery over to AppDefinition --- .../shared/src/app-definitions-url.test.ts | 32 +++ packages/shared/src/app-definitions.ts | 67 +++++ .../shared/src/app-definitions/zapier.json | 2 +- packages/shared/src/index.ts | 20 +- packages/shared/src/tool-app-gallery.test.ts | 32 --- packages/shared/src/tool-app-gallery.ts | 243 ------------------ .../shared/src/validators/app-definition.ts | 2 +- .../src/__tests__/tool-access-service.test.ts | 35 ++- server/src/routes/tool-access.ts | 12 +- server/src/services/tool-access.ts | 88 ++++--- ui/src/api/tools.ts | 4 +- ui/src/components/AppConnectionSidebar.tsx | 12 +- ui/src/pages/apps/AppDetail.tsx | 10 +- ui/src/pages/apps/AppNotConnected.tsx | 4 +- ui/src/pages/apps/AppsConnect.test.tsx | 68 +---- ui/src/pages/apps/AppsConnect.tsx | 67 ++--- ui/src/pages/apps/Browse.tsx | 24 +- ui/src/pages/apps/Connections.tsx | 10 +- .../pages/apps/app-detail/AdvancedPanel.tsx | 17 +- ui/src/pages/apps/app-detail/SetupPanel.tsx | 4 +- ui/src/pages/apps/app-detail/types.ts | 4 +- .../apps-connect-discoverability.stories.tsx | 6 +- .../stories/apps-connect-name.stories.tsx | 6 +- 23 files changed, 295 insertions(+), 474 deletions(-) create mode 100644 packages/shared/src/app-definitions-url.test.ts create mode 100644 packages/shared/src/app-definitions.ts delete mode 100644 packages/shared/src/tool-app-gallery.test.ts delete mode 100644 packages/shared/src/tool-app-gallery.ts diff --git a/packages/shared/src/app-definitions-url.test.ts b/packages/shared/src/app-definitions-url.test.ts new file mode 100644 index 00000000000..24386db0a79 --- /dev/null +++ b/packages/shared/src/app-definitions-url.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + getAppDefinitionForUrl, + CONNECTABLE_APP_DEFINITIONS, +} from "./app-definitions.js"; + +describe("tool app gallery URL matching", () => { + it("matches pasted links against gallery URL patterns", () => { + expect(getAppDefinitionForUrl("https://mcp.zapier.com/api/mcp")?.slug).toBe("zapier"); + expect(getAppDefinitionForUrl("https://api.githubcopilot.com/mcp/")?.slug).toBe("github"); + expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets"); + }); + + it("returns null for invalid or unknown links", () => { + expect(getAppDefinitionForUrl("not a url")).toBeNull(); + expect(getAppDefinitionForUrl("https://example.com/mcp")).toBeNull(); + expect(getAppDefinitionForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull(); + }); + + it("does not list Google Drive until its OAuth client flow is supported", () => { + expect(CONNECTABLE_APP_DEFINITIONS.map((app) => app.slug)).not.toContain("google-drive"); + expect(getAppDefinitionForUrl("https://mcp.google.com/drive")).toBeNull(); + }); + + it("keeps every gallery entry reachable through at least one pattern", () => { + for (const app of CONNECTABLE_APP_DEFINITIONS) { + const example = app.urlPatterns[0]?.replace("*", "example"); + expect(example, `${app.slug} has a pattern`).toBeTruthy(); + expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug); + } + }); +}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts new file mode 100644 index 00000000000..ba38d87e7f7 --- /dev/null +++ b/packages/shared/src/app-definitions.ts @@ -0,0 +1,67 @@ +import { APP_DEFINITIONS } from "./app-definitions.generated.js"; +import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js"; +import type { ToolConnectionOwnership } from "./types/tool-access.js"; + +const CONNECTABLE_APP_SLUGS = new Set([ + "zapier", + "github", + "slack", + "notion", + "linear", + "google-sheets", + "context7", +]); + +export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) => + CONNECTABLE_APP_SLUGS.has(app.slug) +); + +export const DEFAULT_OWNERSHIP_AVAILABILITY: Record = { + platform_shared: false, + platform_provisioned: false, + customer: true, + dcr: true, +}; + +export function getConnectableAppDefinition(slug: string): AppDefinition | null { + return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null; +} + +function wildcardPatternToRegExp(pattern: string): RegExp { + const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i"); +} + +export function getAppDefinitionForUrl( + link: string, + definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS, +): AppDefinition | null { + let normalized: string; + try { + normalized = new URL(link.trim()).toString(); + } catch { + return null; + } + return definitions.find((app) => + app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized)) + ) ?? null; +} + +export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null { + const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY; + return app.methods.find((method) => + method.ownershipModes.some((ownership) => availability[ownership] !== false) + ) ?? null; +} + +export function credentialConfigPath(field: FieldDef): string { + return `credentials.${field.key}`; +} + +export function recommendedDefaultsForApp(app: AppDefinition): Record { + const method = getAvailableConnectionMethod(app); + return { + access: "all_agents", + askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"], + }; +} diff --git a/packages/shared/src/app-definitions/zapier.json b/packages/shared/src/app-definitions/zapier.json index 780fa9df6b3..016771b6542 100644 --- a/packages/shared/src/app-definitions/zapier.json +++ b/packages/shared/src/app-definitions/zapier.json @@ -30,7 +30,7 @@ "credentialFields": [ { "key": "authorization", - "label": "Zapier token", + "label": "Zapier MCP token", "type": "password", "required": true, "placeholder": "Paste your Zapier token", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ad27a4e0248..488d2c4ef6c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -143,15 +143,14 @@ export { type SourceTrustMetadata, } from "./trust-policy.js"; export { - TOOL_APP_GALLERY, - getToolAppGalleryEntry, - getToolAppGalleryEntryForUrl, - type AppGalleryAuthKind, - type AppGalleryCredentialField, - type AppGalleryEntry, - type AppGalleryKey, - type AppGalleryTransportTemplate, -} from "./tool-app-gallery.js"; + CONNECTABLE_APP_DEFINITIONS, + DEFAULT_OWNERSHIP_AVAILABILITY, + credentialConfigPath, + getAppDefinitionForUrl, + getAvailableConnectionMethod, + getConnectableAppDefinition, + recommendedDefaultsForApp, +} from "./app-definitions.js"; export { APP_DEFINITIONS } from "./app-definitions.generated.js"; export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js"; export { @@ -1259,6 +1258,9 @@ export type { PluginJobRecord, PluginJobRunRecord, PluginWebhookDeliveryRecord, + AppDefinition, + ConnectionMethodDef, + FieldDef, QuotaWindow, ProviderQuotaResult, } from "./types/index.js"; diff --git a/packages/shared/src/tool-app-gallery.test.ts b/packages/shared/src/tool-app-gallery.test.ts deleted file mode 100644 index 21318066531..00000000000 --- a/packages/shared/src/tool-app-gallery.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - getToolAppGalleryEntryForUrl, - TOOL_APP_GALLERY, -} from "./tool-app-gallery.js"; - -describe("tool app gallery URL matching", () => { - it("matches pasted links against gallery URL patterns", () => { - expect(getToolAppGalleryEntryForUrl("https://mcp.zapier.com/api/mcp")?.key).toBe("zapier"); - expect(getToolAppGalleryEntryForUrl("https://api.githubcopilot.com/mcp/")?.key).toBe("github"); - expect(getToolAppGalleryEntryForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.key).toBe("google-sheets"); - }); - - it("returns null for invalid or unknown links", () => { - expect(getToolAppGalleryEntryForUrl("not a url")).toBeNull(); - expect(getToolAppGalleryEntryForUrl("https://example.com/mcp")).toBeNull(); - expect(getToolAppGalleryEntryForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull(); - }); - - it("does not list Google Drive until its OAuth client flow is supported", () => { - expect(TOOL_APP_GALLERY.map((entry) => entry.key)).not.toContain("google-drive"); - expect(getToolAppGalleryEntryForUrl("https://mcp.google.com/drive")).toBeNull(); - }); - - it("keeps every gallery entry reachable through at least one pattern", () => { - for (const entry of TOOL_APP_GALLERY) { - const example = entry.urlPatterns[0]?.replace("*", "example"); - expect(example, `${entry.key} has a pattern`).toBeTruthy(); - expect(getToolAppGalleryEntryForUrl(example!)?.key).toBe(entry.key); - } - }); -}); diff --git a/packages/shared/src/tool-app-gallery.ts b/packages/shared/src/tool-app-gallery.ts deleted file mode 100644 index bb54fcb360e..00000000000 --- a/packages/shared/src/tool-app-gallery.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { ToolConnectionTransport } from "./types/tool-access.js"; - -export type AppGalleryAuthKind = "oauth" | "api_key" | "none"; - -export interface AppGalleryCredentialField { - label: string; - configPath: string; - helpUrl: string; - required?: boolean; - placement?: "header" | "env"; - key?: string; - prefix?: string | null; -} - -export type AppGalleryTransportTemplate = - | { - transport: Extract; - url: string; - } - | { - transport: Extract; - templateKey: string; - }; - -export interface AppGalleryEntry { - key: string; - name: string; - logoUrl: string; - tagline: string; - description?: string; - authKind: AppGalleryAuthKind; - transportTemplate: AppGalleryTransportTemplate; - credentialFields: AppGalleryCredentialField[]; - recommendedDefaults: Record; - urlPatterns: string[]; - availability?: { - available: boolean; - reason?: string | null; - robotEmail?: string | null; - }; - oauth?: { - provider: string; - scopes: string[]; - tokenUrl?: string | null; - metadataUrl?: string | null; - authorizationUrl?: string | null; - }; -} - -const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`; - -export const TOOL_APP_GALLERY = [ - { - key: "zapier", - name: "Zapier", - logoUrl: favicon("zapier.com"), - tagline: "Connect Zapier-hosted actions to your Paperclip agents.", - description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.", - authKind: "api_key", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.zapier.com/api/mcp", - }, - credentialFields: [ - { - label: "Zapier MCP token", - configPath: "credentials.authorization", - helpUrl: "https://zapier.com/app/settings/authorizations", - required: true, - placement: "header", - key: "Authorization", - prefix: "Bearer ", - }, - ], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.zapier.com/*"], - }, - { - key: "github", - name: "GitHub", - logoUrl: favicon("github.com"), - tagline: "Read and manage GitHub issues and pull requests.", - description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.", - authKind: "api_key", - transportTemplate: { - transport: "mcp_remote", - url: "https://api.githubcopilot.com/mcp/", - }, - credentialFields: [ - { - label: "GitHub token", - configPath: "credentials.authorization", - helpUrl: "https://github.com/settings/tokens", - required: true, - placement: "header", - key: "Authorization", - prefix: "Bearer ", - }, - ], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://api.githubcopilot.com/mcp/*"], - }, - { - key: "slack", - name: "Slack", - logoUrl: favicon("slack.com"), - tagline: "Search channels and coordinate Slack actions.", - description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.slack.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.slack.com/*"], - oauth: { - provider: "slack", - scopes: ["channels:read", "chat:write", "search:read"], - authorizationUrl: "https://slack.com/oauth/v2/authorize", - tokenUrl: "https://slack.com/api/oauth.v2.access", - }, - }, - { - key: "notion", - name: "Notion", - logoUrl: favicon("notion.so"), - tagline: "Search and update Notion workspace content.", - description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.notion.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.notion.com/*"], - oauth: { - provider: "notion", - scopes: ["read_content", "update_content"], - authorizationUrl: "https://api.notion.com/v1/oauth/authorize", - tokenUrl: "https://api.notion.com/v1/oauth/token", - }, - }, - { - key: "linear", - name: "Linear", - logoUrl: favicon("linear.app"), - tagline: "Read and update Linear issues from agent workflows.", - description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.", - authKind: "oauth", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.linear.app/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://mcp.linear.app/*"], - oauth: { - provider: "linear", - scopes: ["read", "write"], - authorizationUrl: "https://linear.app/oauth/authorize", - tokenUrl: "https://api.linear.app/oauth/token", - }, - }, - { - key: "google-sheets", - name: "Google Sheets", - logoUrl: favicon("sheets.google.com"), - tagline: "Read and update selected spreadsheets.", - description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.", - authKind: "none", - transportTemplate: { - transport: "local_stdio", - templateKey: "paperclip.google-sheets", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: ["write", "destructive"], - }, - urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"], - }, - { - key: "context7", - name: "Context7", - logoUrl: favicon("context7.com"), - tagline: "Fetch up-to-date library documentation with Context7.", - description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.", - authKind: "none", - transportTemplate: { - transport: "mcp_remote", - url: "https://mcp.context7.com/mcp", - }, - credentialFields: [], - recommendedDefaults: { - access: "all_agents", - askFirstRiskLevels: [], - }, - urlPatterns: ["https://mcp.context7.com/*"], - }, -] satisfies AppGalleryEntry[]; - -export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"]; - -export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null { - return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null; -} - -function wildcardPatternToRegExp(pattern: string): RegExp { - const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*"); - return new RegExp(`^${escaped}$`, "i"); -} - -export function getToolAppGalleryEntryForUrl( - link: string, - entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY, -): AppGalleryEntry | null { - let normalized: string; - try { - normalized = new URL(link.trim()).toString(); - } catch { - return null; - } - return entries.find((entry) => - entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized)) - ) ?? null; -} diff --git a/packages/shared/src/validators/app-definition.ts b/packages/shared/src/validators/app-definition.ts index aa91e7c3f29..4aa559b2ae5 100644 --- a/packages/shared/src/validators/app-definition.ts +++ b/packages/shared/src/validators/app-definition.ts @@ -2,5 +2,5 @@ import { z } from "zod"; import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js"; const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})}); export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})}); -export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional()}); +export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()}); export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})}); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index 10fbd18f8b3..f5b38979365 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -2285,7 +2285,7 @@ describeEmbeddedPostgres("tool access service", () => { const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`); expect(res.status).toBe(200); - expect(res.body.apps.map((entry: { key: string }) => entry.key)).toEqual([ + expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual([ "zapier", "github", "slack", @@ -2294,23 +2294,36 @@ describeEmbeddedPostgres("tool access service", () => { "google-sheets", "context7", ]); - expect(res.body.apps.map((entry: { key: string }) => entry.key)).not.toContain("google-drive"); + expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive"); expect(res.body.apps).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: "slack", - authKind: "oauth", - oauth: expect.objectContaining({ provider: "slack" }), + slug: "slack", + methods: expect.arrayContaining([ + expect.objectContaining({ + auth: "oauth", + defaults: expect.objectContaining({ authorizationEndpoint: "https://slack.com/oauth/v2/authorize" }), + }), + ]), + ownershipAvailability: expect.objectContaining({ + platform_shared: false, + platform_provisioned: false, + customer: true, + dcr: true, + }), }), expect.objectContaining({ - key: "zapier", - credentialFields: [ + slug: "zapier", + methods: expect.arrayContaining([ expect.objectContaining({ - configPath: "credentials.authorization", - placement: "header", - key: "Authorization", + credentialFields: [expect.objectContaining({ key: "authorization" })], + keyPlacement: expect.objectContaining({ location: "header", name: "Authorization" }), }), - ], + ]), + }), + expect.objectContaining({ + slug: "google-sheets", + availability: expect.objectContaining({ available: false }), }), ]), ); diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 3aa75182c9c..7915afd4909 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -3,7 +3,8 @@ import type { Db } from "@paperclipai/db"; import { agents, companies } from "@paperclipai/db"; import { eq } from "drizzle-orm"; import { - TOOL_APP_GALLERY, + CONNECTABLE_APP_DEFINITIONS, + DEFAULT_OWNERSHIP_AVAILABILITY, TOOL_ACTION_REQUEST_STATUSES, type DeploymentExposure, type DeploymentMode, @@ -218,15 +219,16 @@ export function toolAccessRoutes( assertCompanyAccess(req, companyId); const googleSheetsAvailability = googleSheetsRobotEmailFromEnv(); res.json({ - apps: TOOL_APP_GALLERY.map((entry) => - entry.key === "google-sheets" + apps: CONNECTABLE_APP_DEFINITIONS.map((app) => + app.slug === "google-sheets" ? { - ...entry, + ...app, + ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY, availability: googleSheetsAvailability.available ? { available: true, robotEmail: googleSheetsAvailability.robotEmail } : { available: false, reason: googleSheetsAvailability.reason }, } - : entry, + : { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY }, ), }); }); diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 80589d2c0bc..3ea6c9455e3 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -33,6 +33,7 @@ import { toolRuntimeSlots, } from "@paperclipai/db"; import type { + AppDefinition, ConnectionTokenIssuanceOutcome, ConnectionTokenIssuancePath, ConnectionTokenRequest, @@ -104,7 +105,7 @@ import type { UpdateToolProfileWithEntries, UnbindToolProfileBinding, } from "@paperclipai/shared"; -import { CLASS3_STATIC_LEASE_ALLOWLIST, getToolAppGalleryEntry, isToolConnectionAttentionHealth } from "@paperclipai/shared"; +import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared"; import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { logActivity } from "./activity-log.js"; import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js"; @@ -423,6 +424,25 @@ export function googleSheetsRobotEmailFromEnv( return { available: false, reason: "Google Sheets is not available on this instance yet." }; } +function connectionMethodFor(app: AppDefinition) { + const method = getAvailableConnectionMethod(app); + if (!method) throw unprocessable("This app does not have an available connection method"); + return method; +} + +function credentialFieldsFor(app: AppDefinition) { + const method = connectionMethodFor(app); + return (method.credentialFields ?? []).map((field) => ({ + label: field.label, + configPath: credentialConfigPath(field), + helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "", + required: field.required, + placement: method.keyPlacement?.location === "header" ? "header" as const : undefined, + key: method.keyPlacement?.name, + prefix: method.keyPlacement?.prefix, + })); +} + function googleSheetsAllowedSpreadsheetIds(configValues: Record | undefined): string[] { const raw = configValues?.allowedSpreadsheetIds; const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : []; @@ -3833,13 +3853,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} return null; } - async function oauthProviderEndpoints(galleryEntry: NonNullable>): Promise { - const oauth = galleryEntry.oauth; - if (!oauth) throw unprocessable("This app does not support sign in"); - let authorizationUrl = oauth.authorizationUrl ?? null; - let tokenUrl = oauth.tokenUrl ?? null; - if ((!authorizationUrl || !tokenUrl) && oauth.metadataUrl) { - const response = await fetchRemoteHttpUrl(oauth.metadataUrl); + async function oauthProviderEndpoints(app: AppDefinition): Promise { + const method = connectionMethodFor(app); + if (method.auth !== "oauth") throw unprocessable("This app does not support sign in"); + let authorizationUrl = method.defaults?.authorizationEndpoint ?? null; + let tokenUrl = method.defaults?.tokenEndpoint ?? null; + const metadataUrl = method.defaults?.metadataUrl ?? null; + if ((!authorizationUrl || !tokenUrl) && metadataUrl) { + const response = await fetchRemoteHttpUrl(metadataUrl); if (!response.ok) throw new HttpError(502, "OAuth provider metadata could not be loaded", { code: "oauth_metadata_failed" }); const metadata = asRecord(await response.json() as unknown); authorizationUrl = authorizationUrl ?? (typeof metadata.authorization_endpoint === "string" ? metadata.authorization_endpoint : null); @@ -3848,7 +3869,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (!authorizationUrl || !tokenUrl) { throw unprocessable("OAuth provider endpoints are not configured for this app"); } - return { provider: oauth.provider, scopes: oauth.scopes, authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl: oauth.metadataUrl ?? null }; + return { provider: app.slug, scopes: method.defaults?.scopesHint ?? [], authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl }; } async function oauthEndpointsForConnection( @@ -3858,9 +3879,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} ): Promise { const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; const endpoints = smokeLabEndpoints - ?? (galleryEntry?.authKind === "oauth" && galleryEntry.oauth + ?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth" ? await oauthProviderEndpoints(galleryEntry) : await discoverOAuthEndpoints(connection, challenge)); if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in"); @@ -3871,8 +3892,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} async function oauthGalleryEntryForConnection(connection: typeof toolConnections.$inferSelect) { const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; if (!sourceTemplateKey) throw unprocessable("This app connection was not created from the app gallery"); - const galleryEntry = getToolAppGalleryEntry(sourceTemplateKey); - if (!galleryEntry || galleryEntry.authKind !== "oauth" || !galleryEntry.oauth) { + const galleryEntry = getConnectableAppDefinition(sourceTemplateKey); + if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") { throw unprocessable("This app connection does not use sign in"); } return galleryEntry; @@ -4087,7 +4108,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} input: ConnectToolApp, actor?: ActorInfo, ): Promise { - const galleryEntry = input.galleryKey ? getToolAppGalleryEntry(input.galleryKey) : null; + const galleryEntry = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null; if (input.galleryKey && !galleryEntry) throw notFound("Tool app gallery entry not found"); let existingApplication: typeof toolApplications.$inferSelect | null = null; @@ -4101,18 +4122,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? ""); - const transportTemplate = galleryEntry?.transportTemplate ?? { - transport: "mcp_remote" as const, - url: input.link ?? "", - }; - const transport = transportTemplate.transport; + const method = galleryEntry ? connectionMethodFor(galleryEntry) : null; + const transport = method?.transport ?? "mcp_remote"; const baseConfig = transport === "mcp_remote" - ? { url: transportTemplate.url } - : { templateId: transportTemplate.templateKey }; + ? { url: method?.defaults?.serverUrl ?? input.link ?? "" } + : { templateId: method?.defaults?.templateKey }; let config: Record = galleryEntry - ? { ...baseConfig, sourceTemplateKey: galleryEntry.key, quarantineNewEntries: true } + ? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true } : { ...baseConfig, quarantineNewEntries: true }; - if (galleryEntry?.key === GOOGLE_SHEETS_GALLERY_KEY) { + if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) { const availability = googleSheetsRobotEmailFromEnv(); if (!availability.available) { throw unprocessable(availability.reason, { code: "google_sheets_unavailable" }); @@ -4142,7 +4160,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null; try { - const credentialFields = galleryEntry?.credentialFields ?? linkCredentialFields(credentialValues); + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues); for (const field of credentialFields) { const value = credentialValues[field.configPath]; if (!value && field.required !== false) { @@ -4188,12 +4206,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} } else { [applicationRow] = await db.insert(toolApplications).values({ companyId, - applicationKey: `app-gallery:${galleryEntry?.key ?? "link"}:${randomUUID()}`, + applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`, name, - description: galleryEntry?.tagline ?? `Connected app at ${input.link}`, + description: galleryEntry?.description ?? `Connected app at ${input.link}`, type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio", status: "draft", - metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.key, galleryKey: galleryEntry.key } : { source: "link" }, + metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" }, }).returning(); } @@ -4235,7 +4253,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} name, uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId), connectionKind: "managed", - authKind: galleryEntry?.authKind ?? "none", + authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none", transport, status: "draft", enabled: false, @@ -4250,14 +4268,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} await syncCredentialBindings(connectionRow); await ensureRuntimeSlot(connectionRow); - if (galleryEntry?.authKind === "oauth") { + if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") { return { connectionId: connectionRow.id, application: toApplication(applicationRow), connection: toConnection(connectionRow), catalog: [], actions: { readOnly: [], canMakeChanges: [] }, - suggestedDefaults: galleryEntry.recommendedDefaults, + suggestedDefaults: recommendedDefaultsForApp(galleryEntry), auth: { kind: "oauth", startUrl: null }, }; } @@ -4292,7 +4310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry?.recommendedDefaults ?? { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, @@ -4619,8 +4637,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected"); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; - const credentialFields = galleryEntry?.credentialFields ?? [ + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; + const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [ { label: "App key", configPath: "credentials.authorization", @@ -4784,7 +4802,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId); const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null; - const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null; + const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null; const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri); const client = oauthClientForConnection(connection, endpoints.provider); if (!client.clientId) throw unprocessable(`OAuth client id is not configured for ${endpoints.provider}`); @@ -4884,7 +4902,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {} connection: refresh.connection, catalog: refresh.catalog, actions: groupedActions(refresh.catalog), - suggestedDefaults: galleryEntry?.recommendedDefaults ?? { + suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : { access: "all_agents", askFirstRiskLevels: ["write", "destructive"], }, diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 3f93ac8cd22..a49fe03b952 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -34,7 +34,7 @@ import type { ToolRiskLevel, UpdateToolPolicy, ReorderToolPolicies, - AppGalleryEntry, + AppDefinition, ToolAppsAttentionResponse, ToolConnectionActivityResponse, ToolConnectionTestAgentsResponse, @@ -70,7 +70,7 @@ export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary; export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] }; export type ToolPoliciesResponse = { policies: ToolPolicy[] }; export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] }; -export type ToolGalleryResponse = { apps: AppGalleryEntry[] }; +export type ToolGalleryResponse = { apps: AppDefinition[] }; export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] }; export type CreateGatewayTokenInput = Omit & { expiresAt?: string | Date | null; diff --git a/ui/src/components/AppConnectionSidebar.tsx b/ui/src/components/AppConnectionSidebar.tsx index 36c58e6edad..7b6ec27d465 100644 --- a/ui/src/components/AppConnectionSidebar.tsx +++ b/ui/src/components/AppConnectionSidebar.tsx @@ -1,7 +1,7 @@ import { ChevronLeft } from "lucide-react"; import { useQuery } from "@tanstack/react-query"; import { humanizeConnectionDisplayName } from "@paperclipai/shared"; -import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared"; +import type { AppDefinition, ToolApplication, ToolConnection } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { toolsApi } from "@/api/tools"; import { useCompany } from "@/context/CompanyContext"; @@ -83,7 +83,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) { All apps
- + {appName}
@@ -117,17 +117,17 @@ function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string { } function galleryEntryFor( - apps: AppGalleryEntry[], + apps: AppDefinition[], connection: ToolConnection | undefined, application: ToolApplication | undefined, -): AppGalleryEntry | null { +): AppDefinition | null { if (application?.applicationKey) { - const keyed = apps.find((app) => app.key === application.applicationKey); + const keyed = apps.find((app) => app.slug === application.applicationKey); if (keyed) return keyed; } const name = (connection?.name ?? application?.name)?.toLowerCase(); if (!name) return null; - return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.key === name) ?? null; + return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.slug === name) ?? null; } function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null { diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 1f568cc450a..758e96c6447 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Check, Loader2, Pencil } from "lucide-react"; import type { - AppGalleryEntry, + AppDefinition, ToolConnection, ToolPolicy, ToolProfileWithDetails, @@ -479,7 +479,7 @@ function AppDetailHeader({ }: { appName: string; connection: ToolConnection; - logoEntry: AppGalleryEntry | null; + logoEntry: AppDefinition | null; status: StatusInfo; actionCount: number; renaming: boolean; @@ -493,7 +493,7 @@ function AppDetailHeader({ return (
- +
{renaming ? (
a.name.toLowerCase() === name) ?? apps.find((a) => a.key === name) ?? null; + return apps.find((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.slug === name) ?? null; } function addAll(set: Set, ids: string[]): Set { diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index b3ed0820229..f4cce08a343 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -123,8 +123,8 @@ export function AppNotConnected() { const gallery = galleryQuery.data?.apps ?? []; const logoUrl = - (application.applicationKey ? gallery.find((entry) => entry.key === application.applicationKey)?.logoUrl : undefined) ?? - gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.logoUrl; + (application.applicationKey ? gallery.find((entry) => entry.slug === application.applicationKey)?.branding.logoUrl : undefined) ?? + gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.branding.logoUrl; const previousAddress = previousConnection ? connectionAddress(previousConnection) : null; const connectHref = reconnectHref({ diff --git a/ui/src/pages/apps/AppsConnect.test.tsx b/ui/src/pages/apps/AppsConnect.test.tsx index d9ff3228605..ac2b0fde6c2 100644 --- a/ui/src/pages/apps/AppsConnect.test.tsx +++ b/ui/src/pages/apps/AppsConnect.test.tsx @@ -3,6 +3,7 @@ import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AppsConnect } from "./AppsConnect"; @@ -15,6 +16,9 @@ const mockNavigate = vi.hoisted(() => vi.fn()); const mockSearch = vi.hoisted(() => ({ value: "" })); const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined })); +const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!; +const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!; + vi.mock("@/api/tools", () => ({ toolsApi: { listGallery: (companyId: string) => listGalleryMock(companyId), @@ -122,15 +126,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { document.body.appendChild(container); listGalleryMock.mockResolvedValue({ apps: [ - { - key: "zapier", - name: "Zapier", - tagline: "Automate things", - authKind: "api_key", - urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"], - logoUrl: null, - credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }], - }, + ZAPIER, ], }); finishAppMock.mockResolvedValue({}); @@ -280,15 +276,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { mockSearch.value = "byo=1&source=zapier"; listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "zapier", - name: "Zapier", - tagline: "Automate things", - authKind: "api_key", - urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"], - logoUrl: "https://example.com/zapier.png", - credentialFields: [], - }, + { ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } }, ], }); connectAppMock.mockResolvedValueOnce({ @@ -475,16 +463,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -505,16 +484,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("shows inline validation for invalid Google Sheets links", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -615,16 +585,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("a custom name on the Google Sheets step is sent to the connect mutation", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); @@ -659,16 +620,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => { it("passes parsed Google Sheets IDs as connection config values", async () => { listGalleryMock.mockResolvedValueOnce({ apps: [ - { - key: "google-sheets", - name: "Google Sheets", - tagline: "Read and update selected spreadsheets.", - authKind: "none", - urlPatterns: ["https://docs.google.com/spreadsheets/*"], - logoUrl: "https://example.com/sheets.png", - credentialFields: [], - availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" }, - }, + { ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } }, ], }); await render(); diff --git a/ui/src/pages/apps/AppsConnect.tsx b/ui/src/pages/apps/AppsConnect.tsx index 227f68ff227..0b1783d34f4 100644 --- a/ui/src/pages/apps/AppsConnect.tsx +++ b/ui/src/pages/apps/AppsConnect.tsx @@ -15,11 +15,11 @@ import { import type { LucideIcon } from "lucide-react"; import type { Agent, - AppGalleryEntry, + AppDefinition, ConnectToolAppResult, ToolAppConnectionActionSummary, } from "@paperclipai/shared"; -import { getToolAppGalleryEntryForUrl } from "@paperclipai/shared"; +import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared"; import { useNavigate, useParams, useSearchParams } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -83,8 +83,8 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] { return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"]; } -function isGoogleSheetsEntry(entry: AppGalleryEntry | null): boolean { - return entry?.key === "google-sheets"; +function isGoogleSheetsEntry(entry: AppDefinition | null): boolean { + return entry?.slug === "google-sheets"; } export function AppsConnect() { @@ -109,7 +109,7 @@ export function AppsConnect() { }); const [step, setStep] = useState(appKey || prefill.link || zapierSource ? "key" : "gallery"); - const [entry, setEntry] = useState(null); + const [entry, setEntry] = useState(null); const [galleryName, setGalleryName] = useState(""); const [linkUrl, setLinkUrl] = useState(prefill.link); const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : "")); @@ -160,15 +160,15 @@ export function AppsConnect() { useEffect(() => { if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return; - const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.key === appKey); - if (!requestedEntry || requestedEntry.authKind === "oauth" || requestedEntry.availability?.available === false) { + const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === appKey); + if (!requestedEntry || getAvailableConnectionMethod(requestedEntry)?.auth === "oauth" || requestedEntry.availability?.available === false) { setEntry(null); setStep("gallery"); navigate("/apps/connect", { replace: true }); return; } - if (entry?.key !== requestedEntry.key) { + if (entry?.slug !== requestedEntry.slug) { setEntry(requestedEntry); setGalleryName(requestedEntry.name); setLinkUrl(""); @@ -183,11 +183,11 @@ export function AppsConnect() { setInstallMode("none"); setInstallAgentIds(new Set()); setStep("key"); - }, [appKey, entry?.key, galleryQuery.data, galleryQuery.isLoading, navigate]); + }, [appKey, entry?.slug, galleryQuery.data, galleryQuery.isLoading, navigate]); const setAppStep = (nextStep: Step) => { setStep(nextStep); - if (entry) navigate(appConnectHref(entry.key, nextStep)); + if (entry) navigate(appConnectHref(entry.slug, nextStep)); }; const connectMutation = useMutation({ @@ -196,7 +196,7 @@ export function AppsConnect() { const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : []; const trimmedGalleryName = galleryName.trim(); return toolsApi.connectApp(selectedCompanyId!, { - galleryKey: entry.key, + galleryKey: entry.slug, name: trimmedGalleryName || undefined, credentialValues: credentials, configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined, @@ -284,7 +284,7 @@ export function AppsConnect() { entry?.name ?? (linkName.trim() || defaultLinkName(linkUrl) || "this app"); const zapierEntry = zapierSource - ? galleryQuery.data?.apps.find((app) => app.key === "zapier") ?? null + ? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null : null; const stepLabels = zapierSource ? ZAPIER_STEP_LABELS @@ -311,7 +311,7 @@ export function AppsConnect() { labels={stepLabels} appIdentity={ zapierSource - ? { name: "Zapier", logoUrl: zapierEntry?.logoUrl ?? null } + ? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null } : undefined } onCancel={() => navigate(zapierSource ? "/apps/browse" : "/apps")} @@ -338,10 +338,10 @@ export function AppsConnect() { setInstallMode("none"); setInstallAgentIds(new Set()); setStep("key"); - navigate(appConnectHref(picked.key, "key")); + navigate(appConnectHref(picked.slug, "key")); }} onUseLink={(url) => { - const matchedEntry = getToolAppGalleryEntryForUrl(url, galleryQuery.data?.apps ?? []); + const matchedEntry = getAppDefinitionForUrl(url, galleryQuery.data?.apps ?? []); setEntry(null); setGalleryName(""); setLinkUrl(url); @@ -470,7 +470,7 @@ export function AppsConnect() { {step === "success" && ( void; + onPick: (entry: AppDefinition) => void; onUseLink: (link: string) => void; onRunYourOwn: () => void; onPasteConfig: () => void; @@ -635,7 +635,7 @@ function GalleryStep({ return apps.filter((a) => a.name.toLowerCase().includes(q)); }, [apps, search]); const normalizedLink = normalizeAppLink(linkInput); - const matchedEntry = normalizedLink ? getToolAppGalleryEntryForUrl(normalizedLink, apps) : null; + const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null; const zapierSource = source === "zapier"; const continueWithLink = () => { @@ -672,12 +672,12 @@ function GalleryStep({
{filtered.map((app) => { - const copy = appCopyFor(app.key, app.tagline); - const oauth = app.authKind === "oauth"; + const copy = appCopyFor(app.slug, app.description); + const oauth = getAvailableConnectionMethod(app)?.auth === "oauth"; const unavailable = app.availability?.available === false; return ( @@ -1037,7 +1037,7 @@ function KeyStep({ onBack, onConnect, }: { - entry: AppGalleryEntry; + entry: AppDefinition; name: string; onNameChange: (next: string) => void; values: Record; @@ -1049,8 +1049,13 @@ function KeyStep({ onBack: () => void; onConnect: () => void; }) { - const copy = appCopyFor(entry.key, entry.tagline); - const fields = entry.credentialFields ?? []; + const copy = appCopyFor(entry.slug, entry.description); + const method = getAvailableConnectionMethod(entry); + const fields = (method?.credentialFields ?? []).map((field) => ({ + ...field, + configPath: credentialConfigPath(field), + helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", + })); const allFilled = fields.every( (f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0, ); @@ -1063,7 +1068,7 @@ function KeyStep({ return (
- +

Connect Google Sheets

{copy.short}

@@ -1136,7 +1141,7 @@ function KeyStep({ return (
- +

Connect {entry.name}

{copy.short}

diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index 5998e0abf3b..da2925bbcfb 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { Link2, Search } from "lucide-react"; -import type { AppGalleryEntry } from "@paperclipai/shared"; +import type { AppDefinition } from "@paperclipai/shared"; import { useNavigate } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; @@ -49,8 +49,8 @@ export function Browse() { const gallery = galleryQuery.data?.apps ?? []; const popular = useMemo( () => - POPULAR_KEYS.map((key) => gallery.find((entry) => entry.key === key)).filter( - (entry): entry is AppGalleryEntry => Boolean(entry), + POPULAR_KEYS.map((key) => gallery.find((entry) => entry.slug === key)).filter( + (entry): entry is AppDefinition => Boolean(entry), ), [gallery], ); @@ -61,7 +61,7 @@ export function Browse() { return gallery.filter( (entry) => entry.name.toLowerCase().includes(trimmed) || - entry.tagline.toLowerCase().includes(trimmed) || + entry.description.toLowerCase().includes(trimmed) || (entry.description?.toLowerCase().includes(trimmed) ?? false), ); }, [gallery, trimmed]); @@ -109,9 +109,9 @@ export function Browse() {
{popular.map((entry) => ( navigate(ZAPIER_CONNECT_HREF) : undefined} + onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined} compact /> ))} @@ -132,9 +132,9 @@ export function Browse() {
{filtered.map((entry) => ( navigate(ZAPIER_CONNECT_HREF) : undefined} + onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined} /> ))}
@@ -160,7 +160,7 @@ function AppTile({ onConnect, compact = false, }: { - entry: AppGalleryEntry; + entry: AppDefinition; onConnect?: () => void; compact?: boolean; }) { @@ -175,7 +175,7 @@ function AppTile({ ? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60" : "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"} > - + {entry.name} {disabled ? "Coming soon" : "Connect →"} @@ -192,10 +192,10 @@ function AppTile({ ? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60" : "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"} > - +
{entry.name}
-
{entry.tagline}
+
{entry.description}
{disabled ? "Coming soon" : "Connect →"} diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index d5bf011a64d..887ee4bac16 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react"; import type { - AppGalleryEntry, + AppDefinition, ToolApplication, ToolConnection, ToolProfileWithDetails, @@ -115,13 +115,13 @@ export function Connections() { const gallery = galleryQuery.data?.apps ?? []; const logoByName = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const entry of gallery) map.set(entry.name.toLowerCase(), entry); return map; }, [gallery]); const logoByKey = useMemo(() => { - const map = new Map(); - for (const entry of gallery) map.set(entry.key, entry); + const map = new Map(); + for (const entry of gallery) map.set(entry.slug, entry); return map; }, [gallery]); @@ -173,7 +173,7 @@ export function Connections() { status: statusFor(application, appConnections), actionCount, lastUsedAt, - logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl, + logoUrl: galleryEntry?.branding.logoUrl ?? logoByName.get(application.name.toLowerCase())?.branding.logoUrl, }; }); }, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]); diff --git a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx index 994120eeccc..807bfe2d88e 100644 --- a/ui/src/pages/apps/app-detail/AdvancedPanel.tsx +++ b/ui/src/pages/apps/app-detail/AdvancedPanel.tsx @@ -1,8 +1,8 @@ import { useState } from "react"; import { useMutation } from "@tanstack/react-query"; import { ArrowUpRight, Loader2, Lock } from "lucide-react"; -import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared"; -import { humanizeConnectionDisplayName } from "@paperclipai/shared"; +import type { AppDefinition, ToolConnection } from "@paperclipai/shared"; +import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared"; import { toolsApi } from "@/api/tools"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -37,7 +37,7 @@ function KeySection({ onReplaced, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onReplaced: () => void; }) { const [open, setOpen] = useState(false); @@ -82,7 +82,7 @@ export function ReconnectCard({ onReconnected, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onReconnected: () => void; }) { return ( @@ -105,12 +105,17 @@ function ReconnectForm({ onReconnected, }: { connection: ToolConnection; - galleryEntry: AppGalleryEntry | null; + galleryEntry: AppDefinition | null; onCancel?: () => void; onReconnected: () => void; }) { const { pushToast } = useToast(); - const fields = galleryEntry?.credentialFields ?? []; + const method = galleryEntry ? getAvailableConnectionMethod(galleryEntry) : null; + const fields = (method?.credentialFields ?? []).map((field) => ({ + ...field, + configPath: credentialConfigPath(field), + helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "", + })); const [values, setValues] = useState>({}); const [single, setSingle] = useState(""); const usesGallery = fields.length > 0 && !!galleryEntry; diff --git a/ui/src/pages/apps/app-detail/SetupPanel.tsx b/ui/src/pages/apps/app-detail/SetupPanel.tsx index a8e66fb4e82..8c1573486c5 100644 --- a/ui/src/pages/apps/app-detail/SetupPanel.tsx +++ b/ui/src/pages/apps/app-detail/SetupPanel.tsx @@ -26,7 +26,7 @@ export function SetupPanel({ onStartOAuth: () => void; oauthStartDisabled: boolean; }) { - const description = galleryEntry?.description ?? galleryEntry?.tagline ?? null; + const description = galleryEntry?.description ?? null; const oauth = connection.config?.oauth; const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth)); const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http"; @@ -35,7 +35,7 @@ export function SetupPanel({ {description && (

{description}

)} - {galleryEntry?.key === "google-sheets" && ( + {galleryEntry?.slug === "google-sheets" && ( e.key === "zapier") as AppGalleryEntry; -const GALLERY: AppGalleryEntry[] = [ZAPIER]; +const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((e) => e.slug === "zapier") as AppDefinition; +const GALLERY: AppDefinition[] = [ZAPIER]; function seededClient() { const c = new QueryClient({