Mirror the Composio project's auth configs as the connector catalog with logo.dev icons

This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 11:06:07 -04:00
1 parent dde449d226
commit 00e05054ca
18 files changed
+269 -175

No files matched your search

+4 -4
View File
@@ -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:%';
`;
+2
View File
@@ -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;
+20 -41
View File
@@ -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}`;
}