@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.
@@ -0,0 +1,333 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { ConnectionView } from "@frockbot/configuration-core";
3
+ import {
4
+ connectionCompletionResponse,
5
+ createComposioBackendContribution,
6
+ createConfiguredComposioBackendContribution,
7
+ decodeAuthorizationState,
8
+ encodeAuthorizationState,
9
+ } from "./backend.js";
10
+ import type { ComposioClient } from "./composio-client.js";
11
+ import type { ComposioConnectionStore } from "./connections.js";
12
+
13
+ describe("configured Composio backend", () => {
14
+ const configuredHost = (secrets: Record<string, string>) => ({
15
+ callbackBaseUrl: "https://bot.frockbot.com",
16
+ readSecret: (name: string) => secrets[name],
17
+ storeFor: () => ({}) as ComposioConnectionStore,
18
+ markConnectionUnavailable: () => Promise.resolve("applied" as const),
19
+ });
20
+
21
+ test("requires a strong dedicated authorization-state secret without auth fallback", () => {
22
+ const invalidSecrets: Array<Record<string, string>> = [
23
+ {
24
+ COMPOSIO_API_KEY: "api-secret",
25
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
26
+ BETTER_AUTH_SECRET: "unrelated-auth-secret-with-enough-length",
27
+ },
28
+ {
29
+ COMPOSIO_API_KEY: "api-secret",
30
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
31
+ FROCKBOT_AUTHORIZATION_STATE_SECRET: "too-short",
32
+ },
33
+ {
34
+ COMPOSIO_API_KEY: "api-secret",
35
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
36
+ FROCKBOT_AUTHORIZATION_STATE_SECRET:
37
+ "replace-with-an-independent-random-secret",
38
+ },
39
+ {
40
+ COMPOSIO_API_KEY: "api-secret",
41
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
42
+ FROCKBOT_AUTHORIZATION_STATE_SECRET: "x".repeat(32),
43
+ },
44
+ {
45
+ COMPOSIO_API_KEY: "api-secret",
46
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
47
+ FROCKBOT_AUTHORIZATION_STATE_SECRET: "0123456789abcdef".repeat(4),
48
+ },
49
+ {
50
+ COMPOSIO_API_KEY: "api-secret",
51
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
52
+ BETTER_AUTH_SECRET: "shared-trust-authority-secret-0001",
53
+ FROCKBOT_AUTHORIZATION_STATE_SECRET:
54
+ "shared-trust-authority-secret-0001",
55
+ },
56
+ ];
57
+ for (const secrets of invalidSecrets) {
58
+ expect(() =>
59
+ createConfiguredComposioBackendContribution(configuredHost(secrets)),
60
+ ).toThrow("Composio backend Contribution is not configured");
61
+ }
62
+
63
+ expect(
64
+ createConfiguredComposioBackendContribution(
65
+ configuredHost({
66
+ COMPOSIO_API_KEY: "api-secret",
67
+ COMPOSIO_GMAIL_AUTH_CONFIG_ID: "gmail-config",
68
+ BETTER_AUTH_SECRET: "better-auth-secret-that-is-independent",
69
+ FROCKBOT_AUTHORIZATION_STATE_SECRET:
70
+ "6f0d6ae3ec5c4c448ef2ccdd08b0d4d834422c873244420f8879b6a2e99504fa",
71
+ }),
72
+ ).packageId,
73
+ ).toBe("composio");
74
+ });
75
+ });
76
+
77
+ describe("Composio authorization return handoff", () => {
78
+ test("returns desktop authorization to the fixed native protocol", () => {
79
+ const response = connectionCompletionResponse(
80
+ new URL("https://bot.frockbot.com/api/plugins/composio/callback"),
81
+ "desktop",
82
+ "ready",
83
+ "native-1",
84
+ );
85
+ expect(response.status).toBe(303);
86
+ expect(response.headers.get("location")).toBe(
87
+ "com.frockbot.desktop:/connections?status=ready&nonce=native-1",
88
+ );
89
+ });
90
+
91
+ test("returns browser authorization to the hosted application", () => {
92
+ const response = connectionCompletionResponse(
93
+ new URL("https://bot.frockbot.com/api/plugins/composio/callback"),
94
+ "browser",
95
+ "failed",
96
+ );
97
+ expect(response.headers.get("location")).toBe(
98
+ "https://bot.frockbot.com/?connection=composio-failed",
99
+ );
100
+ });
101
+
102
+ test("signs desktop state over User, Connection, and native return nonce", async () => {
103
+ const state = await encodeAuthorizationState(
104
+ {
105
+ schemaVersion: 1,
106
+ authorizationStateId: "state-1",
107
+ userId: "user-1",
108
+ connectionId: "connection-1",
109
+ returnTarget: "desktop",
110
+ expiresAt: Date.now() + 60_000,
111
+ nativeReturnNonce: "native-1",
112
+ },
113
+ "state-secret",
114
+ );
115
+ await expect(
116
+ decodeAuthorizationState(state, "state-secret"),
117
+ ).resolves.toMatchObject({
118
+ userId: "user-1",
119
+ connectionId: "connection-1",
120
+ nativeReturnNonce: "native-1",
121
+ });
122
+ const [payload, signature] = state.split(".");
123
+ if (!payload || !signature) throw new Error("expected signed state parts");
124
+ const tamperedSignature = `${signature[0] === "a" ? "b" : "a"}${signature.slice(1)}`;
125
+ await expect(
126
+ decodeAuthorizationState(
127
+ `${payload}.${tamperedSignature}`,
128
+ "state-secret",
129
+ ),
130
+ ).rejects.toThrow("invalid");
131
+ });
132
+ });
133
+
134
+ describe("Composio revoke route", () => {
135
+ test("rejects invalid Connection identifiers before resolving storage", async () => {
136
+ let storeLookups = 0;
137
+ const contribution = createComposioBackendContribution({
138
+ client: {} as ComposioClient,
139
+ callbackBaseUrl: "https://bot.frockbot.com",
140
+ authorizationStateSecret: "state-secret",
141
+ connectionTypes: {},
142
+ storeFor() {
143
+ storeLookups += 1;
144
+ throw new Error("invalid routes must not resolve storage");
145
+ },
146
+ });
147
+ const invalidIdentifiers = [
148
+ "%",
149
+ "invalid%2Fidentifier",
150
+ "-leading-hyphen",
151
+ "a".repeat(129),
152
+ "constructor",
153
+ "prototype",
154
+ "__proto__",
155
+ ];
156
+
157
+ for (const identifier of invalidIdentifiers) {
158
+ const url = new URL(
159
+ `https://bot.frockbot.com/api/plugins/composio/connections/${identifier}/revoke`,
160
+ );
161
+ const response = await contribution.route(
162
+ new Request(url, { method: "POST" }),
163
+ url,
164
+ { userId: "user-1", client: "browser" },
165
+ );
166
+ expect(response?.status).toBe(400);
167
+ if (!response) throw new Error("Composio revoke route was not handled");
168
+ const body: unknown = await response.json();
169
+ expect(body).toEqual({ error: "connectionId is invalid" });
170
+ }
171
+ expect(storeLookups).toBe(0);
172
+ });
173
+
174
+ test("passes one decoded valid Connection identifier to revocation", async () => {
175
+ let claimedConnectionId: string | undefined;
176
+ const connection: ConnectionView = {
177
+ connectionId: "connection-1",
178
+ packageId: "composio",
179
+ connectionTypeId: "gmail",
180
+ displayName: "Gmail",
181
+ state: "revoked",
182
+ safeMetadata: {},
183
+ };
184
+ const store = {
185
+ claimConnectionRevocation(_userId: string, connectionId: string) {
186
+ claimedConnectionId = connectionId;
187
+ return Promise.resolve({ phase: "done" as const, connection });
188
+ },
189
+ } as unknown as ComposioConnectionStore;
190
+ const contribution = createComposioBackendContribution({
191
+ client: {} as ComposioClient,
192
+ callbackBaseUrl: "https://bot.frockbot.com",
193
+ authorizationStateSecret: "state-secret",
194
+ connectionTypes: {},
195
+ storeFor: () => store,
196
+ });
197
+ const url = new URL(
198
+ "https://bot.frockbot.com/api/plugins/composio/connections/connection%2D1/revoke",
199
+ );
200
+
201
+ const response = await contribution.route(
202
+ new Request(url, {
203
+ method: "POST",
204
+ headers: { "content-type": "application/json" },
205
+ body: JSON.stringify({
206
+ schemaVersion: 1,
207
+ type: "connection/revoke",
208
+ }),
209
+ }),
210
+ url,
211
+ { userId: "user-1", client: "browser" },
212
+ );
213
+
214
+ expect(response?.status).toBe(200);
215
+ if (!response) throw new Error("Composio revoke route was not handled");
216
+ const body: unknown = await response.json();
217
+ expect(body).toEqual({ schemaVersion: 1, status: "revoked" });
218
+ expect(claimedConnectionId).toBe("connection-1");
219
+ });
220
+ });
221
+
222
+ describe("Composio Connection start route", () => {
223
+ test("rejects reserved and unconfigured identifiers before resolving storage", async () => {
224
+ let storeLookups = 0;
225
+ const contribution = createComposioBackendContribution({
226
+ client: {} as ComposioClient,
227
+ callbackBaseUrl: "https://bot.frockbot.com",
228
+ authorizationStateSecret: "state-secret",
229
+ connectionTypes: {
230
+ gmail: {
231
+ authConfigId: "gmail-auth",
232
+ displayName: "Gmail",
233
+ toolkitSlug: "gmail",
234
+ },
235
+ },
236
+ storeFor() {
237
+ storeLookups += 1;
238
+ throw new Error("invalid routes must not resolve storage");
239
+ },
240
+ });
241
+ const invalidCommands = [
242
+ { commandId: "__proto__", connectionTypeId: "gmail" },
243
+ { commandId: "constructor", connectionTypeId: "gmail" },
244
+ { commandId: "connection-1", connectionTypeId: "constructor" },
245
+ { commandId: "connection-1", connectionTypeId: "prototype" },
246
+ { commandId: "connection-1", connectionTypeId: "__proto__" },
247
+ { commandId: "connection-1", connectionTypeId: "unconfigured" },
248
+ ];
249
+
250
+ for (const input of invalidCommands) {
251
+ const url = new URL(
252
+ "https://bot.frockbot.com/api/plugins/composio/connections",
253
+ );
254
+ const response = await contribution.route(
255
+ new Request(url, {
256
+ method: "POST",
257
+ headers: { "content-type": "application/json" },
258
+ body: JSON.stringify({
259
+ schemaVersion: 1,
260
+ type: "connection/start",
261
+ ...input,
262
+ }),
263
+ }),
264
+ url,
265
+ { userId: "user-1", client: "browser" },
266
+ );
267
+ expect(response?.status).toBe(400);
268
+ }
269
+ expect(storeLookups).toBe(0);
270
+ });
271
+
272
+ test("rejects unsupported and inexact command envelopes before storage", async () => {
273
+ let storeLookups = 0;
274
+ const contribution = createComposioBackendContribution({
275
+ client: {} as ComposioClient,
276
+ callbackBaseUrl: "https://bot.frockbot.com",
277
+ authorizationStateSecret: "state-secret",
278
+ connectionTypes: {
279
+ gmail: {
280
+ authConfigId: "gmail-auth",
281
+ displayName: "Gmail",
282
+ toolkitSlug: "gmail",
283
+ },
284
+ },
285
+ storeFor() {
286
+ storeLookups += 1;
287
+ throw new Error("invalid commands must not resolve storage");
288
+ },
289
+ });
290
+ for (const body of [
291
+ {
292
+ schemaVersion: 2,
293
+ type: "connection/start",
294
+ commandId: "connection-1",
295
+ connectionTypeId: "gmail",
296
+ },
297
+ {
298
+ schemaVersion: 1,
299
+ type: "connection/start",
300
+ commandId: "connection-1",
301
+ connectionTypeId: "gmail",
302
+ extra: true,
303
+ },
304
+ ]) {
305
+ const url = new URL(
306
+ "https://bot.frockbot.com/api/plugins/composio/connections",
307
+ );
308
+ const response = await contribution.route(
309
+ new Request(url, { method: "POST", body: JSON.stringify(body) }),
310
+ url,
311
+ { userId: "user-1", client: "browser" },
312
+ );
313
+ expect(response?.status).toBe(400);
314
+ }
315
+ const revokeUrl = new URL(
316
+ "https://bot.frockbot.com/api/plugins/composio/connections/connection-1/revoke",
317
+ );
318
+ const revokeResponse = await contribution.route(
319
+ new Request(revokeUrl, {
320
+ method: "POST",
321
+ body: JSON.stringify({
322
+ schemaVersion: 1,
323
+ type: "connection/revoke",
324
+ extra: true,
325
+ }),
326
+ }),
327
+ revokeUrl,
328
+ { userId: "user-1", client: "browser" },
329
+ );
330
+ expect(revokeResponse?.status).toBe(400);
331
+ expect(storeLookups).toBe(0);
332
+ });
333
+ });