@frockbot/plugin-composio 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/backend.ts ADDED
@@ -0,0 +1,385 @@
1
+ import {
2
+ ConfigurationDecodeError,
3
+ decodeRevokeConnectionCommandV1,
4
+ decodeStartConnectionCommandV1,
5
+ isConnectionIdentifier,
6
+ type ConnectionView,
7
+ type StartConnectionCommandV1,
8
+ } from "@frockbot/configuration-core";
9
+ import {
10
+ decodeAuthorizationState,
11
+ encodeAuthorizationState,
12
+ isStrongAuthorizationStateSecretV1,
13
+ type AuthorizationState,
14
+ } from "@frockbot/connection-core";
15
+ import type { Plugin } from "cordis";
16
+ import { ComposioClient } from "./composio-client.js";
17
+ /**
18
+ * The signed callback `state` moved to `@frockbot/connection-core` when a
19
+ * second Package began minting one (`plugin-mcp`'s `mcp-oauth` driver). The
20
+ * wire format is unchanged, so a state minted before the move still verifies,
21
+ * and it is re-exported here because this Package's own surface promised it.
22
+ */
23
+ export {
24
+ decodeAuthorizationState,
25
+ encodeAuthorizationState,
26
+ type AuthorizationState,
27
+ } from "@frockbot/connection-core";
28
+ export type {
29
+ ConnectionCompletionResult,
30
+ RevokeConnectionResult,
31
+ StartConnectionResult,
32
+ } from "./backend-contracts.js";
33
+ import {
34
+ ComposioConnectionCoordinator,
35
+ DefinitiveConnectionOperationError,
36
+ type ComposioConnectionStore,
37
+ type ComposioConnectionTypeConfig,
38
+ } from "./connections.js";
39
+
40
+ function decodeConnectionIdentifier(value: unknown): string | undefined {
41
+ return isConnectionIdentifier(value) ? value : undefined;
42
+ }
43
+
44
+ function decodeConnectionPathIdentifier(value: string): string | undefined {
45
+ let decoded: string;
46
+ try {
47
+ decoded = decodeURIComponent(value);
48
+ } catch {
49
+ return undefined;
50
+ }
51
+ return decodeConnectionIdentifier(decoded);
52
+ }
53
+
54
+ export interface BackendRouteContext {
55
+ userId?: string;
56
+ client: "browser" | "desktop";
57
+ }
58
+
59
+ export interface BackendRouteContribution {
60
+ packageId: string;
61
+ publicRoute?(
62
+ request: Request,
63
+ url: URL,
64
+ context: BackendRouteContext,
65
+ ): Promise<Response | undefined>;
66
+ route(
67
+ request: Request,
68
+ url: URL,
69
+ context: BackendRouteContext,
70
+ ): Promise<Response | undefined>;
71
+ }
72
+
73
+ export interface ComposioBackendConfig {
74
+ client: ComposioClient;
75
+ callbackBaseUrl: string;
76
+ connectionTypes: Record<string, ComposioConnectionTypeConfig>;
77
+ authorizationStateSecret: string;
78
+ storeFor(userId: string): ComposioConnectionStore;
79
+ markBotUnavailable?: (
80
+ userId: string,
81
+ botId: string,
82
+ connectionId: string,
83
+ compensation: { id: string; expectedGeneration: string },
84
+ ) => Promise<"applied" | "stale">;
85
+ }
86
+
87
+ export interface ComposioBackendHost {
88
+ callbackBaseUrl: string;
89
+ readSecret(name: string): string | undefined;
90
+ storeFor(userId: string): ComposioConnectionStore;
91
+ markConnectionUnavailable(
92
+ userId: string,
93
+ botId: string,
94
+ connectionId: string,
95
+ compensation: { id: string; expectedGeneration: string },
96
+ ): Promise<"applied" | "stale">;
97
+ }
98
+
99
+ export function createConfiguredComposioBackendContribution(
100
+ host: ComposioBackendHost,
101
+ ): BackendRouteContribution {
102
+ const apiKey = host.readSecret("COMPOSIO_API_KEY");
103
+ const gmailAuthConfigId = host.readSecret("COMPOSIO_GMAIL_AUTH_CONFIG_ID");
104
+ const authorizationStateSecret = host.readSecret(
105
+ "FROCKBOT_AUTHORIZATION_STATE_SECRET",
106
+ );
107
+ const betterAuthSecret = host.readSecret("BETTER_AUTH_SECRET");
108
+ if (
109
+ !apiKey ||
110
+ !gmailAuthConfigId ||
111
+ !authorizationStateSecret ||
112
+ !isStrongAuthorizationStateSecretV1(authorizationStateSecret) ||
113
+ authorizationStateSecret === betterAuthSecret
114
+ ) {
115
+ throw new Error("Composio backend Contribution is not configured");
116
+ }
117
+ return createComposioBackendContribution({
118
+ client: new ComposioClient({ apiKey }),
119
+ storeFor: host.storeFor,
120
+ callbackBaseUrl: host.callbackBaseUrl,
121
+ authorizationStateSecret,
122
+ connectionTypes: {
123
+ gmail: {
124
+ authConfigId: gmailAuthConfigId,
125
+ displayName: "Gmail",
126
+ toolkitSlug: "gmail",
127
+ },
128
+ },
129
+ markBotUnavailable: host.markConnectionUnavailable,
130
+ });
131
+ }
132
+
133
+ export namespace createConfiguredComposioBackendContribution {
134
+ export function plugin(
135
+ host: ComposioBackendHost,
136
+ lifecycle: { mount(value: BackendRouteContribution): () => void },
137
+ ): Plugin {
138
+ return () =>
139
+ lifecycle.mount(createConfiguredComposioBackendContribution(host));
140
+ }
141
+ }
142
+
143
+ function jsonError(
144
+ status: number,
145
+ message: string,
146
+ options?: { definitive?: boolean },
147
+ ): Response {
148
+ return Response.json({ error: message, ...options }, { status });
149
+ }
150
+
151
+ function requiredUser(context: BackendRouteContext): string | Response {
152
+ return context.userId ?? jsonError(401, "authentication required");
153
+ }
154
+
155
+ function coordinator(
156
+ config: ComposioBackendConfig,
157
+ userId: string,
158
+ ): ComposioConnectionCoordinator {
159
+ return new ComposioConnectionCoordinator({
160
+ client: config.client,
161
+ store: config.storeFor(userId),
162
+ callbackBaseUrl: config.callbackBaseUrl,
163
+ connectionTypes: config.connectionTypes,
164
+ markBotUnavailable: config.markBotUnavailable,
165
+ });
166
+ }
167
+
168
+ export function connectionCompletionResponse(
169
+ url: URL,
170
+ target: "browser" | "desktop",
171
+ status: "ready" | "pending" | "failed",
172
+ nativeReturnNonce?: string,
173
+ ): Response {
174
+ const destination =
175
+ target === "desktop"
176
+ ? `com.frockbot.desktop:/connections?status=${status}${nativeReturnNonce ? `&nonce=${encodeURIComponent(nativeReturnNonce)}` : ""}`
177
+ : new URL(`/?connection=composio-${status}`, url.origin).toString();
178
+ return new Response(null, {
179
+ status: 303,
180
+ headers: { location: destination },
181
+ });
182
+ }
183
+
184
+ export function createComposioBackendContribution(
185
+ config: ComposioBackendConfig,
186
+ ): BackendRouteContribution {
187
+ const contribution: BackendRouteContribution = {
188
+ packageId: "composio",
189
+ async route(request, url, context) {
190
+ const isStart = url.pathname === "/api/plugins/composio/connections";
191
+ const revokeMatch = url.pathname.match(
192
+ /^\/api\/plugins\/composio\/connections\/([^/]+)\/revoke$/,
193
+ );
194
+ const isCallback = url.pathname === "/api/plugins/composio/callback";
195
+ if (!isStart && !revokeMatch && !isCallback) return undefined;
196
+
197
+ let revokeConnectionId: string | undefined;
198
+ if (revokeMatch) {
199
+ revokeConnectionId = decodeConnectionPathIdentifier(revokeMatch[1]);
200
+ if (!revokeConnectionId) {
201
+ return jsonError(400, "connectionId is invalid");
202
+ }
203
+ if (request.method !== "POST") {
204
+ return jsonError(405, "method not allowed");
205
+ }
206
+ }
207
+
208
+ let callbackState: AuthorizationState | undefined;
209
+ if (isCallback) {
210
+ const encodedState = url.searchParams.get("state");
211
+ if (!encodedState)
212
+ return jsonError(400, "Composio callback state is required");
213
+ try {
214
+ callbackState = await decodeAuthorizationState(
215
+ encodedState,
216
+ config.authorizationStateSecret,
217
+ );
218
+ } catch (error) {
219
+ return jsonError(
220
+ 400,
221
+ error instanceof Error ? error.message : "Connection failed",
222
+ );
223
+ }
224
+ }
225
+ const user = callbackState?.userId ?? requiredUser(context);
226
+ if (user instanceof Response) return user;
227
+
228
+ if (isStart) {
229
+ if (request.method !== "POST") {
230
+ return jsonError(405, "method not allowed");
231
+ }
232
+ let value: StartConnectionCommandV1;
233
+ try {
234
+ value = decodeStartConnectionCommandV1(await request.json());
235
+ } catch (error) {
236
+ return jsonError(
237
+ 400,
238
+ error instanceof Error
239
+ ? error.message
240
+ : "Connection request is invalid",
241
+ );
242
+ }
243
+ const { commandId, connectionTypeId } = value;
244
+ if (!Object.hasOwn(config.connectionTypes, connectionTypeId)) {
245
+ return jsonError(400, "connectionTypeId is invalid");
246
+ }
247
+ if (
248
+ (context.client === "desktop" && !value.nativeReturnNonce) ||
249
+ (context.client === "browser" &&
250
+ value.nativeReturnNonce !== undefined)
251
+ ) {
252
+ return jsonError(400, "nativeReturnNonce is invalid");
253
+ }
254
+ try {
255
+ const nativeReturnNonce =
256
+ context.client === "desktop" ? value.nativeReturnNonce : undefined;
257
+ const startInput = {
258
+ commandId,
259
+ connectionTypeId,
260
+ alias: value.alias,
261
+ returnTarget: context.client,
262
+ nativeReturnNonce,
263
+ };
264
+ const connections = coordinator(config, user);
265
+ const replay = await connections.replayStart(user, startInput);
266
+ if (replay) return Response.json(replay);
267
+ const authorizationStateId = crypto.randomUUID();
268
+ const authorizationStateExpiresAt = Date.now() + 10 * 60_000;
269
+ const callbackState = await encodeAuthorizationState(
270
+ {
271
+ schemaVersion: 1,
272
+ authorizationStateId,
273
+ userId: user,
274
+ connectionId: commandId,
275
+ returnTarget: context.client,
276
+ expiresAt: authorizationStateExpiresAt,
277
+ nativeReturnNonce,
278
+ },
279
+ config.authorizationStateSecret,
280
+ );
281
+ return Response.json(
282
+ await connections.start(user, {
283
+ ...startInput,
284
+ callbackState,
285
+ authorizationStateId,
286
+ authorizationStateExpiresAt,
287
+ nativeReturnNonce,
288
+ }),
289
+ { status: 201 },
290
+ );
291
+ } catch (error) {
292
+ if (error instanceof DefinitiveConnectionOperationError) {
293
+ return jsonError(409, error.message, { definitive: true });
294
+ }
295
+ return jsonError(
296
+ 500,
297
+ error instanceof Error ? error.message : "Connection failed",
298
+ );
299
+ }
300
+ }
301
+
302
+ if (revokeConnectionId) {
303
+ try {
304
+ decodeRevokeConnectionCommandV1(await request.json());
305
+ } catch (error) {
306
+ return jsonError(
307
+ 400,
308
+ error instanceof ConfigurationDecodeError
309
+ ? error.message
310
+ : "Connection revoke command is invalid",
311
+ );
312
+ }
313
+ try {
314
+ const connections = coordinator(config, user);
315
+ return Response.json(
316
+ await connections.revoke(user, revokeConnectionId),
317
+ );
318
+ } catch (error) {
319
+ return jsonError(
320
+ 500,
321
+ error instanceof Error ? error.message : "Revocation failed",
322
+ );
323
+ }
324
+ }
325
+
326
+ if (request.method !== "GET") {
327
+ return jsonError(405, "method not allowed");
328
+ }
329
+ const connections = coordinator(config, user);
330
+ const connectionId = callbackState?.connectionId;
331
+ const connectedAccountId =
332
+ url.searchParams.get("connected_account_id") ??
333
+ url.searchParams.get("connectedAccountId");
334
+ if (connectionId && url.searchParams.get("status") === "failed") {
335
+ try {
336
+ const result = await connections.fail(
337
+ user,
338
+ connectionId,
339
+ "Composio authorization was not completed",
340
+ callbackState!.authorizationStateId,
341
+ );
342
+ return connectionCompletionResponse(
343
+ url,
344
+ result.returnTarget,
345
+ result.status,
346
+ result.nativeReturnNonce,
347
+ );
348
+ } catch (error) {
349
+ return jsonError(
350
+ 500,
351
+ error instanceof Error ? error.message : "Connection failed",
352
+ );
353
+ }
354
+ }
355
+ if (!connectionId || !connectedAccountId) {
356
+ return jsonError(400, "Composio callback is incomplete");
357
+ }
358
+ try {
359
+ const result = await connections.complete(user, {
360
+ connectionId,
361
+ connectedAccountId,
362
+ authorizationStateId: callbackState!.authorizationStateId,
363
+ });
364
+ return connectionCompletionResponse(
365
+ url,
366
+ result.returnTarget,
367
+ result.status,
368
+ result.nativeReturnNonce,
369
+ );
370
+ } catch (error) {
371
+ return jsonError(
372
+ 400,
373
+ error instanceof Error ? error.message : "Connection failed",
374
+ );
375
+ }
376
+ },
377
+ };
378
+ contribution.publicRoute = (request, url, context) =>
379
+ url.pathname === "/api/plugins/composio/callback"
380
+ ? contribution.route(request, url, context)
381
+ : Promise.resolve(undefined);
382
+ return contribution;
383
+ }
384
+
385
+ export type { ComposioConnectionStore, ConnectionView };
@@ -0,0 +1,208 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { ComposioClient } from "./composio-client.ts";
3
+
4
+ describe("ComposioClient", () => {
5
+ test("creates a hosted Connect Link without exposing the project key", async () => {
6
+ let captured: { url: string; init?: RequestInit } | undefined;
7
+ const client = new ComposioClient({
8
+ apiKey: "project-secret",
9
+ fetch: (input: string | URL | Request, init?: RequestInit) => {
10
+ captured = { url: String(input), init };
11
+ return Promise.resolve(
12
+ Response.json(
13
+ {
14
+ connected_account_id: "ca_123",
15
+ redirect_url: "https://connect.composio.dev/link/ln_123",
16
+ expires_at: "2026-08-28T01:00:00.000Z",
17
+ },
18
+ { status: 201 },
19
+ ),
20
+ );
21
+ },
22
+ });
23
+
24
+ const result = await client.createConnectLink({
25
+ userId: "user-123",
26
+ authConfigId: "ac_gmail",
27
+ callbackUrl: "https://bot.frockbot.com/api/connections/composio/callback",
28
+ alias: "personal",
29
+ });
30
+
31
+ expect(result).toEqual({
32
+ connectedAccountId: "ca_123",
33
+ redirectUrl: "https://connect.composio.dev/link/ln_123",
34
+ expiresAt: "2026-08-28T01:00:00.000Z",
35
+ });
36
+ expect(captured?.url).toBe(
37
+ "https://backend.composio.dev/api/v3.1/connected_accounts/link",
38
+ );
39
+ expect(new Headers(captured?.init?.headers).get("x-api-key")).toBe(
40
+ "project-secret",
41
+ );
42
+ expect(JSON.parse(String(captured?.init?.body))).toEqual({
43
+ auth_config_id: "ac_gmail",
44
+ user_id: "user-123",
45
+ callback_url:
46
+ "https://bot.frockbot.com/api/connections/composio/callback",
47
+ alias: "personal",
48
+ });
49
+ });
50
+
51
+ test("executes a tool against an explicit connected account", async () => {
52
+ let body: unknown;
53
+ const client = new ComposioClient({
54
+ apiKey: "secret",
55
+ fetch: (_input: string | URL | Request, init?: RequestInit) => {
56
+ body = JSON.parse(String(init?.body));
57
+ return Promise.resolve(Response.json({ data: { ok: true } }));
58
+ },
59
+ });
60
+
61
+ expect(
62
+ await client.executeTool({
63
+ toolSlug: "GMAIL_FETCH_EMAILS",
64
+ userId: "user-123",
65
+ connectedAccountId: "ca_123",
66
+ arguments: { max_results: 5 },
67
+ }),
68
+ ).toEqual({ data: { ok: true } });
69
+ expect(body).toEqual({
70
+ user_id: "user-123",
71
+ connected_account_id: "ca_123",
72
+ version: "latest",
73
+ arguments: { max_results: 5 },
74
+ });
75
+ });
76
+
77
+ test("bounds provider failures without leaking response bodies", async () => {
78
+ const client = new ComposioClient({
79
+ apiKey: "secret",
80
+ fetch: () =>
81
+ Promise.resolve(new Response("token=provider-secret", { status: 401 })),
82
+ });
83
+
84
+ let failure: unknown;
85
+ try {
86
+ await client.createConnectLink({
87
+ userId: "user-123",
88
+ authConfigId: "ac_gmail",
89
+ callbackUrl: "https://bot.frockbot.com/callback",
90
+ });
91
+ } catch (error) {
92
+ failure = error;
93
+ }
94
+ expect(failure).toBeInstanceOf(Error);
95
+ expect(failure instanceof Error ? failure.message : "").toBe(
96
+ "Composio request failed (401)",
97
+ );
98
+ });
99
+
100
+ test("follows every connected-account cursor page", async () => {
101
+ const requested: string[] = [];
102
+ const client = new ComposioClient({
103
+ apiKey: "secret",
104
+ fetch: (input) => {
105
+ const url = new URL(String(input));
106
+ requested.push(url.toString());
107
+ if (!url.searchParams.has("cursor")) {
108
+ return Promise.resolve(
109
+ Response.json({
110
+ items: [
111
+ {
112
+ id: "ca_first",
113
+ status: "INITIALIZING",
114
+ toolkit: { slug: "gmail" },
115
+ alias: "other",
116
+ },
117
+ ],
118
+ next_cursor: "page-2",
119
+ }),
120
+ );
121
+ }
122
+ return Promise.resolve(
123
+ Response.json({
124
+ items: [
125
+ {
126
+ id: "ca_second",
127
+ status: "ACTIVE",
128
+ toolkit: { slug: "gmail" },
129
+ alias: "target",
130
+ },
131
+ ],
132
+ next_cursor: null,
133
+ }),
134
+ );
135
+ },
136
+ });
137
+
138
+ const accounts = await client.listConnectedAccounts("user-1");
139
+
140
+ expect(accounts.map((account) => account.id)).toEqual([
141
+ "ca_first",
142
+ "ca_second",
143
+ ]);
144
+ expect(new URL(requested[1]!).searchParams.get("cursor")).toBe("page-2");
145
+ expect(new URL(requested[1]!).searchParams.get("user_ids")).toBe("user-1");
146
+ });
147
+
148
+ test("rejects repeated and unbounded account cursors", async () => {
149
+ let loopCalls = 0;
150
+ const looping = new ComposioClient({
151
+ apiKey: "secret",
152
+ fetch: () => {
153
+ loopCalls += 1;
154
+ return Promise.resolve(
155
+ Response.json({ items: [], next_cursor: "same-cursor" }),
156
+ );
157
+ },
158
+ });
159
+ await expect(looping.listConnectedAccounts("user-1")).rejects.toThrow(
160
+ "invalid account cursor",
161
+ );
162
+ expect(loopCalls).toBe(2);
163
+
164
+ let boundedCalls = 0;
165
+ const unbounded = new ComposioClient({
166
+ apiKey: "secret",
167
+ fetch: () => {
168
+ boundedCalls += 1;
169
+ return Promise.resolve(
170
+ Response.json({ items: [], next_cursor: `page-${boundedCalls}` }),
171
+ );
172
+ },
173
+ });
174
+ await expect(unbounded.listConnectedAccounts("user-1")).rejects.toThrow(
175
+ "pagination exceeded its limit",
176
+ );
177
+ expect(boundedCalls).toBe(100);
178
+ });
179
+
180
+ test("retrieves a known connected account by exact ID", async () => {
181
+ let requestedUrl = "";
182
+ const client = new ComposioClient({
183
+ apiKey: "secret",
184
+ fetch: (input) => {
185
+ requestedUrl = String(input);
186
+ return Promise.resolve(
187
+ Response.json({
188
+ id: "ca_exact",
189
+ user_id: "user-1",
190
+ status: "REVOKED",
191
+ toolkit: { slug: "gmail" },
192
+ }),
193
+ );
194
+ },
195
+ });
196
+
197
+ await expect(client.getConnectedAccount("ca_exact")).resolves.toEqual({
198
+ id: "ca_exact",
199
+ userId: "user-1",
200
+ status: "REVOKED",
201
+ toolkitSlug: "gmail",
202
+ alias: undefined,
203
+ });
204
+ expect(requestedUrl).toBe(
205
+ "https://backend.composio.dev/api/v3.1/connected_accounts/ca_exact",
206
+ );
207
+ });
208
+ });