@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/frockbot.json +34 -0
- package/package.json +37 -6
- package/src/agent.test.ts +207 -0
- package/src/agent.ts +227 -0
- package/src/backend-contracts.ts +8 -0
- package/src/backend.test.ts +333 -0
- package/src/backend.ts +385 -0
- package/src/composio-client.test.ts +208 -0
- package/src/composio-client.ts +232 -0
- package/src/connection-recovery.test.ts +54 -0
- package/src/connection-recovery.ts +52 -0
- package/src/connections.ts +861 -0
- package/src/dependency-coordination.test.ts +136 -0
- package/src/dependency-coordination.ts +140 -0
- package/src/index.ts +7 -0
- package/src/manifest.ts +3 -0
- package/src/provider-reconciliation.ts +71 -0
- package/src/user-configuration.test.ts +1937 -0
- package/src/user-configuration.ts +1366 -0
- package/tsconfig.json +15 -0
- package/README.md +0 -3
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isPublicIdentifier,
|
|
3
|
+
type ConnectionView,
|
|
4
|
+
} from "@frockbot/configuration-core";
|
|
5
|
+
import type {
|
|
6
|
+
ComposioClient,
|
|
7
|
+
ConnectLink,
|
|
8
|
+
} from "@frockbot/plugin-composio/client";
|
|
9
|
+
import type {
|
|
10
|
+
ConnectionCompletionResult,
|
|
11
|
+
RevokeConnectionResult,
|
|
12
|
+
StartConnectionResult,
|
|
13
|
+
} from "./backend-contracts.js";
|
|
14
|
+
import { isSettledBotCompensation } from "./connection-recovery.js";
|
|
15
|
+
import {
|
|
16
|
+
linkReconciliationDisposition,
|
|
17
|
+
reconcileComposioProviderConnection,
|
|
18
|
+
} from "./provider-reconciliation.js";
|
|
19
|
+
|
|
20
|
+
export interface ComposioConnectionStore {
|
|
21
|
+
isPackageInstalled(userId: string, packageId: string): Promise<boolean>;
|
|
22
|
+
getConnection(
|
|
23
|
+
userId: string,
|
|
24
|
+
connectionId: string,
|
|
25
|
+
): Promise<ConnectionView | undefined>;
|
|
26
|
+
startConnection(
|
|
27
|
+
userId: string,
|
|
28
|
+
input: {
|
|
29
|
+
connectionId: string;
|
|
30
|
+
packageId: string;
|
|
31
|
+
connectionTypeId: string;
|
|
32
|
+
displayName: string;
|
|
33
|
+
safeMetadata?: ConnectionView["safeMetadata"];
|
|
34
|
+
},
|
|
35
|
+
): Promise<boolean>;
|
|
36
|
+
recordConnectLinkResult(
|
|
37
|
+
userId: string,
|
|
38
|
+
connectionId: string,
|
|
39
|
+
safeMetadata: ConnectionView["safeMetadata"],
|
|
40
|
+
): Promise<boolean>;
|
|
41
|
+
recordLinkReconciliationIdentity(
|
|
42
|
+
userId: string,
|
|
43
|
+
connectionId: string,
|
|
44
|
+
safeMetadata: ConnectionView["safeMetadata"],
|
|
45
|
+
): Promise<boolean>;
|
|
46
|
+
claimLostLinkCleanup(
|
|
47
|
+
userId: string,
|
|
48
|
+
connectionId: string,
|
|
49
|
+
safeMetadata: ConnectionView["safeMetadata"],
|
|
50
|
+
): Promise<{
|
|
51
|
+
phase: "provider" | "pending" | "done";
|
|
52
|
+
connection: ConnectionView;
|
|
53
|
+
}>;
|
|
54
|
+
finishConnectionAuthorization(
|
|
55
|
+
userId: string,
|
|
56
|
+
connectionId: string,
|
|
57
|
+
update: {
|
|
58
|
+
state: "ready" | "failed";
|
|
59
|
+
safeMetadata?: ConnectionView["safeMetadata"];
|
|
60
|
+
failure?: string;
|
|
61
|
+
authorizationStateId?: string;
|
|
62
|
+
},
|
|
63
|
+
): Promise<boolean>;
|
|
64
|
+
recordAssignmentCompensated(
|
|
65
|
+
userId: string,
|
|
66
|
+
connectionId: string,
|
|
67
|
+
compensationId: string,
|
|
68
|
+
): Promise<boolean>;
|
|
69
|
+
requireConnectionReconciliation(
|
|
70
|
+
userId: string,
|
|
71
|
+
connectionId: string,
|
|
72
|
+
operation: "link" | "revoke",
|
|
73
|
+
failure: string,
|
|
74
|
+
): Promise<boolean>;
|
|
75
|
+
claimConnectionRevocation(
|
|
76
|
+
userId: string,
|
|
77
|
+
connectionId: string,
|
|
78
|
+
recoveredSafeMetadata?: ConnectionView["safeMetadata"],
|
|
79
|
+
): Promise<{
|
|
80
|
+
phase: "provider" | "finalize" | "pending" | "done";
|
|
81
|
+
connection: ConnectionView;
|
|
82
|
+
}>;
|
|
83
|
+
recordRevocationProviderCompleted(
|
|
84
|
+
userId: string,
|
|
85
|
+
connectionId: string,
|
|
86
|
+
): Promise<boolean>;
|
|
87
|
+
finishConnectionRevocation(
|
|
88
|
+
userId: string,
|
|
89
|
+
connectionId: string,
|
|
90
|
+
): Promise<boolean>;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ComposioConnectionTypeConfig {
|
|
94
|
+
authConfigId: string;
|
|
95
|
+
displayName: string;
|
|
96
|
+
toolkitSlug: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface ComposioConnectionCoordinatorConfig {
|
|
100
|
+
client: ComposioClient;
|
|
101
|
+
store: ComposioConnectionStore;
|
|
102
|
+
callbackBaseUrl: string;
|
|
103
|
+
connectionTypes: Record<string, ComposioConnectionTypeConfig>;
|
|
104
|
+
markBotUnavailable?: (
|
|
105
|
+
userId: string,
|
|
106
|
+
botId: string,
|
|
107
|
+
connectionId: string,
|
|
108
|
+
compensation: { id: string; expectedGeneration: string },
|
|
109
|
+
) => Promise<"applied" | "stale">;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export class DefinitiveConnectionOperationError extends Error {
|
|
113
|
+
constructor(message: string) {
|
|
114
|
+
super(message);
|
|
115
|
+
this.name = "DefinitiveConnectionOperationError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function durableCompletionResult(
|
|
120
|
+
connection: ConnectionView,
|
|
121
|
+
): ConnectionCompletionResult | undefined {
|
|
122
|
+
const returnTarget =
|
|
123
|
+
connection.safeMetadata.returnTarget === "desktop" ? "desktop" : "browser";
|
|
124
|
+
const nativeReturnNonce =
|
|
125
|
+
typeof connection.safeMetadata.nativeReturnNonce === "string"
|
|
126
|
+
? connection.safeMetadata.nativeReturnNonce
|
|
127
|
+
: undefined;
|
|
128
|
+
if (connection.state === "ready" || connection.state === "failed") {
|
|
129
|
+
return {
|
|
130
|
+
returnTarget,
|
|
131
|
+
status: connection.state,
|
|
132
|
+
nativeReturnNonce,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function connectionStartCommandFingerprintV1(
|
|
139
|
+
userId: string,
|
|
140
|
+
input: {
|
|
141
|
+
connectionTypeId: string;
|
|
142
|
+
alias?: string;
|
|
143
|
+
returnTarget: "browser" | "desktop";
|
|
144
|
+
nativeReturnNonce?: string;
|
|
145
|
+
},
|
|
146
|
+
): string {
|
|
147
|
+
return `connection-start-command-v1:${JSON.stringify({
|
|
148
|
+
userId,
|
|
149
|
+
packageId: "composio",
|
|
150
|
+
connectionTypeId: input.connectionTypeId,
|
|
151
|
+
alias: input.alias ?? null,
|
|
152
|
+
safeMetadata: {
|
|
153
|
+
returnTarget: input.returnTarget,
|
|
154
|
+
nativeReturnNonce: input.nativeReturnNonce ?? null,
|
|
155
|
+
},
|
|
156
|
+
})}`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function decodeConnectionStartReplayV1(
|
|
160
|
+
connection: ConnectionView,
|
|
161
|
+
commandFingerprint: string,
|
|
162
|
+
): StartConnectionResult | undefined {
|
|
163
|
+
const value = connection.safeMetadata.connectionStartReplay;
|
|
164
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
if (
|
|
168
|
+
value.schemaVersion !== 1 ||
|
|
169
|
+
value.commandFingerprint !== commandFingerprint ||
|
|
170
|
+
value.connectionId !== connection.connectionId ||
|
|
171
|
+
value.status !== "ready" ||
|
|
172
|
+
value.redirectUrl !== undefined ||
|
|
173
|
+
value.expiresAt !== undefined ||
|
|
174
|
+
(value.nativeReturnNonce !== undefined &&
|
|
175
|
+
typeof value.nativeReturnNonce !== "string")
|
|
176
|
+
) {
|
|
177
|
+
return undefined;
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
schemaVersion: 1,
|
|
181
|
+
status: "ready",
|
|
182
|
+
connectionId: value.connectionId,
|
|
183
|
+
...(value.nativeReturnNonce
|
|
184
|
+
? { nativeReturnNonce: value.nativeReturnNonce }
|
|
185
|
+
: {}),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export class ComposioConnectionCoordinator {
|
|
190
|
+
constructor(private readonly config: ComposioConnectionCoordinatorConfig) {}
|
|
191
|
+
|
|
192
|
+
private async retirePendingLink(
|
|
193
|
+
userId: string,
|
|
194
|
+
connectionId: string,
|
|
195
|
+
safeMetadata: ConnectionView["safeMetadata"],
|
|
196
|
+
): Promise<never> {
|
|
197
|
+
const cleanup = await this.config.store.claimLostLinkCleanup(
|
|
198
|
+
userId,
|
|
199
|
+
connectionId,
|
|
200
|
+
safeMetadata,
|
|
201
|
+
);
|
|
202
|
+
if (cleanup.phase === "done") {
|
|
203
|
+
throw new DefinitiveConnectionOperationError(
|
|
204
|
+
"Connection authorization was retired; retry with a new operation",
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
if (cleanup.phase === "provider") {
|
|
208
|
+
const connectedAccountId = safeMetadata.connectedAccountId;
|
|
209
|
+
if (typeof connectedAccountId !== "string") {
|
|
210
|
+
throw new Error("Pending Link cleanup identity is invalid");
|
|
211
|
+
}
|
|
212
|
+
let providerError: unknown;
|
|
213
|
+
try {
|
|
214
|
+
await this.config.client.revokeConnectedAccount(connectedAccountId);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
providerError = error;
|
|
217
|
+
}
|
|
218
|
+
await this.config.store.requireConnectionReconciliation(
|
|
219
|
+
userId,
|
|
220
|
+
connectionId,
|
|
221
|
+
"revoke",
|
|
222
|
+
"Lost Connect Link cleanup requires provider reconciliation",
|
|
223
|
+
);
|
|
224
|
+
if (providerError !== undefined) throw providerError;
|
|
225
|
+
}
|
|
226
|
+
throw new Error("Connection cleanup requires reconciliation");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
async replayStart(
|
|
230
|
+
userId: string,
|
|
231
|
+
input: {
|
|
232
|
+
commandId: string;
|
|
233
|
+
connectionTypeId: string;
|
|
234
|
+
alias?: string;
|
|
235
|
+
returnTarget?: "browser" | "desktop";
|
|
236
|
+
nativeReturnNonce?: string;
|
|
237
|
+
},
|
|
238
|
+
): Promise<StartConnectionResult | undefined> {
|
|
239
|
+
if (!isPublicIdentifier(input.commandId)) {
|
|
240
|
+
throw new Error("Connection commandId is invalid");
|
|
241
|
+
}
|
|
242
|
+
const commandFingerprint = connectionStartCommandFingerprintV1(userId, {
|
|
243
|
+
connectionTypeId: input.connectionTypeId,
|
|
244
|
+
alias: input.alias?.trim() || undefined,
|
|
245
|
+
returnTarget: input.returnTarget ?? "browser",
|
|
246
|
+
nativeReturnNonce: input.nativeReturnNonce,
|
|
247
|
+
});
|
|
248
|
+
const existing = await this.config.store.getConnection(
|
|
249
|
+
userId,
|
|
250
|
+
input.commandId,
|
|
251
|
+
);
|
|
252
|
+
if (!existing) return undefined;
|
|
253
|
+
if (existing.safeMetadata.startCommandFingerprint !== commandFingerprint) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`Connection command idempotency key "${input.commandId}" was reused for a different command`,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
if (existing.state !== "ready") return undefined;
|
|
259
|
+
const replay = decodeConnectionStartReplayV1(existing, commandFingerprint);
|
|
260
|
+
if (!replay) {
|
|
261
|
+
throw new Error("Connection command replay snapshot is invalid");
|
|
262
|
+
}
|
|
263
|
+
return replay;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async start(
|
|
267
|
+
userId: string,
|
|
268
|
+
input: {
|
|
269
|
+
commandId: string;
|
|
270
|
+
connectionTypeId: string;
|
|
271
|
+
alias?: string;
|
|
272
|
+
returnTarget?: "browser" | "desktop";
|
|
273
|
+
callbackState: string;
|
|
274
|
+
authorizationStateId: string;
|
|
275
|
+
authorizationStateExpiresAt: number;
|
|
276
|
+
nativeReturnNonce?: string;
|
|
277
|
+
},
|
|
278
|
+
): Promise<StartConnectionResult> {
|
|
279
|
+
if (
|
|
280
|
+
!input.callbackState ||
|
|
281
|
+
!input.authorizationStateId ||
|
|
282
|
+
!Number.isFinite(input.authorizationStateExpiresAt) ||
|
|
283
|
+
input.authorizationStateExpiresAt <= Date.now()
|
|
284
|
+
) {
|
|
285
|
+
throw new Error("Connection authorization state is invalid");
|
|
286
|
+
}
|
|
287
|
+
const terminalReplay = await this.replayStart(userId, input);
|
|
288
|
+
if (terminalReplay) return terminalReplay;
|
|
289
|
+
if (!isPublicIdentifier(input.commandId)) {
|
|
290
|
+
throw new Error("Connection commandId is invalid");
|
|
291
|
+
}
|
|
292
|
+
const connectionId = input.commandId;
|
|
293
|
+
const alias = input.alias?.trim() || undefined;
|
|
294
|
+
const returnTarget = input.returnTarget ?? "browser";
|
|
295
|
+
const commandFingerprint = connectionStartCommandFingerprintV1(userId, {
|
|
296
|
+
connectionTypeId: input.connectionTypeId,
|
|
297
|
+
alias,
|
|
298
|
+
returnTarget,
|
|
299
|
+
nativeReturnNonce: input.nativeReturnNonce,
|
|
300
|
+
});
|
|
301
|
+
const authorizationStateExpiresAt = input.authorizationStateExpiresAt;
|
|
302
|
+
const stored = await this.config.store.getConnection(userId, connectionId);
|
|
303
|
+
let type: ComposioConnectionTypeConfig | undefined;
|
|
304
|
+
let claimed = false;
|
|
305
|
+
if (!stored) {
|
|
306
|
+
if (!(await this.config.store.isPackageInstalled(userId, "composio"))) {
|
|
307
|
+
throw new Error("Composio Package is not installed");
|
|
308
|
+
}
|
|
309
|
+
type = this.config.connectionTypes[input.connectionTypeId];
|
|
310
|
+
if (!type) throw new Error("Unknown Composio Connection Type");
|
|
311
|
+
claimed = await this.config.store.startConnection(userId, {
|
|
312
|
+
connectionId,
|
|
313
|
+
packageId: "composio",
|
|
314
|
+
connectionTypeId: input.connectionTypeId,
|
|
315
|
+
displayName: alias ?? type.displayName,
|
|
316
|
+
safeMetadata: {
|
|
317
|
+
toolkitSlug: type.toolkitSlug,
|
|
318
|
+
providerAlias: connectionId,
|
|
319
|
+
returnTarget,
|
|
320
|
+
startCommandFingerprint: commandFingerprint,
|
|
321
|
+
authorizationStateId: input.authorizationStateId,
|
|
322
|
+
authorizationStateExpiresAt,
|
|
323
|
+
...(input.nativeReturnNonce
|
|
324
|
+
? { nativeReturnNonce: input.nativeReturnNonce }
|
|
325
|
+
: {}),
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
if (!claimed) {
|
|
330
|
+
const existing =
|
|
331
|
+
stored ?? (await this.config.store.getConnection(userId, connectionId));
|
|
332
|
+
if (
|
|
333
|
+
existing?.safeMetadata.startCommandFingerprint !== commandFingerprint
|
|
334
|
+
) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Connection command idempotency key "${input.commandId}" was reused for a different command`,
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const admittedToolkitSlug = existing.safeMetadata.toolkitSlug;
|
|
340
|
+
if (typeof admittedToolkitSlug !== "string") {
|
|
341
|
+
throw new Error("Connection command snapshot is invalid");
|
|
342
|
+
}
|
|
343
|
+
if (existing.state === "ready") {
|
|
344
|
+
const replay = decodeConnectionStartReplayV1(
|
|
345
|
+
existing,
|
|
346
|
+
commandFingerprint,
|
|
347
|
+
);
|
|
348
|
+
if (!replay) {
|
|
349
|
+
throw new Error("Connection command replay snapshot is invalid");
|
|
350
|
+
}
|
|
351
|
+
return replay;
|
|
352
|
+
}
|
|
353
|
+
const redirectUrl = existing?.safeMetadata.redirectUrl;
|
|
354
|
+
const expiresAt = existing?.safeMetadata.expiresAt;
|
|
355
|
+
const expiry =
|
|
356
|
+
typeof expiresAt === "string" ? Date.parse(expiresAt) : Number.NaN;
|
|
357
|
+
const authorizationStateExpiresAt =
|
|
358
|
+
existing?.safeMetadata.authorizationStateExpiresAt;
|
|
359
|
+
const authorizationStateExpiry =
|
|
360
|
+
typeof authorizationStateExpiresAt === "number"
|
|
361
|
+
? authorizationStateExpiresAt
|
|
362
|
+
: Number.NaN;
|
|
363
|
+
const now = Date.now();
|
|
364
|
+
const authorizationStateExpired =
|
|
365
|
+
!Number.isFinite(authorizationStateExpiry) ||
|
|
366
|
+
authorizationStateExpiry <= now;
|
|
367
|
+
const persistedLinkExpired =
|
|
368
|
+
typeof redirectUrl === "string" &&
|
|
369
|
+
(!Number.isFinite(expiry) || expiry <= now);
|
|
370
|
+
const revocationRequested =
|
|
371
|
+
existing.safeMetadata.revocationRequested === true;
|
|
372
|
+
if (existing.state === "failed" || existing.state === "revoked") {
|
|
373
|
+
throw new DefinitiveConnectionOperationError(
|
|
374
|
+
"Connection authorization expired; retry with a new operation",
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
if (
|
|
378
|
+
existing?.state === "authorizing" &&
|
|
379
|
+
!authorizationStateExpired &&
|
|
380
|
+
typeof redirectUrl === "string" &&
|
|
381
|
+
typeof expiresAt === "string" &&
|
|
382
|
+
Number.isFinite(expiry) &&
|
|
383
|
+
expiry > now
|
|
384
|
+
) {
|
|
385
|
+
return {
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
status: "authorization-required",
|
|
388
|
+
connectionId,
|
|
389
|
+
redirectUrl,
|
|
390
|
+
expiresAt,
|
|
391
|
+
nativeReturnNonce:
|
|
392
|
+
typeof existing?.safeMetadata.nativeReturnNonce === "string"
|
|
393
|
+
? existing.safeMetadata.nativeReturnNonce
|
|
394
|
+
: undefined,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
const providerAlias = existing.safeMetadata.providerAlias;
|
|
398
|
+
const linkReconciliationRequired =
|
|
399
|
+
(existing.state === "reconciliation-required" &&
|
|
400
|
+
existing.safeMetadata.reconciliationOperation === "link") ||
|
|
401
|
+
(existing.state === "authorizing" &&
|
|
402
|
+
(authorizationStateExpired || persistedLinkExpired) &&
|
|
403
|
+
typeof providerAlias === "string");
|
|
404
|
+
if (linkReconciliationRequired) {
|
|
405
|
+
if (typeof providerAlias !== "string") {
|
|
406
|
+
throw new Error("Connection command snapshot is invalid");
|
|
407
|
+
}
|
|
408
|
+
if (existing.state === "authorizing") {
|
|
409
|
+
const scheduled =
|
|
410
|
+
await this.config.store.requireConnectionReconciliation(
|
|
411
|
+
userId,
|
|
412
|
+
connectionId,
|
|
413
|
+
"link",
|
|
414
|
+
"Expired authorization requires provider reconciliation",
|
|
415
|
+
);
|
|
416
|
+
if (!scheduled) {
|
|
417
|
+
const replay = await this.replayStart(userId, input);
|
|
418
|
+
if (replay) return replay;
|
|
419
|
+
throw new Error(
|
|
420
|
+
"Connection authorization changed during reconciliation",
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
const reconciliation = await reconcileComposioProviderConnection(
|
|
425
|
+
this.config.client,
|
|
426
|
+
{
|
|
427
|
+
operation: "link",
|
|
428
|
+
userId,
|
|
429
|
+
providerAlias,
|
|
430
|
+
toolkitSlug: admittedToolkitSlug,
|
|
431
|
+
},
|
|
432
|
+
);
|
|
433
|
+
const account =
|
|
434
|
+
reconciliation.status === "active"
|
|
435
|
+
? reconciliation.account
|
|
436
|
+
: reconciliation.status === "pending" ||
|
|
437
|
+
reconciliation.status === "failed" ||
|
|
438
|
+
reconciliation.status === "revoked"
|
|
439
|
+
? reconciliation.account
|
|
440
|
+
: undefined;
|
|
441
|
+
const safeMetadata = account
|
|
442
|
+
? {
|
|
443
|
+
...existing.safeMetadata,
|
|
444
|
+
connectedAccountId: account.id,
|
|
445
|
+
toolkitSlug: account.toolkitSlug,
|
|
446
|
+
authorizationStateConsumed: false,
|
|
447
|
+
}
|
|
448
|
+
: undefined;
|
|
449
|
+
if (revocationRequested && reconciliation.status === "absent") {
|
|
450
|
+
const completed =
|
|
451
|
+
await this.config.store.recordRevocationProviderCompleted(
|
|
452
|
+
userId,
|
|
453
|
+
connectionId,
|
|
454
|
+
);
|
|
455
|
+
if (completed) {
|
|
456
|
+
await this.config.store.finishConnectionRevocation(
|
|
457
|
+
userId,
|
|
458
|
+
connectionId,
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
throw new DefinitiveConnectionOperationError(
|
|
462
|
+
"Connection was revoked during authorization",
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
if (revocationRequested && safeMetadata) {
|
|
466
|
+
if (reconciliation.status === "revoked") {
|
|
467
|
+
const claim = await this.config.store.claimConnectionRevocation(
|
|
468
|
+
userId,
|
|
469
|
+
connectionId,
|
|
470
|
+
safeMetadata,
|
|
471
|
+
);
|
|
472
|
+
if (claim.phase !== "done") {
|
|
473
|
+
await this.config.store.recordRevocationProviderCompleted(
|
|
474
|
+
userId,
|
|
475
|
+
connectionId,
|
|
476
|
+
);
|
|
477
|
+
await this.config.store.finishConnectionRevocation(
|
|
478
|
+
userId,
|
|
479
|
+
connectionId,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
throw new DefinitiveConnectionOperationError(
|
|
483
|
+
"Connection was revoked during authorization",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
await this.revoke(userId, connectionId, safeMetadata);
|
|
487
|
+
throw new DefinitiveConnectionOperationError(
|
|
488
|
+
"Connection was revoked during authorization",
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
const disposition = linkReconciliationDisposition(reconciliation);
|
|
492
|
+
if (!revocationRequested && disposition === "failed") {
|
|
493
|
+
const finished =
|
|
494
|
+
await this.config.store.finishConnectionAuthorization(
|
|
495
|
+
userId,
|
|
496
|
+
connectionId,
|
|
497
|
+
{
|
|
498
|
+
state: "failed",
|
|
499
|
+
failure: "Connection authorization could not be recovered",
|
|
500
|
+
},
|
|
501
|
+
);
|
|
502
|
+
if (!finished) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
"Connection authorization changed during reconciliation",
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
throw new DefinitiveConnectionOperationError(
|
|
508
|
+
"Connection authorization failed; retry with a new operation",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
if (disposition === "pending") {
|
|
512
|
+
if (safeMetadata) {
|
|
513
|
+
return this.retirePendingLink(userId, connectionId, safeMetadata);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
if (disposition === "ready" && safeMetadata) {
|
|
517
|
+
const finished =
|
|
518
|
+
await this.config.store.finishConnectionAuthorization(
|
|
519
|
+
userId,
|
|
520
|
+
connectionId,
|
|
521
|
+
{
|
|
522
|
+
state: "ready",
|
|
523
|
+
safeMetadata: {
|
|
524
|
+
...safeMetadata,
|
|
525
|
+
authorizationStateConsumed: true,
|
|
526
|
+
},
|
|
527
|
+
},
|
|
528
|
+
);
|
|
529
|
+
const ready = await this.config.store.getConnection(
|
|
530
|
+
userId,
|
|
531
|
+
connectionId,
|
|
532
|
+
);
|
|
533
|
+
const replay = ready
|
|
534
|
+
? decodeConnectionStartReplayV1(ready, commandFingerprint)
|
|
535
|
+
: undefined;
|
|
536
|
+
if (!replay) {
|
|
537
|
+
if (!finished) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Connection authorization changed during reconciliation",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
throw new Error("Connection command replay snapshot is invalid");
|
|
543
|
+
}
|
|
544
|
+
return replay;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
if (
|
|
548
|
+
!revocationRequested &&
|
|
549
|
+
(authorizationStateExpired || persistedLinkExpired)
|
|
550
|
+
) {
|
|
551
|
+
await this.config.store.finishConnectionAuthorization(
|
|
552
|
+
userId,
|
|
553
|
+
connectionId,
|
|
554
|
+
{
|
|
555
|
+
state: "failed",
|
|
556
|
+
failure: "Connection authorization expired",
|
|
557
|
+
},
|
|
558
|
+
);
|
|
559
|
+
throw new DefinitiveConnectionOperationError(
|
|
560
|
+
"Connection authorization expired; retry with a new operation",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
throw new Error("Connection authorization requires reconciliation");
|
|
564
|
+
}
|
|
565
|
+
if (!type) throw new Error("Connection command snapshot is invalid");
|
|
566
|
+
let link: ConnectLink;
|
|
567
|
+
try {
|
|
568
|
+
link = await this.config.client.createConnectLink({
|
|
569
|
+
userId,
|
|
570
|
+
authConfigId: type.authConfigId,
|
|
571
|
+
callbackUrl: `${this.config.callbackBaseUrl}/api/plugins/composio/callback?state=${encodeURIComponent(input.callbackState)}`,
|
|
572
|
+
alias: connectionId,
|
|
573
|
+
});
|
|
574
|
+
} catch (error) {
|
|
575
|
+
await this.config.store.requireConnectionReconciliation(
|
|
576
|
+
userId,
|
|
577
|
+
connectionId,
|
|
578
|
+
"link",
|
|
579
|
+
"Connect Link outcome requires reconciliation",
|
|
580
|
+
);
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
const recorded = await this.config.store.recordConnectLinkResult(
|
|
584
|
+
userId,
|
|
585
|
+
connectionId,
|
|
586
|
+
{
|
|
587
|
+
returnTarget,
|
|
588
|
+
providerAlias: connectionId,
|
|
589
|
+
startCommandFingerprint: commandFingerprint,
|
|
590
|
+
connectedAccountId: link.connectedAccountId,
|
|
591
|
+
redirectUrl: link.redirectUrl,
|
|
592
|
+
toolkitSlug: type.toolkitSlug,
|
|
593
|
+
expiresAt: link.expiresAt,
|
|
594
|
+
authorizationStateId: input.authorizationStateId,
|
|
595
|
+
authorizationStateExpiresAt,
|
|
596
|
+
...(input.nativeReturnNonce
|
|
597
|
+
? { nativeReturnNonce: input.nativeReturnNonce }
|
|
598
|
+
: {}),
|
|
599
|
+
},
|
|
600
|
+
);
|
|
601
|
+
if (!recorded) {
|
|
602
|
+
throw new Error(
|
|
603
|
+
"Connection authorization changed while creating its link",
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
const afterLink = await this.config.store.getConnection(
|
|
607
|
+
userId,
|
|
608
|
+
connectionId,
|
|
609
|
+
);
|
|
610
|
+
if (afterLink?.safeMetadata.revocationRequested === true) {
|
|
611
|
+
await this.revoke(userId, connectionId);
|
|
612
|
+
throw new Error("Connection was revoked while creating its link");
|
|
613
|
+
}
|
|
614
|
+
return {
|
|
615
|
+
schemaVersion: 1,
|
|
616
|
+
status: "authorization-required",
|
|
617
|
+
connectionId,
|
|
618
|
+
redirectUrl: link.redirectUrl,
|
|
619
|
+
expiresAt: link.expiresAt,
|
|
620
|
+
nativeReturnNonce: input.nativeReturnNonce,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
async fail(
|
|
625
|
+
userId: string,
|
|
626
|
+
connectionId: string,
|
|
627
|
+
message: string,
|
|
628
|
+
authorizationStateId?: string,
|
|
629
|
+
): Promise<ConnectionCompletionResult> {
|
|
630
|
+
const connection = await this.config.store.getConnection(
|
|
631
|
+
userId,
|
|
632
|
+
connectionId,
|
|
633
|
+
);
|
|
634
|
+
const callbackStateId =
|
|
635
|
+
authorizationStateId ?? connection?.safeMetadata.authorizationStateId;
|
|
636
|
+
if (typeof callbackStateId !== "string") {
|
|
637
|
+
throw new Error("Composio authorization state is invalid or expired");
|
|
638
|
+
}
|
|
639
|
+
if (connection?.safeMetadata.authorizationStateId !== callbackStateId) {
|
|
640
|
+
throw new Error("Composio authorization state is invalid or expired");
|
|
641
|
+
}
|
|
642
|
+
await this.config.store.finishConnectionAuthorization(
|
|
643
|
+
userId,
|
|
644
|
+
connectionId,
|
|
645
|
+
{
|
|
646
|
+
state: "failed",
|
|
647
|
+
failure: message.slice(0, 500),
|
|
648
|
+
authorizationStateId: callbackStateId,
|
|
649
|
+
},
|
|
650
|
+
);
|
|
651
|
+
const current = await this.config.store.getConnection(userId, connectionId);
|
|
652
|
+
const durable = current ? durableCompletionResult(current) : undefined;
|
|
653
|
+
if (durable) return durable;
|
|
654
|
+
throw new Error("Composio authorization state is invalid or expired");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async revoke(
|
|
658
|
+
userId: string,
|
|
659
|
+
connectionId: string,
|
|
660
|
+
recoveredSafeMetadata?: ConnectionView["safeMetadata"],
|
|
661
|
+
): Promise<RevokeConnectionResult> {
|
|
662
|
+
const claim = await this.config.store.claimConnectionRevocation(
|
|
663
|
+
userId,
|
|
664
|
+
connectionId,
|
|
665
|
+
recoveredSafeMetadata,
|
|
666
|
+
);
|
|
667
|
+
const connection = claim.connection;
|
|
668
|
+
if (connection.packageId !== "composio") {
|
|
669
|
+
throw new Error("Composio Connection was not admitted");
|
|
670
|
+
}
|
|
671
|
+
if (claim.phase === "done") {
|
|
672
|
+
return { schemaVersion: 1, status: "revoked" };
|
|
673
|
+
}
|
|
674
|
+
const connectedAccountId = connection.safeMetadata.connectedAccountId;
|
|
675
|
+
if (typeof connectedAccountId !== "string") {
|
|
676
|
+
return { schemaVersion: 1, status: "reconciliation-required" };
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const shouldInvokeProvider = claim.phase === "provider";
|
|
680
|
+
if (
|
|
681
|
+
claim.phase === "pending" &&
|
|
682
|
+
connection.safeMetadata.reconciliationOperation === "revoke"
|
|
683
|
+
) {
|
|
684
|
+
const reconciliation = await reconcileComposioProviderConnection(
|
|
685
|
+
this.config.client,
|
|
686
|
+
{ operation: "revoke", userId, connectedAccountId },
|
|
687
|
+
);
|
|
688
|
+
if (
|
|
689
|
+
reconciliation.status === "revoked" ||
|
|
690
|
+
reconciliation.status === "absent"
|
|
691
|
+
) {
|
|
692
|
+
await this.config.store.recordRevocationProviderCompleted(
|
|
693
|
+
userId,
|
|
694
|
+
connectionId,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
if (shouldInvokeProvider) {
|
|
699
|
+
try {
|
|
700
|
+
await this.config.client.revokeConnectedAccount(connectedAccountId);
|
|
701
|
+
await this.config.store.recordRevocationProviderCompleted(
|
|
702
|
+
userId,
|
|
703
|
+
connectionId,
|
|
704
|
+
);
|
|
705
|
+
} catch (error) {
|
|
706
|
+
await this.config.store.requireConnectionReconciliation(
|
|
707
|
+
userId,
|
|
708
|
+
connectionId,
|
|
709
|
+
"revoke",
|
|
710
|
+
"Revocation outcome requires reconciliation",
|
|
711
|
+
);
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const refreshed = await this.config.store.getConnection(
|
|
717
|
+
userId,
|
|
718
|
+
connectionId,
|
|
719
|
+
);
|
|
720
|
+
if (refreshed?.safeMetadata.revocationProviderCompleted !== true) {
|
|
721
|
+
return { schemaVersion: 1, status: "reconciliation-required" };
|
|
722
|
+
}
|
|
723
|
+
const compensations = Array.isArray(
|
|
724
|
+
refreshed.safeMetadata.assignmentCompensations,
|
|
725
|
+
)
|
|
726
|
+
? refreshed.safeMetadata.assignmentCompensations
|
|
727
|
+
: [];
|
|
728
|
+
if (this.config.markBotUnavailable) {
|
|
729
|
+
for (const candidate of compensations) {
|
|
730
|
+
if (
|
|
731
|
+
!candidate ||
|
|
732
|
+
typeof candidate !== "object" ||
|
|
733
|
+
Array.isArray(candidate)
|
|
734
|
+
) {
|
|
735
|
+
return { schemaVersion: 1, status: "reconciliation-required" };
|
|
736
|
+
}
|
|
737
|
+
const compensation = candidate as Record<string, unknown>;
|
|
738
|
+
if (
|
|
739
|
+
typeof compensation.botId !== "string" ||
|
|
740
|
+
typeof compensation.id !== "string" ||
|
|
741
|
+
typeof compensation.expectedGeneration !== "string"
|
|
742
|
+
) {
|
|
743
|
+
return { schemaVersion: 1, status: "reconciliation-required" };
|
|
744
|
+
}
|
|
745
|
+
const result = await this.config.markBotUnavailable(
|
|
746
|
+
userId,
|
|
747
|
+
compensation.botId,
|
|
748
|
+
connectionId,
|
|
749
|
+
{
|
|
750
|
+
id: compensation.id,
|
|
751
|
+
expectedGeneration: compensation.expectedGeneration,
|
|
752
|
+
},
|
|
753
|
+
);
|
|
754
|
+
if (isSettledBotCompensation(result)) {
|
|
755
|
+
await this.config.store.recordAssignmentCompensated(
|
|
756
|
+
userId,
|
|
757
|
+
connectionId,
|
|
758
|
+
compensation.id,
|
|
759
|
+
);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
const finished = await this.config.store.finishConnectionRevocation(
|
|
764
|
+
userId,
|
|
765
|
+
connectionId,
|
|
766
|
+
);
|
|
767
|
+
return {
|
|
768
|
+
schemaVersion: 1,
|
|
769
|
+
status: finished ? "revoked" : "reconciliation-required",
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
async complete(
|
|
774
|
+
userId: string,
|
|
775
|
+
input: {
|
|
776
|
+
connectionId: string;
|
|
777
|
+
connectedAccountId: string;
|
|
778
|
+
authorizationStateId?: string;
|
|
779
|
+
},
|
|
780
|
+
): Promise<ConnectionCompletionResult> {
|
|
781
|
+
const connection = await this.config.store.getConnection(
|
|
782
|
+
userId,
|
|
783
|
+
input.connectionId,
|
|
784
|
+
);
|
|
785
|
+
if (!connection || connection.packageId !== "composio") {
|
|
786
|
+
throw new Error("Composio Connection was not admitted");
|
|
787
|
+
}
|
|
788
|
+
const returnTarget =
|
|
789
|
+
connection.safeMetadata.returnTarget === "desktop"
|
|
790
|
+
? "desktop"
|
|
791
|
+
: "browser";
|
|
792
|
+
const nativeReturnNonce =
|
|
793
|
+
typeof connection.safeMetadata.nativeReturnNonce === "string"
|
|
794
|
+
? connection.safeMetadata.nativeReturnNonce
|
|
795
|
+
: undefined;
|
|
796
|
+
const authorizationStateId =
|
|
797
|
+
input.authorizationStateId ??
|
|
798
|
+
(connection.safeMetadata.authorizationStateId as string);
|
|
799
|
+
if (connection.safeMetadata.authorizationStateConsumed === true) {
|
|
800
|
+
const durable = durableCompletionResult(connection);
|
|
801
|
+
if (durable) return durable;
|
|
802
|
+
}
|
|
803
|
+
const expectedAccountId = connection.safeMetadata.connectedAccountId;
|
|
804
|
+
if (expectedAccountId !== input.connectedAccountId) {
|
|
805
|
+
throw new Error(
|
|
806
|
+
"Composio callback does not match the admitted Connection",
|
|
807
|
+
);
|
|
808
|
+
}
|
|
809
|
+
let verifiedMetadata: ConnectionView["safeMetadata"] | undefined;
|
|
810
|
+
if (connection.safeMetadata.authorizationStateConsumed !== true) {
|
|
811
|
+
if (
|
|
812
|
+
connection.state !== "authorizing" &&
|
|
813
|
+
!(
|
|
814
|
+
connection.state === "reconciliation-required" &&
|
|
815
|
+
connection.safeMetadata.reconciliationOperation === "link"
|
|
816
|
+
)
|
|
817
|
+
) {
|
|
818
|
+
throw new Error(
|
|
819
|
+
"Composio Connection cannot complete from its current state",
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
const account = await this.config.client.getConnectedAccount(
|
|
823
|
+
input.connectedAccountId,
|
|
824
|
+
);
|
|
825
|
+
if (account.userId !== userId || account.status !== "ACTIVE") {
|
|
826
|
+
throw new Error("Composio connected account is not active");
|
|
827
|
+
}
|
|
828
|
+
if (account.toolkitSlug !== connection.safeMetadata.toolkitSlug) {
|
|
829
|
+
throw new Error(
|
|
830
|
+
"Composio connected account does not match the admitted toolkit",
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
verifiedMetadata = {
|
|
834
|
+
...connection.safeMetadata,
|
|
835
|
+
toolkitSlug: account.toolkitSlug,
|
|
836
|
+
...(account.alias ? { providerAlias: account.alias } : {}),
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const finished = await this.config.store.finishConnectionAuthorization(
|
|
841
|
+
userId,
|
|
842
|
+
input.connectionId,
|
|
843
|
+
{
|
|
844
|
+
state: "ready",
|
|
845
|
+
safeMetadata: verifiedMetadata,
|
|
846
|
+
authorizationStateId,
|
|
847
|
+
},
|
|
848
|
+
);
|
|
849
|
+
if (finished) {
|
|
850
|
+
return { returnTarget, status: "ready", nativeReturnNonce };
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
const current = await this.config.store.getConnection(
|
|
854
|
+
userId,
|
|
855
|
+
input.connectionId,
|
|
856
|
+
);
|
|
857
|
+
const durable = current ? durableCompletionResult(current) : undefined;
|
|
858
|
+
if (durable) return durable;
|
|
859
|
+
throw new Error("Connection state changed during authorization completion");
|
|
860
|
+
}
|
|
861
|
+
}
|