Mirror the Composio project's auth configs as the connector catalog with logo.dev icons
This commit is contained in:
1 parent
dde449d226
commit
00e05054ca
18 files changed
+269
-175
No files matched your search
@@ -329,16 +329,16 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const composioConnections = new ComposioConnectionService(deps.connection.db);
|
||||
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async () => {
|
||||
ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => {
|
||||
try {
|
||||
return ok(await composioConnections.listToolkits());
|
||||
return ok(await composioConnections.listCatalog());
|
||||
} catch (error) {
|
||||
return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error));
|
||||
return fail("composio_catalog_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
|
||||
try {
|
||||
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.toolkitName);
|
||||
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug);
|
||||
notifyDataChanged();
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Auth configs are configured in the Composio dashboard and mirrored through
|
||||
* the API at runtime — the app no longer creates them, so the create-once
|
||||
* auth_config:* cache rows in composio_settings are never read again and are
|
||||
* purged. entity_id rows stay: connected-account calls still use them.
|
||||
*/
|
||||
export const authConfigPurgeSchema = `
|
||||
delete from composio_settings where key like 'auth_config:%';
|
||||
`;
|
||||
@@ -45,6 +45,7 @@ import { composioIntegrationSchema } from "./migrations/043-composio-integration
|
||||
import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state";
|
||||
import { connectorGrantsSchema } from "./migrations/045-connector-grants";
|
||||
import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge";
|
||||
import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -99,6 +100,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema },
|
||||
{ version: 45, name: "connector_grants", sql: connectorGrantsSchema },
|
||||
{ version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema },
|
||||
{ version: 47, name: "auth_config_purge", sql: authConfigPurgeSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -13,15 +13,11 @@ export interface ComposioToolSchema {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposioToolkitSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
categories: string[];
|
||||
authSchemes: string[];
|
||||
noAuth: boolean;
|
||||
toolsCount: number;
|
||||
export interface ComposioAuthConfigSummary {
|
||||
id: string;
|
||||
toolkitSlug: string;
|
||||
authScheme: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ComposioConnectedAccount {
|
||||
@@ -40,34 +36,28 @@ export interface ComposioToolExecutionResult {
|
||||
export class ComposioClient {
|
||||
constructor(private readonly apiKey: string) {}
|
||||
|
||||
/** The toolkit catalog, most popular first. */
|
||||
async listToolkits(limit: number): Promise<ComposioToolkitSummary[]> {
|
||||
const toolkits: ComposioToolkitSummary[] = [];
|
||||
/** Every auth config in the Composio project — the connector catalog. */
|
||||
async listAuthConfigs(): Promise<ComposioAuthConfigSummary[]> {
|
||||
const configs: ComposioAuthConfigSummary[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (toolkits.length < limit) {
|
||||
const params = new URLSearchParams({ limit: String(Math.min(100, limit - toolkits.length)) });
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const response = await this.request("GET", `${BASE_URL}/toolkits?${params.toString()}`);
|
||||
const response = await this.request("GET", `${BASE_URL}/auth_configs?${params.toString()}`);
|
||||
const items = Array.isArray(response.items) ? response.items as Array<Record<string, unknown>> : [];
|
||||
for (const item of items) {
|
||||
const meta = (item.meta ?? {}) as Record<string, unknown>;
|
||||
toolkits.push({
|
||||
slug: String(item.slug ?? ""),
|
||||
name: String(item.name ?? ""),
|
||||
description: String(meta.description ?? "").slice(0, 200),
|
||||
logo: typeof meta.logo === "string" ? meta.logo : null,
|
||||
categories: Array.isArray(meta.categories)
|
||||
? (meta.categories as Array<Record<string, unknown>>).map((c) => String(c.name ?? c.id ?? "")).filter(Boolean)
|
||||
: [],
|
||||
authSchemes: Array.isArray(item.auth_schemes) ? (item.auth_schemes as unknown[]).map(String) : [],
|
||||
noAuth: item.no_auth === true,
|
||||
toolsCount: Number(meta.tools_count ?? 0),
|
||||
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
|
||||
configs.push({
|
||||
id: String(item.id ?? ""),
|
||||
toolkitSlug: String(toolkit.slug ?? "").toLowerCase(),
|
||||
authScheme: String(item.auth_scheme ?? ""),
|
||||
status: String(item.status ?? ""),
|
||||
});
|
||||
}
|
||||
cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null;
|
||||
if (!cursor || items.length === 0) break;
|
||||
}
|
||||
return toolkits;
|
||||
if (items.length === 0) break;
|
||||
} while (cursor);
|
||||
return configs;
|
||||
}
|
||||
|
||||
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
@@ -83,17 +73,6 @@ export class ComposioClient {
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Creates a Composio-managed auth config for a toolkit. */
|
||||
async createAuthConfig(toolkitSlug: string): Promise<string> {
|
||||
const response = await this.request("POST", `${BASE_URL}/auth_configs`, {
|
||||
toolkit: { slug: toolkitSlug },
|
||||
});
|
||||
const authConfig = (response.auth_config ?? {}) as Record<string, unknown>;
|
||||
const id = String(authConfig.id ?? "");
|
||||
if (!id) throw new Error("Composio auth config creation returned no id.");
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a connection for a user; the redirect URL completes it in the
|
||||
* browser. Composio-managed auth configs only accept the link flow —
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient, type ComposioToolkitSummary } from "./composio-client";
|
||||
import type { ConnectorCatalogEntry, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient } from "./composio-client";
|
||||
import { composioApiKey } from "./composio-credentials";
|
||||
import { getComposioUserId } from "./composio-tool-bridge";
|
||||
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
|
||||
import { normalizeSlug } from "./connector-grants";
|
||||
|
||||
const TOOLKIT_CATALOG_LIMIT = 200;
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | null = null;
|
||||
let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null = null;
|
||||
|
||||
/**
|
||||
* Company-level Composio connections. One connected account per toolkit per
|
||||
* organization, mirrored into connector_accounts so agent runs and the
|
||||
* Connectors page read local state instead of calling out on every frame.
|
||||
* Company-level Composio connections. The catalog mirrors the auth configs
|
||||
* of the Composio project — the dashboard is the source of truth for which
|
||||
* connectors exist; the app never creates auth configs, it only connects
|
||||
* accounts against them. One connected account per toolkit per organization,
|
||||
* mirrored into connector_accounts so agent runs and the Connectors page
|
||||
* read local state instead of calling out on every frame.
|
||||
*/
|
||||
export class ComposioConnectionService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
listToolkits(): Promise<ComposioToolkitSummaryDto[]> {
|
||||
const client = this.requireClient();
|
||||
return this.catalog(client);
|
||||
listCatalog(): Promise<ConnectorCatalogEntry[]> {
|
||||
return this.catalog(this.requireClient());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the company's connection for a toolkit: creates a
|
||||
* Composio-managed auth config once per toolkit, initiates the connected
|
||||
* account for the company user via the link flow, and records the pending
|
||||
* account locally as INITIALIZING — the poll that follows adopts Composio's
|
||||
* real status. The returned redirect URL completes authorization in the
|
||||
* browser.
|
||||
* Starts (or restarts) the company's connection for a toolkit: initiates a
|
||||
* connected account against the toolkit's configured auth config via the
|
||||
* link flow and records the pending account locally as INITIALIZING — the
|
||||
* poll that follows adopts Composio's real status. The returned redirect
|
||||
* URL completes authorization (or API-key entry) in the browser.
|
||||
*/
|
||||
async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise<ConnectComposioToolkitResult> {
|
||||
async connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
|
||||
const slug = normalizeSlug(toolkitSlug);
|
||||
const client = this.requireClient();
|
||||
const authConfigId = await this.ensureAuthConfig(client, orgId, slug);
|
||||
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
|
||||
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
|
||||
const account = await client.createConnectedAccount({
|
||||
authConfigId,
|
||||
authConfigId: entry.authConfigId,
|
||||
userId: getComposioUserId(this.db, orgId),
|
||||
});
|
||||
if (!account.id) throw new Error("Composio did not return a connected account id.");
|
||||
@@ -50,7 +52,7 @@ export class ComposioConnectionService {
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(account.id, orgId, slug, toolkitName.trim() || slug, authConfigId, "INITIALIZING", now, now);
|
||||
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
|
||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ export class ComposioConnectionService {
|
||||
account.id,
|
||||
orgId,
|
||||
account.toolkitSlug,
|
||||
account.toolkitSlug,
|
||||
connectorDisplayName(account.toolkitSlug),
|
||||
account.authConfigId,
|
||||
account.status,
|
||||
account.createdAt || now,
|
||||
@@ -88,27 +90,24 @@ export class ComposioConnectionService {
|
||||
return new ComposioClient(apiKey);
|
||||
}
|
||||
|
||||
private async ensureAuthConfig(client: ComposioClient, orgId: string, slug: string): Promise<string> {
|
||||
const key = `auth_config:${slug}`;
|
||||
const existing = this.db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = ? limit 1",
|
||||
).get(orgId, key) as { value: string } | undefined;
|
||||
if (existing?.value) return existing.value;
|
||||
const authConfigId = await client.createAuthConfig(slug);
|
||||
this.db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run(orgId, key, authConfigId, new Date().toISOString());
|
||||
return authConfigId;
|
||||
}
|
||||
|
||||
private async catalog(client: ComposioClient): Promise<ComposioToolkitSummaryDto[]> {
|
||||
private async catalog(client: ComposioClient): Promise<ConnectorCatalogEntry[]> {
|
||||
if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) {
|
||||
return catalogCache.toolkits;
|
||||
return catalogCache.entries;
|
||||
}
|
||||
const toolkits = await client.listToolkits(TOOLKIT_CATALOG_LIMIT);
|
||||
catalogCache = { fetchedAt: Date.now(), toolkits };
|
||||
return toolkits;
|
||||
const configs = await client.listAuthConfigs();
|
||||
const bySlug = new Map<string, ConnectorCatalogEntry>();
|
||||
for (const config of configs) {
|
||||
if (config.status !== "ENABLED" || !config.toolkitSlug || bySlug.has(config.toolkitSlug)) continue;
|
||||
bySlug.set(config.toolkitSlug, {
|
||||
authConfigId: config.id,
|
||||
slug: config.toolkitSlug,
|
||||
name: connectorDisplayName(config.toolkitSlug),
|
||||
authScheme: config.authScheme,
|
||||
logoUrl: connectorLogoUrl(config.toolkitSlug),
|
||||
});
|
||||
}
|
||||
const entries = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
catalogCache = { fetchedAt: Date.now(), entries };
|
||||
return entries;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Display names and logo domains for the toolkits configured in the Composio
|
||||
* project. Logos come from img.logo.dev keyed by company domain (publishable
|
||||
* token — safe to embed). A slug outside this table renders with a prettified
|
||||
* name and the letter tile until its brand row is added here.
|
||||
*/
|
||||
const LOGO_DEV_TOKEN = "***REMOVED***";
|
||||
|
||||
const BRANDS: Record<string, { name: string; domain: string }> = {
|
||||
airtable: { name: "Airtable", domain: "airtable.com" },
|
||||
attio: { name: "Attio", domain: "attio.com" },
|
||||
cal: { name: "Cal.com", domain: "cal.com" },
|
||||
calendly: { name: "Calendly", domain: "calendly.com" },
|
||||
canva: { name: "Canva", domain: "canva.com" },
|
||||
cloudflare: { name: "Cloudflare", domain: "cloudflare.com" },
|
||||
discord: { name: "Discord", domain: "discord.com" },
|
||||
elevenlabs: { name: "ElevenLabs", domain: "elevenlabs.io" },
|
||||
figma: { name: "Figma", domain: "figma.com" },
|
||||
firecrawl: { name: "Firecrawl", domain: "firecrawl.dev" },
|
||||
github: { name: "GitHub", domain: "github.com" },
|
||||
gmail: { name: "Gmail", domain: "gmail.com" },
|
||||
google_analytics: { name: "Google Analytics", domain: "analytics.google.com" },
|
||||
googleads: { name: "Google Ads", domain: "ads.google.com" },
|
||||
googlecalendar: { name: "Google Calendar", domain: "calendar.google.com" },
|
||||
googledocs: { name: "Google Docs", domain: "docs.google.com" },
|
||||
googledrive: { name: "Google Drive", domain: "drive.google.com" },
|
||||
googlesheets: { name: "Google Sheets", domain: "sheets.google.com" },
|
||||
googletasks: { name: "Google Tasks", domain: "tasks.google.com" },
|
||||
heygen: { name: "HeyGen", domain: "heygen.com" },
|
||||
junglescout: { name: "Jungle Scout", domain: "junglescout.com" },
|
||||
linear: { name: "Linear", domain: "linear.app" },
|
||||
linkedin: { name: "LinkedIn", domain: "linkedin.com" },
|
||||
mailchimp: { name: "Mailchimp", domain: "mailchimp.com" },
|
||||
monday: { name: "Monday", domain: "monday.com" },
|
||||
notion: { name: "Notion", domain: "notion.so" },
|
||||
one_drive: { name: "OneDrive", domain: "onedrive.com" },
|
||||
outlook: { name: "Outlook", domain: "outlook.com" },
|
||||
perplexityai: { name: "Perplexity", domain: "perplexity.ai" },
|
||||
reddit: { name: "Reddit", domain: "reddit.com" },
|
||||
salesforce: { name: "Salesforce", domain: "salesforce.com" },
|
||||
sentry: { name: "Sentry", domain: "sentry.io" },
|
||||
share_point: { name: "SharePoint", domain: "sharepoint.com" },
|
||||
shopify: { name: "Shopify", domain: "shopify.com" },
|
||||
slack: { name: "Slack", domain: "slack.com" },
|
||||
stripe: { name: "Stripe", domain: "stripe.com" },
|
||||
supabase: { name: "Supabase", domain: "supabase.com" },
|
||||
tavily: { name: "Tavily", domain: "tavily.com" },
|
||||
twitter: { name: "X", domain: "x.com" },
|
||||
whatsapp: { name: "WhatsApp", domain: "whatsapp.com" },
|
||||
zoho: { name: "Zoho", domain: "zoho.com" },
|
||||
};
|
||||
|
||||
export function connectorDisplayName(slug: string): string {
|
||||
const brand = BRANDS[slug];
|
||||
if (brand) return brand.name;
|
||||
return slug
|
||||
.split(/[_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function connectorLogoUrl(slug: string): string | null {
|
||||
const brand = BRANDS[slug];
|
||||
if (!brand) return null;
|
||||
return `https://img.logo.dev/${brand.domain}?token=${LOGO_DEV_TOKEN}`;
|
||||
}
|
||||
Reference in new issue
Block a user