import { createHmac } from "node:crypto"; import type { AdapterContext, ConnectorCall, ConnectorCatalogItem, ConnectorEvent, ConnectorTool, ManagedConnectorProvider, } from "@rakazo/adapter-kit"; import { collectPages, filterCatalog } from "./composio-connector.js"; import { combineSignals, redactConnectorPayload, sanitizeConnectorError, } from "./connector-safety.js"; import { callRemoteMcpTool, listRemoteMcpTools, type RemoteTransportDependencies, } from "./remote-mcp.js"; import { isVitestRuntime } from "./test-runtime.js"; import { readBodyCapped, withAbort } from "./web-ssrf.js"; const API_BASE = "https://api.pipedream.com"; const MCP_ENDPOINT = "https://remote.mcp.pipedream.net/v3"; const DIRECTORY_TTL_MS = 10 * 60_000; const REQUEST_TIMEOUT_MS = 30_000; export const MAX_PIPEDREAM_RESPONSE_BYTES = 2 * 1024 * 1024; export interface PipedreamConnectorConfig { clientId: string; clientSecret: string; projectId: string; environment: "development" | "production"; identitySecret: string; } export interface PipedreamEnvironmentValues { pipedreamClientId?: string; pipedreamClientSecret?: string; pipedreamProjectId?: string; pipedreamEnvironment?: string; encryptionKey: string; } export function pipedreamConfigFromEnv( values: PipedreamEnvironmentValues, ): PipedreamConnectorConfig { return { clientId: values.pipedreamClientId ?? "", clientSecret: values.pipedreamClientSecret ?? "", projectId: values.pipedreamProjectId ?? "", environment: values.pipedreamEnvironment === "production" ? "production" : "development", identitySecret: values.encryptionKey, }; } export type PipedreamConnectorDependencies = RemoteTransportDependencies; type PipedreamApp = { id: string; name_slug: string; name: string; img_src?: string; }; type PipedreamAccount = { id: string; healthy?: boolean; dead?: boolean; app?: PipedreamApp; }; export function isPipedreamEnabled(config: Partial): boolean { return Boolean( config.clientId && config.clientSecret && config.projectId && config.environment && config.identitySecret && !isVitestRuntime(), ); } function securePipedreamConnectUrl(value: string): URL { let url: URL; try { url = new URL(value); } catch { throw new Error("Pipedream did not return a secure HTTPS connect URL"); } if (url.protocol !== "https:" || url.username || url.password) { throw new Error("Pipedream did not return a secure HTTPS connect URL"); } return url; } export class PipedreamConnector implements ManagedConnectorProvider { private accessToken?: { value: string; expiresAt: number }; private accessTokenRequest?: Promise; private directory?: { value: PipedreamApp[]; expiresAt: number }; private directoryRequest?: Promise; constructor( private readonly config: PipedreamConnectorConfig, private readonly dependencies: PipedreamConnectorDependencies = {}, ) {} describe() { return { id: "pipedream", contractVersion: "1", adapterVersion: "0.1.0", capabilities: { discover: true, oauth: true, secretsBrokered: true }, }; } async catalog(context: AdapterContext, query?: string): Promise { const [apps, connected] = await Promise.all([ this.apps(), this.listConnectedExternalIds(context), ]); const connectedSet = new Set(connected); return filterCatalog( apps.map((app) => ({ connectorId: "pipedream", slug: app.name_slug, name: app.name, logo: app.img_src ?? null, connected: connectedSet.has(app.name_slug), noAuth: false, })), query ?? "", ); } async warmDirectory(): Promise { await this.apps(); } async listConnectedExternalIds(context: AdapterContext): Promise { const accounts = await this.accounts(context); return [ ...new Set( accounts .filter((account) => account.healthy !== false && account.dead !== true) .map((account) => account.app?.name_slug) .filter((slug): slug is string => Boolean(slug)), ), ]; } async discoverTools(context: AdapterContext): Promise { const apps = context.connectedConnections ?.filter((connection) => connection.connectorId === "pipedream") .map((connection) => connection.externalId) ?? []; if (apps.length === 0) return []; const token = await this.token(); const groups = await Promise.all( [...new Set(apps)].slice(0, 20).map(async (app) => { const tools = await listRemoteMcpTools({ endpoint: MCP_ENDPOINT, headers: this.mcpHeaders(context, app, token), signal: context.signal, fetch: this.dependencies.fetch, resolveHostname: this.dependencies.resolveHostname, }); return tools.map((tool) => ({ ...tool, route: { connectorId: "pipedream", resourceId: app, toolName: tool.name }, })); }), ); return groups.flat(); } async *execute(call: ConnectorCall, context: AdapterContext): AsyncIterable { const app = call.route?.resourceId; if (!app) { yield { type: "error", message: "Pipedream app route is missing" }; return; } let token: string | undefined; try { token = await this.token(); const result = await callRemoteMcpTool( { endpoint: MCP_ENDPOINT, headers: this.mcpHeaders(context, app, token), signal: context.signal, fetch: this.dependencies.fetch, resolveHostname: this.dependencies.resolveHostname, }, call.route?.toolName ?? call.tool, call.args, ); yield { type: "result", data: redactConnectorPayload(result, [token, this.config.clientSecret]), }; } catch (error) { yield { type: "error", message: sanitizeConnectorError( error, [token, this.config.clientSecret].filter((value): value is string => Boolean(value)), ), }; } } async begin( request: { provider: string; redirectUrl: string }, context: AdapterContext, ): Promise<{ authorizationUrl: string | null; state: string }> { const response = await this.request<{ connect_link_url: string }>( `/v1/connect/${encodeURIComponent(this.config.projectId)}/tokens`, { method: "POST", body: JSON.stringify({ external_user_id: this.externalUserId(context), success_redirect_uri: request.redirectUrl, error_redirect_uri: request.redirectUrl, scope: "connect:accounts:read connect:accounts:write", }), }, context.signal, ); const url = securePipedreamConnectUrl(response.connect_link_url); url.searchParams.set("app", request.provider); return { authorizationUrl: url.toString(), state: request.provider }; } async complete(request: { state: string }): Promise<{ connectionRef: string }> { return { connectionRef: request.state }; } /** * Prefer a concrete Pipedream account id over the app slug so multi-account * revoke can target one authorization. spaceId is required for the hashed * external user id Pipedream uses to scope accounts. */ async resolveConnectedAccountId( userId: string, slug: string, currentRef: string | null | undefined, excludeIds: string[] = [], spaceId?: string, ): Promise { const excluded = new Set(excludeIds.filter(Boolean)); const current = currentRef?.trim() || undefined; if (!spaceId) { return current && current !== slug && !excluded.has(current) ? current : undefined; } // Concrete account ids are already authoritative; do not reassign them. if (current && current !== slug) { return excluded.has(current) ? undefined : current; } const context = { userId, spaceId, signal: AbortSignal.timeout(30_000), } as AdapterContext; const accounts = await this.accounts(context, slug); const ids = accounts .filter((account) => account.healthy !== false && account.dead !== true) .map((account) => account.id) .filter(Boolean); return ids.find((id) => !excluded.has(id)); } async connectionReady(context: AdapterContext, externalId: string): Promise { const accounts = await this.accounts(context, externalId); return accounts.some( (account) => account.app?.name_slug === externalId && account.healthy !== false && account.dead !== true, ); } async revoke(externalId: string, context: AdapterContext): Promise { let accounts: PipedreamAccount[]; try { accounts = await this.accounts(context); } catch (error) { // Listing failed before any DELETE — mark so callers can restore local retry state. if (error && typeof error === "object") { (error as { remoteRevokePreDelete?: boolean }).remoteRevokePreDelete = true; } throw error; } const targets = accounts.filter( (account) => account.id === externalId || account.app?.name_slug === externalId, ); await Promise.all( targets.map((account) => this.request( `/v1/connect/${encodeURIComponent(this.config.projectId)}/accounts/${encodeURIComponent(account.id)}`, { method: "DELETE" }, context.signal, ), ), ); } /** * Delete remote accounts for `slug` that no local row still references. Used when a * begin loses a race to revoke and only the app slug is available — never revoke by * slug alone, or every sibling authorization would be deleted. */ async revokeUnreferencedAccounts( slug: string, keepAccountIds: string[], context: AdapterContext, ): Promise { const keep = new Set(keepAccountIds.filter(Boolean)); const accounts = await this.accounts(context, slug); const orphans = accounts.filter( (account) => account.app?.name_slug === slug && account.healthy !== false && account.dead !== true && !keep.has(account.id), ); await Promise.all( orphans.map((account) => this.request( `/v1/connect/${encodeURIComponent(this.config.projectId)}/accounts/${encodeURIComponent(account.id)}`, { method: "DELETE" }, context.signal, ), ), ); } private externalUserId(context: AdapterContext): string { return `rkz_${createHmac("sha256", this.config.identitySecret) .update(`${context.spaceId}:${context.userId}`) .digest("hex")}`; } private mcpHeaders( context: AdapterContext, app: string, accessToken: string, ): Record { return { authorization: `Bearer ${accessToken}`, "x-pd-project-id": this.config.projectId, "x-pd-environment": this.config.environment, "x-pd-external-user-id": this.externalUserId(context), "x-pd-app-slug": app, }; } private async apps(): Promise { if (this.directory && this.directory.expiresAt > Date.now()) return this.directory.value; if (this.directoryRequest) return this.directoryRequest; this.directoryRequest = this.loadApps(); try { return await this.directoryRequest; } finally { this.directoryRequest = undefined; } } private async loadApps(): Promise { const apps: PipedreamApp[] = []; let after: string | undefined; for (let page = 0; page < 100; page += 1) { const query = new URLSearchParams({ limit: "100", has_actions: "true" }); if (after) query.set("after", after); const response = await this.request<{ data?: PipedreamApp[]; page_info?: { end_cursor?: string }; }>(`/v1/connect/apps?${query}`); apps.push(...(response.data ?? [])); after = response.page_info?.end_cursor; if (!after || apps.length >= 5_000) break; } this.directory = { value: apps, expiresAt: Date.now() + DIRECTORY_TTL_MS }; return apps; } private async accounts(context: AdapterContext, app?: string): Promise { return collectPages(async (after) => { const query = new URLSearchParams({ external_user_id: this.externalUserId(context), limit: "100", }); if (app) query.set("app", app); if (after) query.set("after", after); const response = await this.request<{ data?: PipedreamAccount[]; page_info?: { end_cursor?: string }; }>( `/v1/connect/${encodeURIComponent(this.config.projectId)}/accounts?${query}`, {}, context.signal, ); return { items: response.data ?? [], cursor: response.page_info?.end_cursor }; }, 20); } private async request( path: string, init: RequestInit = {}, signal?: AbortSignal, ): Promise { const token = await this.token(); const requestAbort = combineSignals(signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)); const response = await (this.dependencies.fetch ?? globalThis.fetch)(`${API_BASE}${path}`, { ...init, headers: { authorization: `Bearer ${token}`, "content-type": "application/json", "x-pd-environment": this.config.environment, ...init.headers, }, signal: requestAbort, }); if (response.status === 401) this.accessToken = undefined; const body = await readPipedreamBody(response, requestAbort); if (!response.ok) { throw new Error( sanitizeConnectorError(`Pipedream returned HTTP ${response.status}: ${body}`, [token]), ); } return (body ? JSON.parse(body) : {}) as T; } private async token(): Promise { if (this.accessToken && this.accessToken.expiresAt > Date.now() + 60_000) { return this.accessToken.value; } if (this.accessTokenRequest) return this.accessTokenRequest; this.accessTokenRequest = this.fetchToken(); try { return await this.accessTokenRequest; } finally { this.accessTokenRequest = undefined; } } private async fetchToken(): Promise { const requestAbort = AbortSignal.timeout(REQUEST_TIMEOUT_MS); const response = await (this.dependencies.fetch ?? globalThis.fetch)( `${API_BASE}/v1/oauth/token`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ grant_type: "client_credentials", client_id: this.config.clientId, client_secret: this.config.clientSecret, scope: "connect:*", }), signal: requestAbort, }, ); const body = JSON.parse(await readPipedreamBody(response, requestAbort)) as { access_token?: string; expires_in?: number; error?: string; }; if (!response.ok || !body.access_token) { throw new Error(`Pipedream authentication failed: ${body.error ?? response.status}`); } this.accessToken = { value: body.access_token, expiresAt: Date.now() + (body.expires_in ?? 3_600) * 1_000, }; return body.access_token; } } async function readPipedreamBody(response: Response, signal: AbortSignal): Promise { const declared = Number(response.headers.get("content-length") ?? 0); if (Number.isFinite(declared) && declared > MAX_PIPEDREAM_RESPONSE_BYTES) { const cancel = response.body?.cancel() ?? Promise.resolve(); // A hanging cancel must not outlive the shared deadline. await withAbort( cancel.catch(() => undefined), signal, ).catch(() => undefined); throw new Error("Pipedream response is too large."); } try { const bytes = await readBodyCapped(response, MAX_PIPEDREAM_RESPONSE_BYTES, signal); return new TextDecoder().decode(bytes); } catch (error) { if (error instanceof Error && error.message === "Response is too large") { throw new Error("Pipedream response is too large."); } throw error; } }