@frockbot/plugin-composio 0.0.0 → 0.1.1
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,1937 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
USER_PROFILE_PLACEHOLDER_NAME_V1,
|
|
4
|
+
type ConnectionView,
|
|
5
|
+
type UserConfigurationCommandV1,
|
|
6
|
+
type UserSettingsViewV1,
|
|
7
|
+
} from "@frockbot/configuration-core";
|
|
8
|
+
import {
|
|
9
|
+
createComposioUserBackendContribution,
|
|
10
|
+
deriveRevocationCompensations,
|
|
11
|
+
} from "./user-configuration.js";
|
|
12
|
+
import { ComposioClient } from "./composio-client.js";
|
|
13
|
+
import { ComposioConnectionCoordinator } from "./connections.js";
|
|
14
|
+
import { reconcileComposioProviderConnection } from "./provider-reconciliation.js";
|
|
15
|
+
import type {
|
|
16
|
+
ComposioProviderReconciliationRequest,
|
|
17
|
+
ComposioProviderReconciliationResult,
|
|
18
|
+
} from "./provider-reconciliation.js";
|
|
19
|
+
|
|
20
|
+
class MemoryStorage {
|
|
21
|
+
readonly values = new Map<string, unknown>();
|
|
22
|
+
alarmAt: number | undefined;
|
|
23
|
+
interruptAfterNextPut = false;
|
|
24
|
+
|
|
25
|
+
get<T>(key: string): Promise<T | undefined> {
|
|
26
|
+
return Promise.resolve(this.values.get(key) as T | undefined);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
put(key: string | Record<string, unknown>, value?: unknown): Promise<void> {
|
|
30
|
+
if (typeof key === "string") this.values.set(key, structuredClone(value));
|
|
31
|
+
else {
|
|
32
|
+
for (const [entry, item] of Object.entries(key)) {
|
|
33
|
+
this.values.set(entry, structuredClone(item));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (this.interruptAfterNextPut) {
|
|
37
|
+
this.interruptAfterNextPut = false;
|
|
38
|
+
return Promise.reject(new Error("Durable Object interrupted after put"));
|
|
39
|
+
}
|
|
40
|
+
return Promise.resolve();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
transaction<T>(callback: (storage: MemoryStorage) => Promise<T>): Promise<T> {
|
|
44
|
+
return callback(this);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
setAlarm(alarmAt: number): Promise<void> {
|
|
48
|
+
this.alarmAt = alarmAt;
|
|
49
|
+
return Promise.resolve();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
deleteAlarm(): Promise<void> {
|
|
53
|
+
this.alarmAt = undefined;
|
|
54
|
+
return Promise.resolve();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function backendHost(
|
|
59
|
+
storage: MemoryStorage,
|
|
60
|
+
reconcileProviderConnection: (
|
|
61
|
+
request: ComposioProviderReconciliationRequest,
|
|
62
|
+
) => Promise<ComposioProviderReconciliationResult> = () =>
|
|
63
|
+
Promise.resolve({ status: "pending" }),
|
|
64
|
+
revokeConnectedAccount: (
|
|
65
|
+
connectedAccountId: string,
|
|
66
|
+
) => Promise<unknown> = () => Promise.resolve({ success: true }),
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
state: { storage } as unknown as DurableObjectState,
|
|
70
|
+
env: {} as never,
|
|
71
|
+
availablePackages: [{ packageId: "composio", version: "0.0.1" }],
|
|
72
|
+
reconcileProviderConnection,
|
|
73
|
+
revokeConnectedAccount,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function makeReconciliationDue(storage: MemoryStorage): Promise<void> {
|
|
78
|
+
const settings = await storage.get<UserSettingsViewV1>("user-configuration");
|
|
79
|
+
if (!settings) throw new Error("user configuration was not stored");
|
|
80
|
+
await storage.put("user-configuration", {
|
|
81
|
+
...settings,
|
|
82
|
+
connections: settings.connections.map((item) => ({
|
|
83
|
+
...item,
|
|
84
|
+
safeMetadata: { ...item.safeMetadata, reconciliationRetryAt: 0 },
|
|
85
|
+
})),
|
|
86
|
+
} satisfies UserSettingsViewV1);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function deferred<T>() {
|
|
90
|
+
let resolve!: (value: T) => void;
|
|
91
|
+
const promise = new Promise<T>((next) => {
|
|
92
|
+
resolve = next;
|
|
93
|
+
});
|
|
94
|
+
return { promise, resolve };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function connection(
|
|
98
|
+
safeMetadata: ConnectionView["safeMetadata"],
|
|
99
|
+
): ConnectionView {
|
|
100
|
+
return {
|
|
101
|
+
connectionId: "connection-1",
|
|
102
|
+
packageId: "composio",
|
|
103
|
+
connectionTypeId: "gmail",
|
|
104
|
+
displayName: "Gmail",
|
|
105
|
+
state: "ready",
|
|
106
|
+
safeMetadata,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function startInstalledConnection(
|
|
111
|
+
contribution: ReturnType<typeof createComposioUserBackendContribution>,
|
|
112
|
+
input: Parameters<
|
|
113
|
+
ReturnType<typeof createComposioUserBackendContribution>["startConnection"]
|
|
114
|
+
>[1],
|
|
115
|
+
): Promise<boolean> {
|
|
116
|
+
const current = await contribution.read("user-1");
|
|
117
|
+
if (!current.packages.some((pkg) => pkg.packageId === input.packageId)) {
|
|
118
|
+
await contribution.executeConfiguration({
|
|
119
|
+
schemaVersion: 1,
|
|
120
|
+
userId: "user-1",
|
|
121
|
+
command: {
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
type: "user/install-package",
|
|
124
|
+
commandId: `install-${input.packageId}`,
|
|
125
|
+
expectedRevision: current.revision,
|
|
126
|
+
packageId: input.packageId,
|
|
127
|
+
version: "0.0.1",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
return contribution.startConnection("user-1", input);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
describe("Connection revocation dependencies", () => {
|
|
135
|
+
test("uses only acknowledged explicit Assignments", async () => {
|
|
136
|
+
expect(
|
|
137
|
+
await deriveRevocationCompensations(
|
|
138
|
+
connection({
|
|
139
|
+
targetBotId: "oauth-initiator",
|
|
140
|
+
dependentAssignments: [
|
|
141
|
+
{ botId: "pending", generation: "gen-pending", status: "pending" },
|
|
142
|
+
{
|
|
143
|
+
botId: "acknowledged",
|
|
144
|
+
generation: "gen-acknowledged",
|
|
145
|
+
status: "acknowledged",
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
}),
|
|
149
|
+
),
|
|
150
|
+
).toEqual([
|
|
151
|
+
{
|
|
152
|
+
botId: "acknowledged",
|
|
153
|
+
id: expect.stringMatching(/^revocation-[a-f0-9]{64}$/),
|
|
154
|
+
expectedGeneration: "gen-acknowledged",
|
|
155
|
+
},
|
|
156
|
+
]);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("keeps compensation identifiers unique and within the RPC bound", async () => {
|
|
160
|
+
const generation = "g".repeat(128);
|
|
161
|
+
const [first, second] = await deriveRevocationCompensations(
|
|
162
|
+
connection({
|
|
163
|
+
dependentAssignments: [
|
|
164
|
+
{ botId: "primary", generation, status: "acknowledged" },
|
|
165
|
+
{ botId: "secondary", generation, status: "acknowledged" },
|
|
166
|
+
],
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
expect(first).toMatchObject({ expectedGeneration: generation });
|
|
171
|
+
expect(second).toMatchObject({ expectedGeneration: generation });
|
|
172
|
+
expect(first?.id).not.toBe(second?.id);
|
|
173
|
+
expect(first?.id.length).toBeLessThanOrEqual(128);
|
|
174
|
+
expect(second?.id.length).toBeLessThanOrEqual(128);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("ignores legacy Bot metadata even when it has a generation", async () => {
|
|
178
|
+
expect(
|
|
179
|
+
await deriveRevocationCompensations(
|
|
180
|
+
connection({
|
|
181
|
+
targetBotId: "legacy-bot",
|
|
182
|
+
assignmentGeneration: "gen-legacy",
|
|
183
|
+
}),
|
|
184
|
+
),
|
|
185
|
+
).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("retries durable Bot compensation through the versioned RPC envelope", async () => {
|
|
189
|
+
const storage = new MemoryStorage();
|
|
190
|
+
const requests: unknown[] = [];
|
|
191
|
+
await storage.put({
|
|
192
|
+
"user-id": "user-1",
|
|
193
|
+
"user-configuration": {
|
|
194
|
+
schemaVersion: 1,
|
|
195
|
+
revision: 1,
|
|
196
|
+
profile: { name: "User" },
|
|
197
|
+
packages: [],
|
|
198
|
+
connections: [
|
|
199
|
+
{
|
|
200
|
+
...connection({
|
|
201
|
+
connectedAccountId: "account-1",
|
|
202
|
+
revocationProviderCompleted: true,
|
|
203
|
+
assignmentCompensationPending: true,
|
|
204
|
+
assignmentCompensations: [
|
|
205
|
+
{
|
|
206
|
+
botId: "primary",
|
|
207
|
+
id: "compensation-1",
|
|
208
|
+
expectedGeneration: "generation-1",
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
compensationRetryAt: 0,
|
|
212
|
+
}),
|
|
213
|
+
state: "revoking",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
} satisfies UserSettingsViewV1,
|
|
217
|
+
});
|
|
218
|
+
const contribution = createComposioUserBackendContribution({
|
|
219
|
+
...backendHost(storage),
|
|
220
|
+
env: {
|
|
221
|
+
BOT_STATES: {
|
|
222
|
+
idFromName: (name: string) => name,
|
|
223
|
+
get: () => ({
|
|
224
|
+
markConnectionUnavailable: (request: unknown) => {
|
|
225
|
+
requests.push(request);
|
|
226
|
+
return Promise.resolve("applied" as const);
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
} as never,
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await contribution.alarm();
|
|
234
|
+
|
|
235
|
+
expect(requests).toEqual([
|
|
236
|
+
{
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
userId: "user-1",
|
|
239
|
+
botId: "primary",
|
|
240
|
+
connectionId: "connection-1",
|
|
241
|
+
compensation: {
|
|
242
|
+
id: "compensation-1",
|
|
243
|
+
expectedGeneration: "generation-1",
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
]);
|
|
247
|
+
expect(
|
|
248
|
+
await contribution.getConnection("user-1", "connection-1"),
|
|
249
|
+
).toMatchObject({ state: "revoked" });
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("Connection dependency admission", () => {
|
|
254
|
+
test("replays a Package receipt before deployment availability changes", async () => {
|
|
255
|
+
const storage = new MemoryStorage();
|
|
256
|
+
const host = backendHost(storage);
|
|
257
|
+
const installed = createComposioUserBackendContribution({
|
|
258
|
+
...host,
|
|
259
|
+
});
|
|
260
|
+
const command: UserConfigurationCommandV1 = {
|
|
261
|
+
schemaVersion: 1,
|
|
262
|
+
type: "user/install-package",
|
|
263
|
+
commandId: "install-composio",
|
|
264
|
+
expectedRevision: 0,
|
|
265
|
+
packageId: "composio",
|
|
266
|
+
version: "0.0.1",
|
|
267
|
+
};
|
|
268
|
+
const request = {
|
|
269
|
+
schemaVersion: 1 as const,
|
|
270
|
+
userId: "user-1",
|
|
271
|
+
command,
|
|
272
|
+
};
|
|
273
|
+
const receipt = await installed.executeConfiguration(request);
|
|
274
|
+
const redeployed = createComposioUserBackendContribution({
|
|
275
|
+
...host,
|
|
276
|
+
availablePackages: [],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
await expect(redeployed.executeConfiguration(request)).resolves.toEqual(
|
|
280
|
+
receipt,
|
|
281
|
+
);
|
|
282
|
+
await expect(
|
|
283
|
+
redeployed.executeConfiguration({
|
|
284
|
+
...request,
|
|
285
|
+
command: {
|
|
286
|
+
schemaVersion: 1,
|
|
287
|
+
type: "user/update-profile",
|
|
288
|
+
commandId: command.commandId,
|
|
289
|
+
expectedRevision: 0,
|
|
290
|
+
profile: { name: "Collision" },
|
|
291
|
+
},
|
|
292
|
+
}),
|
|
293
|
+
).rejects.toThrow(
|
|
294
|
+
'Configuration command idempotency key "install-composio" was reused for a different command',
|
|
295
|
+
);
|
|
296
|
+
await expect(
|
|
297
|
+
redeployed.executeConfiguration({
|
|
298
|
+
...request,
|
|
299
|
+
command: {
|
|
300
|
+
...command,
|
|
301
|
+
commandId: "install-after-removal",
|
|
302
|
+
expectedRevision: 1,
|
|
303
|
+
},
|
|
304
|
+
}),
|
|
305
|
+
).rejects.toThrow("Package is not available");
|
|
306
|
+
await expect(
|
|
307
|
+
redeployed.readConfiguration({ schemaVersion: 1, userId: "user-1" }),
|
|
308
|
+
).resolves.toMatchObject({
|
|
309
|
+
revision: 1,
|
|
310
|
+
profile: { name: USER_PROFILE_PLACEHOLDER_NAME_V1 },
|
|
311
|
+
packages: [{ packageId: "composio", state: "installed" }],
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("rejects re-enabling a Package version removed from the application", async () => {
|
|
316
|
+
const storage = new MemoryStorage();
|
|
317
|
+
const host = backendHost(storage);
|
|
318
|
+
const contribution = createComposioUserBackendContribution(host);
|
|
319
|
+
await contribution.executeConfiguration({
|
|
320
|
+
schemaVersion: 1,
|
|
321
|
+
userId: "user-1",
|
|
322
|
+
command: {
|
|
323
|
+
schemaVersion: 1,
|
|
324
|
+
type: "user/install-package",
|
|
325
|
+
commandId: "install-composio",
|
|
326
|
+
expectedRevision: 0,
|
|
327
|
+
packageId: "composio",
|
|
328
|
+
version: "0.0.1",
|
|
329
|
+
},
|
|
330
|
+
});
|
|
331
|
+
await contribution.executeConfiguration({
|
|
332
|
+
schemaVersion: 1,
|
|
333
|
+
userId: "user-1",
|
|
334
|
+
command: {
|
|
335
|
+
schemaVersion: 1,
|
|
336
|
+
type: "user/set-package-enabled",
|
|
337
|
+
commandId: "disable-composio",
|
|
338
|
+
expectedRevision: 1,
|
|
339
|
+
packageId: "composio",
|
|
340
|
+
enabled: false,
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
const upgraded = createComposioUserBackendContribution({
|
|
344
|
+
...host,
|
|
345
|
+
availablePackages: [{ packageId: "composio", version: "0.0.2" }],
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
await expect(
|
|
349
|
+
upgraded.executeConfiguration({
|
|
350
|
+
schemaVersion: 1,
|
|
351
|
+
userId: "user-1",
|
|
352
|
+
command: {
|
|
353
|
+
schemaVersion: 1,
|
|
354
|
+
type: "user/set-package-enabled",
|
|
355
|
+
commandId: "enable-composio",
|
|
356
|
+
expectedRevision: 2,
|
|
357
|
+
packageId: "composio",
|
|
358
|
+
enabled: true,
|
|
359
|
+
},
|
|
360
|
+
}),
|
|
361
|
+
).rejects.toThrow("Package is not available");
|
|
362
|
+
await expect(upgraded.read("user-1")).resolves.toMatchObject({
|
|
363
|
+
revision: 2,
|
|
364
|
+
packages: [{ packageId: "composio", state: "disabled" }],
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("rejects Connection admission after its Package is disabled", async () => {
|
|
369
|
+
const storage = new MemoryStorage();
|
|
370
|
+
const contribution = createComposioUserBackendContribution(
|
|
371
|
+
backendHost(storage),
|
|
372
|
+
);
|
|
373
|
+
await contribution.executeConfiguration({
|
|
374
|
+
schemaVersion: 1,
|
|
375
|
+
userId: "user-1",
|
|
376
|
+
command: {
|
|
377
|
+
schemaVersion: 1,
|
|
378
|
+
type: "user/install-package",
|
|
379
|
+
commandId: "install-composio",
|
|
380
|
+
expectedRevision: 0,
|
|
381
|
+
packageId: "composio",
|
|
382
|
+
version: "0.0.1",
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
await contribution.executeConfiguration({
|
|
386
|
+
schemaVersion: 1,
|
|
387
|
+
userId: "user-1",
|
|
388
|
+
command: {
|
|
389
|
+
schemaVersion: 1,
|
|
390
|
+
type: "user/set-package-enabled",
|
|
391
|
+
commandId: "disable-composio",
|
|
392
|
+
expectedRevision: 1,
|
|
393
|
+
packageId: "composio",
|
|
394
|
+
enabled: false,
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
await expect(
|
|
399
|
+
contribution.startConnection("user-1", {
|
|
400
|
+
connectionId: "gmail-1",
|
|
401
|
+
packageId: "composio",
|
|
402
|
+
connectionTypeId: "gmail",
|
|
403
|
+
displayName: "Gmail",
|
|
404
|
+
}),
|
|
405
|
+
).rejects.toThrow('Package "composio" is not installed');
|
|
406
|
+
expect((await contribution.read("user-1")).connections).toEqual([]);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("rejects Connection admission when the installed Package version is unavailable", async () => {
|
|
410
|
+
const storage = new MemoryStorage();
|
|
411
|
+
const host = backendHost(storage);
|
|
412
|
+
const contribution = createComposioUserBackendContribution(host);
|
|
413
|
+
await contribution.executeConfiguration({
|
|
414
|
+
schemaVersion: 1,
|
|
415
|
+
userId: "user-1",
|
|
416
|
+
command: {
|
|
417
|
+
schemaVersion: 1,
|
|
418
|
+
type: "user/install-package",
|
|
419
|
+
commandId: "install-composio",
|
|
420
|
+
expectedRevision: 0,
|
|
421
|
+
packageId: "composio",
|
|
422
|
+
version: "0.0.1",
|
|
423
|
+
},
|
|
424
|
+
});
|
|
425
|
+
const upgraded = createComposioUserBackendContribution({
|
|
426
|
+
...host,
|
|
427
|
+
availablePackages: [{ packageId: "composio", version: "0.0.2" }],
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
await expect(
|
|
431
|
+
upgraded.startConnection("user-1", {
|
|
432
|
+
connectionId: "gmail-1",
|
|
433
|
+
packageId: "composio",
|
|
434
|
+
connectionTypeId: "gmail",
|
|
435
|
+
displayName: "Gmail",
|
|
436
|
+
}),
|
|
437
|
+
).rejects.toThrow('Package "composio" is not available');
|
|
438
|
+
expect((await upgraded.read("user-1")).connections).toEqual([]);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("atomically enforces the Package and Connection Type requirement", async () => {
|
|
442
|
+
const storage = new MemoryStorage();
|
|
443
|
+
const contribution = createComposioUserBackendContribution({
|
|
444
|
+
...backendHost(storage),
|
|
445
|
+
});
|
|
446
|
+
const execute = (command: UserConfigurationCommandV1) =>
|
|
447
|
+
contribution.executeConfiguration({
|
|
448
|
+
schemaVersion: 1,
|
|
449
|
+
userId: "user-1",
|
|
450
|
+
command,
|
|
451
|
+
});
|
|
452
|
+
const read = () =>
|
|
453
|
+
contribution.readConfiguration({ schemaVersion: 1, userId: "user-1" });
|
|
454
|
+
|
|
455
|
+
await expect(
|
|
456
|
+
contribution.readConfiguration({ schemaVersion: 1, userId: 42 }),
|
|
457
|
+
).rejects.toThrow("userId is invalid");
|
|
458
|
+
await expect(
|
|
459
|
+
contribution.executeConfiguration({
|
|
460
|
+
schemaVersion: 1,
|
|
461
|
+
userId: "user-1",
|
|
462
|
+
command: {
|
|
463
|
+
schemaVersion: 1,
|
|
464
|
+
type: "user/update-profile",
|
|
465
|
+
commandId: "malformed-profile",
|
|
466
|
+
expectedRevision: 0,
|
|
467
|
+
profile: { name: 42 },
|
|
468
|
+
},
|
|
469
|
+
}),
|
|
470
|
+
).rejects.toThrow("profile.name must be a string");
|
|
471
|
+
await expect(
|
|
472
|
+
execute({
|
|
473
|
+
schemaVersion: 1,
|
|
474
|
+
type: "user/install-package",
|
|
475
|
+
commandId: "install-unknown",
|
|
476
|
+
expectedRevision: 0,
|
|
477
|
+
packageId: "unknown",
|
|
478
|
+
version: "1.0.0",
|
|
479
|
+
}),
|
|
480
|
+
).rejects.toThrow("Package is not available");
|
|
481
|
+
expect((await read()).revision).toBe(0);
|
|
482
|
+
|
|
483
|
+
await execute({
|
|
484
|
+
schemaVersion: 1,
|
|
485
|
+
type: "user/install-package",
|
|
486
|
+
commandId: "install-composio",
|
|
487
|
+
expectedRevision: 0,
|
|
488
|
+
packageId: "composio",
|
|
489
|
+
version: "0.0.1",
|
|
490
|
+
});
|
|
491
|
+
await startInstalledConnection(contribution, {
|
|
492
|
+
connectionId: "gmail-1",
|
|
493
|
+
packageId: "composio",
|
|
494
|
+
connectionTypeId: "gmail",
|
|
495
|
+
displayName: "Gmail",
|
|
496
|
+
});
|
|
497
|
+
await contribution.finishConnectionAuthorization("user-1", "gmail-1", {
|
|
498
|
+
state: "ready",
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
const requirement = {
|
|
502
|
+
schemaVersion: 1 as const,
|
|
503
|
+
packageId: "composio",
|
|
504
|
+
packageVersion: "0.0.1",
|
|
505
|
+
capabilityId: "gmail-tools",
|
|
506
|
+
connectionTypeIds: ["gmail"],
|
|
507
|
+
};
|
|
508
|
+
expect(
|
|
509
|
+
await contribution.claimConnectionDependency(
|
|
510
|
+
"user-1",
|
|
511
|
+
"gmail-1",
|
|
512
|
+
"primary",
|
|
513
|
+
"generation-1",
|
|
514
|
+
{ ...requirement, packageVersion: "9.9.9" },
|
|
515
|
+
),
|
|
516
|
+
).toBe(false);
|
|
517
|
+
expect(
|
|
518
|
+
await contribution.claimConnectionDependency(
|
|
519
|
+
"user-1",
|
|
520
|
+
"gmail-1",
|
|
521
|
+
"primary",
|
|
522
|
+
"generation-1",
|
|
523
|
+
{ ...requirement, connectionTypeIds: ["calendar"] },
|
|
524
|
+
),
|
|
525
|
+
).toBe(false);
|
|
526
|
+
const beforeClaim = await read();
|
|
527
|
+
expect(beforeClaim.revision).toBe(3);
|
|
528
|
+
expect(beforeClaim.connections[0]?.safeMetadata).not.toHaveProperty(
|
|
529
|
+
"dependentAssignments",
|
|
530
|
+
);
|
|
531
|
+
|
|
532
|
+
expect(
|
|
533
|
+
await contribution.claimConnectionDependency(
|
|
534
|
+
"user-1",
|
|
535
|
+
"gmail-1",
|
|
536
|
+
"primary",
|
|
537
|
+
"generation-1",
|
|
538
|
+
requirement,
|
|
539
|
+
),
|
|
540
|
+
).toBe(true);
|
|
541
|
+
expect((await read()).connections[0]?.safeMetadata).toMatchObject({
|
|
542
|
+
dependentAssignments: [
|
|
543
|
+
{
|
|
544
|
+
botId: "primary",
|
|
545
|
+
generation: "generation-1",
|
|
546
|
+
status: "pending",
|
|
547
|
+
},
|
|
548
|
+
],
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
describe("Connection provider reconciliation alarms", () => {
|
|
554
|
+
test("replays terminal start success after alarm reconciliation", async () => {
|
|
555
|
+
const storage = new MemoryStorage();
|
|
556
|
+
let alarmReads = 0;
|
|
557
|
+
const contribution = createComposioUserBackendContribution(
|
|
558
|
+
backendHost(storage, () => {
|
|
559
|
+
alarmReads += 1;
|
|
560
|
+
return Promise.resolve({
|
|
561
|
+
status: "active",
|
|
562
|
+
account: {
|
|
563
|
+
id: "account-1",
|
|
564
|
+
status: "ACTIVE",
|
|
565
|
+
toolkitSlug: "gmail",
|
|
566
|
+
alias: "link-command",
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
}),
|
|
570
|
+
);
|
|
571
|
+
await contribution.executeConfiguration({
|
|
572
|
+
schemaVersion: 1,
|
|
573
|
+
userId: "user-1",
|
|
574
|
+
command: {
|
|
575
|
+
schemaVersion: 1,
|
|
576
|
+
type: "user/install-package",
|
|
577
|
+
commandId: "install-composio",
|
|
578
|
+
expectedRevision: 0,
|
|
579
|
+
packageId: "composio",
|
|
580
|
+
version: "0.0.1",
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
let createCalls = 0;
|
|
584
|
+
let coordinatorReads = 0;
|
|
585
|
+
const client = new ComposioClient({
|
|
586
|
+
apiKey: "secret",
|
|
587
|
+
fetch: (_input, init) => {
|
|
588
|
+
if (init?.method === "POST") {
|
|
589
|
+
createCalls += 1;
|
|
590
|
+
return Promise.reject(new Error("Link response was lost"));
|
|
591
|
+
}
|
|
592
|
+
coordinatorReads += 1;
|
|
593
|
+
return Promise.reject(new Error("Coordinator provider read attempted"));
|
|
594
|
+
},
|
|
595
|
+
});
|
|
596
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
597
|
+
client,
|
|
598
|
+
store: contribution,
|
|
599
|
+
callbackBaseUrl: "https://app.example.com",
|
|
600
|
+
connectionTypes: {
|
|
601
|
+
gmail: {
|
|
602
|
+
authConfigId: "gmail-auth",
|
|
603
|
+
displayName: "Gmail",
|
|
604
|
+
toolkitSlug: "gmail",
|
|
605
|
+
},
|
|
606
|
+
},
|
|
607
|
+
});
|
|
608
|
+
const authorizationStateExpiresAt = Date.now() + 10 * 60_000;
|
|
609
|
+
const command = {
|
|
610
|
+
commandId: "link-command",
|
|
611
|
+
connectionTypeId: "gmail",
|
|
612
|
+
callbackState: "signed-state",
|
|
613
|
+
authorizationStateId: "authorization-state",
|
|
614
|
+
authorizationStateExpiresAt,
|
|
615
|
+
};
|
|
616
|
+
|
|
617
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
618
|
+
"Link response was lost",
|
|
619
|
+
);
|
|
620
|
+
await makeReconciliationDue(storage);
|
|
621
|
+
await contribution.alarm();
|
|
622
|
+
const readySettings =
|
|
623
|
+
await storage.get<UserSettingsViewV1>("user-configuration");
|
|
624
|
+
if (!readySettings) throw new Error("user configuration was not stored");
|
|
625
|
+
await storage.put("user-configuration", {
|
|
626
|
+
...readySettings,
|
|
627
|
+
connections: readySettings.connections.map((connection) => ({
|
|
628
|
+
...connection,
|
|
629
|
+
safeMetadata: {
|
|
630
|
+
...connection.safeMetadata,
|
|
631
|
+
authorizationStateExpiresAt: Date.now() - 1,
|
|
632
|
+
},
|
|
633
|
+
})),
|
|
634
|
+
} satisfies UserSettingsViewV1);
|
|
635
|
+
const recovered = await coordinator.start("user-1", command);
|
|
636
|
+
const replayed = await coordinator.start("user-1", command);
|
|
637
|
+
|
|
638
|
+
expect(replayed).toEqual(recovered);
|
|
639
|
+
expect(recovered).toEqual({
|
|
640
|
+
schemaVersion: 1,
|
|
641
|
+
status: "ready",
|
|
642
|
+
connectionId: "link-command",
|
|
643
|
+
});
|
|
644
|
+
expect(recovered).not.toHaveProperty("redirectUrl");
|
|
645
|
+
expect(recovered).not.toHaveProperty("expiresAt");
|
|
646
|
+
expect(createCalls).toBe(1);
|
|
647
|
+
expect(alarmReads).toBe(1);
|
|
648
|
+
expect(coordinatorReads).toBe(0);
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
test("replays ready when an alarm wins an explicit retry race", async () => {
|
|
652
|
+
const storage = new MemoryStorage();
|
|
653
|
+
let alarmReads = 0;
|
|
654
|
+
const contribution = createComposioUserBackendContribution(
|
|
655
|
+
backendHost(storage, () => {
|
|
656
|
+
alarmReads += 1;
|
|
657
|
+
return Promise.resolve({
|
|
658
|
+
status: "active",
|
|
659
|
+
account: {
|
|
660
|
+
id: "account-1",
|
|
661
|
+
status: "ACTIVE",
|
|
662
|
+
toolkitSlug: "gmail",
|
|
663
|
+
alias: "link-command",
|
|
664
|
+
},
|
|
665
|
+
});
|
|
666
|
+
}),
|
|
667
|
+
);
|
|
668
|
+
await contribution.executeConfiguration({
|
|
669
|
+
schemaVersion: 1,
|
|
670
|
+
userId: "user-1",
|
|
671
|
+
command: {
|
|
672
|
+
schemaVersion: 1,
|
|
673
|
+
type: "user/install-package",
|
|
674
|
+
commandId: "install-composio",
|
|
675
|
+
expectedRevision: 0,
|
|
676
|
+
packageId: "composio",
|
|
677
|
+
version: "0.0.1",
|
|
678
|
+
},
|
|
679
|
+
});
|
|
680
|
+
const providerStarted = deferred<void>();
|
|
681
|
+
const providerResult = deferred<Response>();
|
|
682
|
+
let createCalls = 0;
|
|
683
|
+
let coordinatorReads = 0;
|
|
684
|
+
const client = new ComposioClient({
|
|
685
|
+
apiKey: "secret",
|
|
686
|
+
fetch: (_input, init) => {
|
|
687
|
+
if (init?.method === "POST") {
|
|
688
|
+
createCalls += 1;
|
|
689
|
+
return Promise.reject(new Error("Link response was lost"));
|
|
690
|
+
}
|
|
691
|
+
coordinatorReads += 1;
|
|
692
|
+
providerStarted.resolve();
|
|
693
|
+
return providerResult.promise;
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
697
|
+
client,
|
|
698
|
+
store: contribution,
|
|
699
|
+
callbackBaseUrl: "https://app.example.com",
|
|
700
|
+
connectionTypes: {
|
|
701
|
+
gmail: {
|
|
702
|
+
authConfigId: "gmail-auth",
|
|
703
|
+
displayName: "Gmail",
|
|
704
|
+
toolkitSlug: "gmail",
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
});
|
|
708
|
+
const command = {
|
|
709
|
+
commandId: "link-command",
|
|
710
|
+
connectionTypeId: "gmail",
|
|
711
|
+
callbackState: "signed-state",
|
|
712
|
+
authorizationStateId: "authorization-state",
|
|
713
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
714
|
+
};
|
|
715
|
+
|
|
716
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
717
|
+
"Link response was lost",
|
|
718
|
+
);
|
|
719
|
+
await makeReconciliationDue(storage);
|
|
720
|
+
const retry = coordinator.start("user-1", command);
|
|
721
|
+
await providerStarted.promise;
|
|
722
|
+
await contribution.alarm();
|
|
723
|
+
providerResult.resolve(
|
|
724
|
+
Response.json({
|
|
725
|
+
items: [
|
|
726
|
+
{
|
|
727
|
+
id: "account-1",
|
|
728
|
+
status: "ACTIVE",
|
|
729
|
+
alias: "link-command",
|
|
730
|
+
toolkit: { slug: "gmail" },
|
|
731
|
+
},
|
|
732
|
+
],
|
|
733
|
+
}),
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
await expect(retry).resolves.toEqual({
|
|
737
|
+
schemaVersion: 1,
|
|
738
|
+
status: "ready",
|
|
739
|
+
connectionId: "link-command",
|
|
740
|
+
});
|
|
741
|
+
await expect(coordinator.start("user-1", command)).resolves.toEqual({
|
|
742
|
+
schemaVersion: 1,
|
|
743
|
+
status: "ready",
|
|
744
|
+
connectionId: "link-command",
|
|
745
|
+
});
|
|
746
|
+
expect(createCalls).toBe(1);
|
|
747
|
+
expect(alarmReads).toBe(1);
|
|
748
|
+
expect(coordinatorReads).toBe(1);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
test("accepts provider-confirmed ACTIVE when callback expires during read", async () => {
|
|
752
|
+
const storage = new MemoryStorage();
|
|
753
|
+
const contribution = createComposioUserBackendContribution(
|
|
754
|
+
backendHost(storage),
|
|
755
|
+
);
|
|
756
|
+
await contribution.executeConfiguration({
|
|
757
|
+
schemaVersion: 1,
|
|
758
|
+
userId: "user-1",
|
|
759
|
+
command: {
|
|
760
|
+
schemaVersion: 1,
|
|
761
|
+
type: "user/install-package",
|
|
762
|
+
commandId: "install-composio",
|
|
763
|
+
expectedRevision: 0,
|
|
764
|
+
packageId: "composio",
|
|
765
|
+
version: "0.0.1",
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
const providerStarted = deferred<void>();
|
|
769
|
+
const providerResult = deferred<Response>();
|
|
770
|
+
const client = new ComposioClient({
|
|
771
|
+
apiKey: "secret",
|
|
772
|
+
fetch: (_input, init) => {
|
|
773
|
+
if (init?.method === "POST") {
|
|
774
|
+
return Promise.reject(new Error("Link response was lost"));
|
|
775
|
+
}
|
|
776
|
+
providerStarted.resolve();
|
|
777
|
+
return providerResult.promise;
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
781
|
+
client,
|
|
782
|
+
store: contribution,
|
|
783
|
+
callbackBaseUrl: "https://app.example.com",
|
|
784
|
+
connectionTypes: {
|
|
785
|
+
gmail: {
|
|
786
|
+
authConfigId: "gmail-auth",
|
|
787
|
+
displayName: "Gmail",
|
|
788
|
+
toolkitSlug: "gmail",
|
|
789
|
+
},
|
|
790
|
+
},
|
|
791
|
+
});
|
|
792
|
+
const command = {
|
|
793
|
+
commandId: "link-command",
|
|
794
|
+
connectionTypeId: "gmail",
|
|
795
|
+
callbackState: "signed-state",
|
|
796
|
+
authorizationStateId: "authorization-state",
|
|
797
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
801
|
+
"Link response was lost",
|
|
802
|
+
);
|
|
803
|
+
const retry = coordinator.start("user-1", command);
|
|
804
|
+
await providerStarted.promise;
|
|
805
|
+
const settings =
|
|
806
|
+
await storage.get<UserSettingsViewV1>("user-configuration");
|
|
807
|
+
if (!settings) throw new Error("user configuration was not stored");
|
|
808
|
+
await storage.put("user-configuration", {
|
|
809
|
+
...settings,
|
|
810
|
+
connections: settings.connections.map((connection) => ({
|
|
811
|
+
...connection,
|
|
812
|
+
safeMetadata: {
|
|
813
|
+
...connection.safeMetadata,
|
|
814
|
+
authorizationStateExpiresAt: Date.now() - 1,
|
|
815
|
+
},
|
|
816
|
+
})),
|
|
817
|
+
} satisfies UserSettingsViewV1);
|
|
818
|
+
providerResult.resolve(
|
|
819
|
+
Response.json({
|
|
820
|
+
items: [
|
|
821
|
+
{
|
|
822
|
+
id: "account-1",
|
|
823
|
+
status: "ACTIVE",
|
|
824
|
+
alias: "link-command",
|
|
825
|
+
toolkit: { slug: "gmail" },
|
|
826
|
+
},
|
|
827
|
+
],
|
|
828
|
+
}),
|
|
829
|
+
);
|
|
830
|
+
|
|
831
|
+
await expect(retry).resolves.toEqual({
|
|
832
|
+
schemaVersion: 1,
|
|
833
|
+
status: "ready",
|
|
834
|
+
connectionId: "link-command",
|
|
835
|
+
});
|
|
836
|
+
expect(
|
|
837
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
838
|
+
).toMatchObject({ state: "ready" });
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("terminalizes recovered ACTIVE state before exposing its response", async () => {
|
|
842
|
+
const storage = new MemoryStorage();
|
|
843
|
+
const contribution = createComposioUserBackendContribution(
|
|
844
|
+
backendHost(storage),
|
|
845
|
+
);
|
|
846
|
+
await contribution.executeConfiguration({
|
|
847
|
+
schemaVersion: 1,
|
|
848
|
+
userId: "user-1",
|
|
849
|
+
command: {
|
|
850
|
+
schemaVersion: 1,
|
|
851
|
+
type: "user/install-package",
|
|
852
|
+
commandId: "install-composio",
|
|
853
|
+
expectedRevision: 0,
|
|
854
|
+
packageId: "composio",
|
|
855
|
+
version: "0.0.1",
|
|
856
|
+
},
|
|
857
|
+
});
|
|
858
|
+
let createCalls = 0;
|
|
859
|
+
let providerReads = 0;
|
|
860
|
+
const client = new ComposioClient({
|
|
861
|
+
apiKey: "secret",
|
|
862
|
+
fetch: (_input, init) => {
|
|
863
|
+
if (init?.method === "POST") {
|
|
864
|
+
createCalls += 1;
|
|
865
|
+
return Promise.reject(new Error("Link response was lost"));
|
|
866
|
+
}
|
|
867
|
+
providerReads += 1;
|
|
868
|
+
return Promise.resolve(
|
|
869
|
+
Response.json({
|
|
870
|
+
items: [
|
|
871
|
+
{
|
|
872
|
+
id: "account-1",
|
|
873
|
+
status: "ACTIVE",
|
|
874
|
+
alias: "link-command",
|
|
875
|
+
toolkit: { slug: "gmail" },
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
}),
|
|
879
|
+
);
|
|
880
|
+
},
|
|
881
|
+
});
|
|
882
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
883
|
+
client,
|
|
884
|
+
store: contribution,
|
|
885
|
+
callbackBaseUrl: "https://app.example.com",
|
|
886
|
+
connectionTypes: {
|
|
887
|
+
gmail: {
|
|
888
|
+
authConfigId: "gmail-auth",
|
|
889
|
+
displayName: "Gmail",
|
|
890
|
+
toolkitSlug: "gmail",
|
|
891
|
+
},
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
const command = {
|
|
895
|
+
commandId: "link-command",
|
|
896
|
+
connectionTypeId: "gmail",
|
|
897
|
+
callbackState: "signed-state",
|
|
898
|
+
authorizationStateId: "authorization-state",
|
|
899
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
900
|
+
};
|
|
901
|
+
|
|
902
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
903
|
+
"Link response was lost",
|
|
904
|
+
);
|
|
905
|
+
const recovered = await coordinator.start("user-1", command);
|
|
906
|
+
const replayed = await coordinator.start("user-1", command);
|
|
907
|
+
|
|
908
|
+
expect(createCalls).toBe(1);
|
|
909
|
+
expect(providerReads).toBe(1);
|
|
910
|
+
expect(replayed).toEqual(recovered);
|
|
911
|
+
expect(recovered).toEqual({
|
|
912
|
+
schemaVersion: 1,
|
|
913
|
+
status: "ready",
|
|
914
|
+
connectionId: "link-command",
|
|
915
|
+
});
|
|
916
|
+
await expect(
|
|
917
|
+
coordinator.start("user-1", { ...command, alias: "Work" }),
|
|
918
|
+
).rejects.toThrow(
|
|
919
|
+
'Connection command idempotency key "link-command" was reused for a different command',
|
|
920
|
+
);
|
|
921
|
+
await expect(
|
|
922
|
+
coordinator.start("user-1", {
|
|
923
|
+
...command,
|
|
924
|
+
nativeReturnNonce: "native-return-2",
|
|
925
|
+
}),
|
|
926
|
+
).rejects.toThrow(
|
|
927
|
+
'Connection command idempotency key "link-command" was reused for a different command',
|
|
928
|
+
);
|
|
929
|
+
expect(createCalls).toBe(1);
|
|
930
|
+
expect(providerReads).toBe(1);
|
|
931
|
+
expect(
|
|
932
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
933
|
+
).toMatchObject({
|
|
934
|
+
state: "ready",
|
|
935
|
+
safeMetadata: {
|
|
936
|
+
connectedAccountId: "account-1",
|
|
937
|
+
authorizationStateConsumed: true,
|
|
938
|
+
},
|
|
939
|
+
});
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
test("serializes simultaneous Link effects for one Connection Type", async () => {
|
|
943
|
+
const storage = new MemoryStorage();
|
|
944
|
+
const contribution = createComposioUserBackendContribution(
|
|
945
|
+
backendHost(storage),
|
|
946
|
+
);
|
|
947
|
+
await contribution.executeConfiguration({
|
|
948
|
+
schemaVersion: 1,
|
|
949
|
+
userId: "user-1",
|
|
950
|
+
command: {
|
|
951
|
+
schemaVersion: 1,
|
|
952
|
+
type: "user/install-package",
|
|
953
|
+
commandId: "install-composio",
|
|
954
|
+
expectedRevision: 0,
|
|
955
|
+
packageId: "composio",
|
|
956
|
+
version: "0.0.1",
|
|
957
|
+
},
|
|
958
|
+
});
|
|
959
|
+
const firstLink = deferred<Response>();
|
|
960
|
+
const firstLinkStarted = deferred<void>();
|
|
961
|
+
let createCalls = 0;
|
|
962
|
+
const client = new ComposioClient({
|
|
963
|
+
apiKey: "secret",
|
|
964
|
+
fetch: () => {
|
|
965
|
+
createCalls += 1;
|
|
966
|
+
if (createCalls === 1) {
|
|
967
|
+
firstLinkStarted.resolve();
|
|
968
|
+
return firstLink.promise;
|
|
969
|
+
}
|
|
970
|
+
return Promise.resolve(
|
|
971
|
+
Response.json({
|
|
972
|
+
connected_account_id: "account-2",
|
|
973
|
+
redirect_url: "https://connect.example/second",
|
|
974
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
975
|
+
}),
|
|
976
|
+
);
|
|
977
|
+
},
|
|
978
|
+
});
|
|
979
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
980
|
+
client,
|
|
981
|
+
store: contribution,
|
|
982
|
+
callbackBaseUrl: "https://app.example.com",
|
|
983
|
+
connectionTypes: {
|
|
984
|
+
gmail: {
|
|
985
|
+
authConfigId: "gmail-auth",
|
|
986
|
+
displayName: "Gmail",
|
|
987
|
+
toolkitSlug: "gmail",
|
|
988
|
+
},
|
|
989
|
+
},
|
|
990
|
+
});
|
|
991
|
+
const first = coordinator.start("user-1", {
|
|
992
|
+
commandId: "first-command",
|
|
993
|
+
connectionTypeId: "gmail",
|
|
994
|
+
callbackState: "first-signed-state",
|
|
995
|
+
authorizationStateId: "first-state",
|
|
996
|
+
authorizationStateExpiresAt: Date.now() + 60_000,
|
|
997
|
+
});
|
|
998
|
+
await firstLinkStarted.promise;
|
|
999
|
+
|
|
1000
|
+
await expect(
|
|
1001
|
+
coordinator.start("user-1", {
|
|
1002
|
+
commandId: "second-command",
|
|
1003
|
+
connectionTypeId: "gmail",
|
|
1004
|
+
callbackState: "second-signed-state",
|
|
1005
|
+
authorizationStateId: "second-state",
|
|
1006
|
+
authorizationStateExpiresAt: Date.now() + 60_000,
|
|
1007
|
+
}),
|
|
1008
|
+
).rejects.toThrow(
|
|
1009
|
+
"Previous Connection authorization requires reconciliation",
|
|
1010
|
+
);
|
|
1011
|
+
expect(createCalls).toBe(1);
|
|
1012
|
+
|
|
1013
|
+
firstLink.resolve(
|
|
1014
|
+
Response.json({
|
|
1015
|
+
connected_account_id: "account-1",
|
|
1016
|
+
redirect_url: "https://connect.example/first",
|
|
1017
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
1018
|
+
}),
|
|
1019
|
+
);
|
|
1020
|
+
await expect(first).resolves.toMatchObject({
|
|
1021
|
+
status: "authorization-required",
|
|
1022
|
+
connectionId: "first-command",
|
|
1023
|
+
});
|
|
1024
|
+
await expect(
|
|
1025
|
+
coordinator.fail(
|
|
1026
|
+
"user-1",
|
|
1027
|
+
"first-command",
|
|
1028
|
+
"Authorization failed",
|
|
1029
|
+
"first-state",
|
|
1030
|
+
),
|
|
1031
|
+
).resolves.toMatchObject({ status: "failed" });
|
|
1032
|
+
|
|
1033
|
+
await expect(
|
|
1034
|
+
coordinator.start("user-1", {
|
|
1035
|
+
commandId: "second-command",
|
|
1036
|
+
connectionTypeId: "gmail",
|
|
1037
|
+
callbackState: "second-signed-state",
|
|
1038
|
+
authorizationStateId: "second-state",
|
|
1039
|
+
authorizationStateExpiresAt: Date.now() + 60_000,
|
|
1040
|
+
}),
|
|
1041
|
+
).resolves.toMatchObject({
|
|
1042
|
+
status: "authorization-required",
|
|
1043
|
+
connectionId: "second-command",
|
|
1044
|
+
});
|
|
1045
|
+
expect(createCalls).toBe(2);
|
|
1046
|
+
});
|
|
1047
|
+
|
|
1048
|
+
test("retires a pending account after a lost Link response", async () => {
|
|
1049
|
+
const storage = new MemoryStorage();
|
|
1050
|
+
const contribution = createComposioUserBackendContribution(
|
|
1051
|
+
backendHost(storage, (request) => {
|
|
1052
|
+
if (request.operation !== "revoke") {
|
|
1053
|
+
throw new Error("Unexpected Link reconciliation alarm");
|
|
1054
|
+
}
|
|
1055
|
+
return Promise.resolve({
|
|
1056
|
+
status: "revoked",
|
|
1057
|
+
account: {
|
|
1058
|
+
id: "account-1",
|
|
1059
|
+
status: "REVOKED",
|
|
1060
|
+
toolkitSlug: "gmail",
|
|
1061
|
+
alias: "link-command",
|
|
1062
|
+
},
|
|
1063
|
+
});
|
|
1064
|
+
}),
|
|
1065
|
+
);
|
|
1066
|
+
await contribution.executeConfiguration({
|
|
1067
|
+
schemaVersion: 1,
|
|
1068
|
+
userId: "user-1",
|
|
1069
|
+
command: {
|
|
1070
|
+
schemaVersion: 1,
|
|
1071
|
+
type: "user/install-package",
|
|
1072
|
+
commandId: "install-composio",
|
|
1073
|
+
expectedRevision: 0,
|
|
1074
|
+
packageId: "composio",
|
|
1075
|
+
version: "0.0.1",
|
|
1076
|
+
},
|
|
1077
|
+
});
|
|
1078
|
+
let createCalls = 0;
|
|
1079
|
+
let providerReads = 0;
|
|
1080
|
+
let revokeCalls = 0;
|
|
1081
|
+
const client = new ComposioClient({
|
|
1082
|
+
apiKey: "secret",
|
|
1083
|
+
fetch: (input, init) => {
|
|
1084
|
+
const url = String(input);
|
|
1085
|
+
if (url.endsWith("/connected_accounts/link")) {
|
|
1086
|
+
createCalls += 1;
|
|
1087
|
+
if (createCalls === 1) {
|
|
1088
|
+
return Promise.reject(new Error("Link response was lost"));
|
|
1089
|
+
}
|
|
1090
|
+
return Promise.resolve(
|
|
1091
|
+
Response.json({
|
|
1092
|
+
connected_account_id: "account-2",
|
|
1093
|
+
redirect_url: "https://connect.example/authorize",
|
|
1094
|
+
expires_at: new Date(Date.now() + 60_000).toISOString(),
|
|
1095
|
+
}),
|
|
1096
|
+
);
|
|
1097
|
+
}
|
|
1098
|
+
if (url.endsWith("/connected_accounts/account-1/revoke")) {
|
|
1099
|
+
revokeCalls += 1;
|
|
1100
|
+
return Promise.resolve(Response.json({ success: true }));
|
|
1101
|
+
}
|
|
1102
|
+
if (url.includes("/connected_accounts?")) {
|
|
1103
|
+
providerReads += 1;
|
|
1104
|
+
return Promise.resolve(
|
|
1105
|
+
Response.json({
|
|
1106
|
+
items: [
|
|
1107
|
+
{
|
|
1108
|
+
id: "account-1",
|
|
1109
|
+
status: "INITIALIZING",
|
|
1110
|
+
alias: "link-command",
|
|
1111
|
+
toolkit: { slug: "gmail" },
|
|
1112
|
+
},
|
|
1113
|
+
],
|
|
1114
|
+
}),
|
|
1115
|
+
);
|
|
1116
|
+
}
|
|
1117
|
+
throw new Error(`Unexpected Composio request: ${url} ${init?.method}`);
|
|
1118
|
+
},
|
|
1119
|
+
});
|
|
1120
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
1121
|
+
client,
|
|
1122
|
+
store: contribution,
|
|
1123
|
+
callbackBaseUrl: "https://app.example.com",
|
|
1124
|
+
connectionTypes: {
|
|
1125
|
+
gmail: {
|
|
1126
|
+
authConfigId: "gmail-auth",
|
|
1127
|
+
displayName: "Gmail",
|
|
1128
|
+
toolkitSlug: "gmail",
|
|
1129
|
+
},
|
|
1130
|
+
},
|
|
1131
|
+
});
|
|
1132
|
+
const command = {
|
|
1133
|
+
commandId: "link-command",
|
|
1134
|
+
connectionTypeId: "gmail",
|
|
1135
|
+
callbackState: "signed-state",
|
|
1136
|
+
authorizationStateId: "authorization-state",
|
|
1137
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
1141
|
+
"Link response was lost",
|
|
1142
|
+
);
|
|
1143
|
+
await expect(coordinator.start("user-1", command)).rejects.toThrow(
|
|
1144
|
+
"cleanup requires reconciliation",
|
|
1145
|
+
);
|
|
1146
|
+
expect(createCalls).toBe(1);
|
|
1147
|
+
expect(providerReads).toBe(1);
|
|
1148
|
+
expect(revokeCalls).toBe(1);
|
|
1149
|
+
expect(
|
|
1150
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1151
|
+
).toMatchObject({
|
|
1152
|
+
state: "reconciliation-required",
|
|
1153
|
+
safeMetadata: {
|
|
1154
|
+
connectedAccountId: "account-1",
|
|
1155
|
+
authorizationStateConsumed: true,
|
|
1156
|
+
lostLinkCleanup: true,
|
|
1157
|
+
reconciliationOperation: "revoke",
|
|
1158
|
+
},
|
|
1159
|
+
});
|
|
1160
|
+
|
|
1161
|
+
await expect(
|
|
1162
|
+
coordinator.start("user-1", {
|
|
1163
|
+
...command,
|
|
1164
|
+
commandId: "replacement-command",
|
|
1165
|
+
authorizationStateId: "replacement-state",
|
|
1166
|
+
}),
|
|
1167
|
+
).rejects.toThrow(
|
|
1168
|
+
"Previous Connection authorization requires reconciliation",
|
|
1169
|
+
);
|
|
1170
|
+
expect(createCalls).toBe(1);
|
|
1171
|
+
|
|
1172
|
+
await makeReconciliationDue(storage);
|
|
1173
|
+
await contribution.alarm();
|
|
1174
|
+
|
|
1175
|
+
expect(
|
|
1176
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1177
|
+
).toMatchObject({ state: "revoked" });
|
|
1178
|
+
await expect(
|
|
1179
|
+
coordinator.start("user-1", {
|
|
1180
|
+
...command,
|
|
1181
|
+
commandId: "replacement-command",
|
|
1182
|
+
authorizationStateId: "replacement-state",
|
|
1183
|
+
}),
|
|
1184
|
+
).resolves.toMatchObject({
|
|
1185
|
+
status: "authorization-required",
|
|
1186
|
+
connectionId: "replacement-command",
|
|
1187
|
+
});
|
|
1188
|
+
expect(createCalls).toBe(2);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
test("consumes failed callback state and replays its terminal result", async () => {
|
|
1192
|
+
const storage = new MemoryStorage();
|
|
1193
|
+
const contribution = createComposioUserBackendContribution(
|
|
1194
|
+
backendHost(storage),
|
|
1195
|
+
);
|
|
1196
|
+
const authorizationStateExpiresAt = Date.now() + 60_000;
|
|
1197
|
+
await startInstalledConnection(contribution, {
|
|
1198
|
+
connectionId: "link-command",
|
|
1199
|
+
packageId: "composio",
|
|
1200
|
+
connectionTypeId: "gmail",
|
|
1201
|
+
displayName: "Gmail",
|
|
1202
|
+
safeMetadata: {
|
|
1203
|
+
authorizationStateId: "authorization-state",
|
|
1204
|
+
authorizationStateExpiresAt,
|
|
1205
|
+
returnTarget: "desktop",
|
|
1206
|
+
},
|
|
1207
|
+
});
|
|
1208
|
+
const coordinator = new ComposioConnectionCoordinator({
|
|
1209
|
+
client: {} as ComposioClient,
|
|
1210
|
+
store: contribution,
|
|
1211
|
+
callbackBaseUrl: "https://app.example.com",
|
|
1212
|
+
connectionTypes: {},
|
|
1213
|
+
});
|
|
1214
|
+
|
|
1215
|
+
await expect(
|
|
1216
|
+
coordinator.fail(
|
|
1217
|
+
"user-1",
|
|
1218
|
+
"link-command",
|
|
1219
|
+
"Authorization failed",
|
|
1220
|
+
"authorization-state",
|
|
1221
|
+
),
|
|
1222
|
+
).resolves.toEqual({
|
|
1223
|
+
returnTarget: "desktop",
|
|
1224
|
+
status: "failed",
|
|
1225
|
+
nativeReturnNonce: undefined,
|
|
1226
|
+
});
|
|
1227
|
+
const first = await contribution.read("user-1");
|
|
1228
|
+
expect(first.connections[0]).toMatchObject({
|
|
1229
|
+
state: "failed",
|
|
1230
|
+
safeMetadata: { authorizationStateConsumed: true },
|
|
1231
|
+
});
|
|
1232
|
+
|
|
1233
|
+
await expect(
|
|
1234
|
+
coordinator.fail(
|
|
1235
|
+
"user-1",
|
|
1236
|
+
"link-command",
|
|
1237
|
+
"Different replayed failure",
|
|
1238
|
+
"authorization-state",
|
|
1239
|
+
),
|
|
1240
|
+
).resolves.toEqual({
|
|
1241
|
+
returnTarget: "desktop",
|
|
1242
|
+
status: "failed",
|
|
1243
|
+
nativeReturnNonce: undefined,
|
|
1244
|
+
});
|
|
1245
|
+
expect((await contribution.read("user-1")).revision).toBe(first.revision);
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
test("survives interruption immediately after recovered ACTIVE commit", async () => {
|
|
1249
|
+
const storage = new MemoryStorage();
|
|
1250
|
+
const contribution = createComposioUserBackendContribution(
|
|
1251
|
+
backendHost(storage, () => {
|
|
1252
|
+
storage.interruptAfterNextPut = true;
|
|
1253
|
+
return Promise.resolve({
|
|
1254
|
+
status: "active",
|
|
1255
|
+
account: {
|
|
1256
|
+
id: "account-1",
|
|
1257
|
+
status: "ACTIVE",
|
|
1258
|
+
toolkitSlug: "gmail",
|
|
1259
|
+
alias: "link-command",
|
|
1260
|
+
},
|
|
1261
|
+
});
|
|
1262
|
+
}),
|
|
1263
|
+
);
|
|
1264
|
+
await startInstalledConnection(contribution, {
|
|
1265
|
+
connectionId: "link-command",
|
|
1266
|
+
packageId: "composio",
|
|
1267
|
+
connectionTypeId: "gmail",
|
|
1268
|
+
displayName: "Gmail",
|
|
1269
|
+
safeMetadata: {
|
|
1270
|
+
providerAlias: "link-command",
|
|
1271
|
+
toolkitSlug: "gmail",
|
|
1272
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
await contribution.requireConnectionReconciliation(
|
|
1276
|
+
"user-1",
|
|
1277
|
+
"link-command",
|
|
1278
|
+
"link",
|
|
1279
|
+
"Connect Link outcome requires reconciliation",
|
|
1280
|
+
);
|
|
1281
|
+
await makeReconciliationDue(storage);
|
|
1282
|
+
|
|
1283
|
+
await contribution.alarm();
|
|
1284
|
+
|
|
1285
|
+
expect(
|
|
1286
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1287
|
+
).toMatchObject({
|
|
1288
|
+
state: "ready",
|
|
1289
|
+
safeMetadata: {
|
|
1290
|
+
connectedAccountId: "account-1",
|
|
1291
|
+
authorizationStateConsumed: true,
|
|
1292
|
+
},
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
|
|
1296
|
+
test.each([
|
|
1297
|
+
{
|
|
1298
|
+
name: "ACTIVE",
|
|
1299
|
+
result: {
|
|
1300
|
+
status: "active" as const,
|
|
1301
|
+
account: {
|
|
1302
|
+
id: "account-1",
|
|
1303
|
+
status: "ACTIVE",
|
|
1304
|
+
toolkitSlug: "gmail",
|
|
1305
|
+
alias: "link-command",
|
|
1306
|
+
},
|
|
1307
|
+
},
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
name: "FAILED",
|
|
1311
|
+
result: {
|
|
1312
|
+
status: "failed" as const,
|
|
1313
|
+
account: {
|
|
1314
|
+
id: "account-1",
|
|
1315
|
+
status: "FAILED",
|
|
1316
|
+
toolkitSlug: "gmail",
|
|
1317
|
+
alias: "link-command",
|
|
1318
|
+
},
|
|
1319
|
+
},
|
|
1320
|
+
},
|
|
1321
|
+
])(
|
|
1322
|
+
"preserves concurrent revocation across $name recovery",
|
|
1323
|
+
async ({ result }) => {
|
|
1324
|
+
const storage = new MemoryStorage();
|
|
1325
|
+
const providerStarted = deferred<void>();
|
|
1326
|
+
const providerResult = deferred<ComposioProviderReconciliationResult>();
|
|
1327
|
+
const contribution = createComposioUserBackendContribution(
|
|
1328
|
+
backendHost(storage, () => {
|
|
1329
|
+
providerStarted.resolve();
|
|
1330
|
+
return providerResult.promise;
|
|
1331
|
+
}),
|
|
1332
|
+
);
|
|
1333
|
+
await startInstalledConnection(contribution, {
|
|
1334
|
+
connectionId: "link-command",
|
|
1335
|
+
packageId: "composio",
|
|
1336
|
+
connectionTypeId: "gmail",
|
|
1337
|
+
displayName: "Gmail",
|
|
1338
|
+
safeMetadata: {
|
|
1339
|
+
providerAlias: "link-command",
|
|
1340
|
+
toolkitSlug: "gmail",
|
|
1341
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1342
|
+
},
|
|
1343
|
+
});
|
|
1344
|
+
await contribution.requireConnectionReconciliation(
|
|
1345
|
+
"user-1",
|
|
1346
|
+
"link-command",
|
|
1347
|
+
"link",
|
|
1348
|
+
"Connect Link outcome requires reconciliation",
|
|
1349
|
+
);
|
|
1350
|
+
await makeReconciliationDue(storage);
|
|
1351
|
+
|
|
1352
|
+
const alarm = contribution.alarm();
|
|
1353
|
+
await providerStarted.promise;
|
|
1354
|
+
await contribution.claimConnectionRevocation("user-1", "link-command");
|
|
1355
|
+
providerResult.resolve(result);
|
|
1356
|
+
await alarm;
|
|
1357
|
+
|
|
1358
|
+
expect(
|
|
1359
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1360
|
+
).toMatchObject({
|
|
1361
|
+
state: "reconciliation-required",
|
|
1362
|
+
safeMetadata: {
|
|
1363
|
+
reconciliationOperation: "link",
|
|
1364
|
+
revocationRequested: true,
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1367
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1368
|
+
},
|
|
1369
|
+
);
|
|
1370
|
+
|
|
1371
|
+
test("recovers a lost Link response after Durable Object eviction", async () => {
|
|
1372
|
+
const storage = new MemoryStorage();
|
|
1373
|
+
const admitted = createComposioUserBackendContribution(
|
|
1374
|
+
backendHost(storage),
|
|
1375
|
+
);
|
|
1376
|
+
await startInstalledConnection(admitted, {
|
|
1377
|
+
connectionId: "link-command",
|
|
1378
|
+
packageId: "composio",
|
|
1379
|
+
connectionTypeId: "gmail",
|
|
1380
|
+
displayName: "Gmail",
|
|
1381
|
+
safeMetadata: {
|
|
1382
|
+
providerAlias: "link-command",
|
|
1383
|
+
toolkitSlug: "gmail",
|
|
1384
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1385
|
+
},
|
|
1386
|
+
});
|
|
1387
|
+
await admitted.requireConnectionReconciliation(
|
|
1388
|
+
"user-1",
|
|
1389
|
+
"link-command",
|
|
1390
|
+
"link",
|
|
1391
|
+
"Connect Link outcome requires reconciliation",
|
|
1392
|
+
);
|
|
1393
|
+
|
|
1394
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1395
|
+
await makeReconciliationDue(storage);
|
|
1396
|
+
let reads = 0;
|
|
1397
|
+
const recovered = createComposioUserBackendContribution(
|
|
1398
|
+
backendHost(storage, () => {
|
|
1399
|
+
reads += 1;
|
|
1400
|
+
return Promise.resolve({
|
|
1401
|
+
status: "active" as const,
|
|
1402
|
+
account: {
|
|
1403
|
+
id: "account-1",
|
|
1404
|
+
status: "ACTIVE",
|
|
1405
|
+
toolkitSlug: "gmail",
|
|
1406
|
+
alias: "link-command",
|
|
1407
|
+
},
|
|
1408
|
+
});
|
|
1409
|
+
}),
|
|
1410
|
+
);
|
|
1411
|
+
|
|
1412
|
+
await recovered.alarm();
|
|
1413
|
+
|
|
1414
|
+
expect(reads).toBe(1);
|
|
1415
|
+
expect(
|
|
1416
|
+
await recovered.getConnection("user-1", "link-command"),
|
|
1417
|
+
).toMatchObject({
|
|
1418
|
+
state: "ready",
|
|
1419
|
+
safeMetadata: {
|
|
1420
|
+
connectedAccountId: "account-1",
|
|
1421
|
+
authorizationStateConsumed: true,
|
|
1422
|
+
},
|
|
1423
|
+
});
|
|
1424
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
test("keeps failed reads scheduled without repeating the Link effect", async () => {
|
|
1428
|
+
const storage = new MemoryStorage();
|
|
1429
|
+
const contribution = createComposioUserBackendContribution(
|
|
1430
|
+
backendHost(storage, () => Promise.reject(new Error("read unavailable"))),
|
|
1431
|
+
);
|
|
1432
|
+
await startInstalledConnection(contribution, {
|
|
1433
|
+
connectionId: "link-command",
|
|
1434
|
+
packageId: "composio",
|
|
1435
|
+
connectionTypeId: "gmail",
|
|
1436
|
+
displayName: "Gmail",
|
|
1437
|
+
safeMetadata: {
|
|
1438
|
+
providerAlias: "link-command",
|
|
1439
|
+
toolkitSlug: "gmail",
|
|
1440
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1441
|
+
},
|
|
1442
|
+
});
|
|
1443
|
+
await contribution.requireConnectionReconciliation(
|
|
1444
|
+
"user-1",
|
|
1445
|
+
"link-command",
|
|
1446
|
+
"link",
|
|
1447
|
+
"Connect Link outcome requires reconciliation",
|
|
1448
|
+
);
|
|
1449
|
+
await makeReconciliationDue(storage);
|
|
1450
|
+
|
|
1451
|
+
await contribution.alarm();
|
|
1452
|
+
|
|
1453
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1454
|
+
expect(
|
|
1455
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1456
|
+
).toMatchObject({
|
|
1457
|
+
state: "reconciliation-required",
|
|
1458
|
+
safeMetadata: { reconciliationOperation: "link" },
|
|
1459
|
+
});
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
test("retires an INITIALIZING identity through verified cleanup", async () => {
|
|
1463
|
+
const storage = new MemoryStorage();
|
|
1464
|
+
let reads = 0;
|
|
1465
|
+
let revokeCalls = 0;
|
|
1466
|
+
const contribution = createComposioUserBackendContribution(
|
|
1467
|
+
backendHost(
|
|
1468
|
+
storage,
|
|
1469
|
+
(request) => {
|
|
1470
|
+
reads += 1;
|
|
1471
|
+
if (request.operation === "link") {
|
|
1472
|
+
return Promise.resolve({
|
|
1473
|
+
status: "pending" as const,
|
|
1474
|
+
account: {
|
|
1475
|
+
id: "account-1",
|
|
1476
|
+
status: "INITIALIZING",
|
|
1477
|
+
toolkitSlug: "gmail",
|
|
1478
|
+
alias: "link-command",
|
|
1479
|
+
},
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
return Promise.resolve({
|
|
1483
|
+
status: "revoked" as const,
|
|
1484
|
+
account: {
|
|
1485
|
+
id: "account-1",
|
|
1486
|
+
status: "REVOKED",
|
|
1487
|
+
toolkitSlug: "gmail",
|
|
1488
|
+
alias: "link-command",
|
|
1489
|
+
},
|
|
1490
|
+
});
|
|
1491
|
+
},
|
|
1492
|
+
() => {
|
|
1493
|
+
revokeCalls += 1;
|
|
1494
|
+
return Promise.resolve({ success: true });
|
|
1495
|
+
},
|
|
1496
|
+
),
|
|
1497
|
+
);
|
|
1498
|
+
await startInstalledConnection(contribution, {
|
|
1499
|
+
connectionId: "link-command",
|
|
1500
|
+
packageId: "composio",
|
|
1501
|
+
connectionTypeId: "gmail",
|
|
1502
|
+
displayName: "Gmail",
|
|
1503
|
+
safeMetadata: {
|
|
1504
|
+
providerAlias: "link-command",
|
|
1505
|
+
toolkitSlug: "gmail",
|
|
1506
|
+
authorizationStateExpiresAt: Date.now() - 1,
|
|
1507
|
+
},
|
|
1508
|
+
});
|
|
1509
|
+
await contribution.requireConnectionReconciliation(
|
|
1510
|
+
"user-1",
|
|
1511
|
+
"link-command",
|
|
1512
|
+
"link",
|
|
1513
|
+
"Connect Link outcome requires reconciliation",
|
|
1514
|
+
);
|
|
1515
|
+
await makeReconciliationDue(storage);
|
|
1516
|
+
|
|
1517
|
+
await contribution.alarm();
|
|
1518
|
+
|
|
1519
|
+
expect(
|
|
1520
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1521
|
+
).toMatchObject({
|
|
1522
|
+
state: "reconciliation-required",
|
|
1523
|
+
safeMetadata: {
|
|
1524
|
+
connectedAccountId: "account-1",
|
|
1525
|
+
authorizationStateConsumed: true,
|
|
1526
|
+
lostLinkCleanup: true,
|
|
1527
|
+
reconciliationOperation: "revoke",
|
|
1528
|
+
},
|
|
1529
|
+
});
|
|
1530
|
+
expect(revokeCalls).toBe(1);
|
|
1531
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1532
|
+
await makeReconciliationDue(storage);
|
|
1533
|
+
|
|
1534
|
+
await contribution.alarm();
|
|
1535
|
+
|
|
1536
|
+
expect(reads).toBe(2);
|
|
1537
|
+
expect(
|
|
1538
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1539
|
+
).toMatchObject({
|
|
1540
|
+
state: "revoked",
|
|
1541
|
+
safeMetadata: { connectedAccountId: "account-1" },
|
|
1542
|
+
});
|
|
1543
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
test("keeps a provider-absent expired Link pending for durable reconciliation", async () => {
|
|
1547
|
+
const storage = new MemoryStorage();
|
|
1548
|
+
const admitted = createComposioUserBackendContribution(
|
|
1549
|
+
backendHost(storage),
|
|
1550
|
+
);
|
|
1551
|
+
const authorizationStateExpiresAt = Date.now() + 60_000;
|
|
1552
|
+
const linkExpiresAt = new Date(Date.now() + 30_000).toISOString();
|
|
1553
|
+
await startInstalledConnection(admitted, {
|
|
1554
|
+
connectionId: "link-command",
|
|
1555
|
+
packageId: "composio",
|
|
1556
|
+
connectionTypeId: "gmail",
|
|
1557
|
+
displayName: "Gmail",
|
|
1558
|
+
safeMetadata: {
|
|
1559
|
+
providerAlias: "link-command",
|
|
1560
|
+
toolkitSlug: "gmail",
|
|
1561
|
+
authorizationStateExpiresAt,
|
|
1562
|
+
},
|
|
1563
|
+
});
|
|
1564
|
+
await admitted.recordConnectLinkResult("user-1", "link-command", {
|
|
1565
|
+
connectedAccountId: "account-1",
|
|
1566
|
+
providerAlias: "link-command",
|
|
1567
|
+
toolkitSlug: "gmail",
|
|
1568
|
+
redirectUrl: "https://connect.example/authorize",
|
|
1569
|
+
expiresAt: linkExpiresAt,
|
|
1570
|
+
authorizationStateExpiresAt,
|
|
1571
|
+
});
|
|
1572
|
+
|
|
1573
|
+
expect(storage.alarmAt).toBe(Date.parse(linkExpiresAt));
|
|
1574
|
+
const settings =
|
|
1575
|
+
await storage.get<UserSettingsViewV1>("user-configuration");
|
|
1576
|
+
if (!settings) throw new Error("user configuration was not stored");
|
|
1577
|
+
await storage.put("user-configuration", {
|
|
1578
|
+
...settings,
|
|
1579
|
+
connections: settings.connections.map((item) => ({
|
|
1580
|
+
...item,
|
|
1581
|
+
safeMetadata: {
|
|
1582
|
+
...item.safeMetadata,
|
|
1583
|
+
expiresAt: new Date(Date.now() - 1).toISOString(),
|
|
1584
|
+
},
|
|
1585
|
+
})),
|
|
1586
|
+
} satisfies UserSettingsViewV1);
|
|
1587
|
+
let reads = 0;
|
|
1588
|
+
const recovered = createComposioUserBackendContribution(
|
|
1589
|
+
backendHost(storage, () => {
|
|
1590
|
+
reads += 1;
|
|
1591
|
+
return Promise.resolve({ status: "absent" });
|
|
1592
|
+
}),
|
|
1593
|
+
);
|
|
1594
|
+
|
|
1595
|
+
await recovered.alarm();
|
|
1596
|
+
|
|
1597
|
+
expect(reads).toBe(1);
|
|
1598
|
+
expect(
|
|
1599
|
+
await recovered.getConnection("user-1", "link-command"),
|
|
1600
|
+
).toMatchObject({
|
|
1601
|
+
state: "reconciliation-required",
|
|
1602
|
+
failure: "Expired authorization requires provider reconciliation",
|
|
1603
|
+
safeMetadata: {
|
|
1604
|
+
reconciliationOperation: "link",
|
|
1605
|
+
connectedAccountId: "account-1",
|
|
1606
|
+
},
|
|
1607
|
+
});
|
|
1608
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1609
|
+
});
|
|
1610
|
+
|
|
1611
|
+
test("schedules no-account revocation reconciliation after eviction", async () => {
|
|
1612
|
+
const storage = new MemoryStorage();
|
|
1613
|
+
const admitted = createComposioUserBackendContribution(
|
|
1614
|
+
backendHost(storage),
|
|
1615
|
+
);
|
|
1616
|
+
await startInstalledConnection(admitted, {
|
|
1617
|
+
connectionId: "link-command",
|
|
1618
|
+
packageId: "composio",
|
|
1619
|
+
connectionTypeId: "gmail",
|
|
1620
|
+
displayName: "Gmail",
|
|
1621
|
+
safeMetadata: {
|
|
1622
|
+
providerAlias: "link-command",
|
|
1623
|
+
toolkitSlug: "gmail",
|
|
1624
|
+
authorizationStateExpiresAt: Date.now() + 10 * 60_000,
|
|
1625
|
+
},
|
|
1626
|
+
});
|
|
1627
|
+
await admitted.finishConnectionAuthorization("user-1", "link-command", {
|
|
1628
|
+
state: "failed",
|
|
1629
|
+
failure: "Authorization failed",
|
|
1630
|
+
});
|
|
1631
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1632
|
+
|
|
1633
|
+
const recovered = createComposioUserBackendContribution(
|
|
1634
|
+
backendHost(storage),
|
|
1635
|
+
);
|
|
1636
|
+
const claim = await recovered.claimConnectionRevocation(
|
|
1637
|
+
"user-1",
|
|
1638
|
+
"link-command",
|
|
1639
|
+
);
|
|
1640
|
+
|
|
1641
|
+
expect(claim).toMatchObject({
|
|
1642
|
+
phase: "pending",
|
|
1643
|
+
connection: {
|
|
1644
|
+
state: "reconciliation-required",
|
|
1645
|
+
safeMetadata: {
|
|
1646
|
+
reconciliationOperation: "link",
|
|
1647
|
+
revocationRequested: true,
|
|
1648
|
+
},
|
|
1649
|
+
},
|
|
1650
|
+
});
|
|
1651
|
+
const retryAt = claim.connection.safeMetadata.reconciliationRetryAt;
|
|
1652
|
+
if (typeof retryAt !== "number") {
|
|
1653
|
+
throw new Error("expected a numeric reconciliation retry deadline");
|
|
1654
|
+
}
|
|
1655
|
+
expect(storage.alarmAt).toBe(retryAt);
|
|
1656
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1657
|
+
});
|
|
1658
|
+
|
|
1659
|
+
test("finishes uncertain revocation through a provider read after eviction", async () => {
|
|
1660
|
+
const storage = new MemoryStorage();
|
|
1661
|
+
const admitted = createComposioUserBackendContribution(
|
|
1662
|
+
backendHost(storage),
|
|
1663
|
+
);
|
|
1664
|
+
await startInstalledConnection(admitted, {
|
|
1665
|
+
connectionId: "connection-1",
|
|
1666
|
+
packageId: "composio",
|
|
1667
|
+
connectionTypeId: "gmail",
|
|
1668
|
+
displayName: "Gmail",
|
|
1669
|
+
});
|
|
1670
|
+
await admitted.recordConnectLinkResult("user-1", "connection-1", {
|
|
1671
|
+
connectedAccountId: "account-1",
|
|
1672
|
+
providerAlias: "connection-1",
|
|
1673
|
+
toolkitSlug: "gmail",
|
|
1674
|
+
});
|
|
1675
|
+
await admitted.finishConnectionAuthorization("user-1", "connection-1", {
|
|
1676
|
+
state: "ready",
|
|
1677
|
+
});
|
|
1678
|
+
expect(
|
|
1679
|
+
(await admitted.claimConnectionRevocation("user-1", "connection-1"))
|
|
1680
|
+
.phase,
|
|
1681
|
+
).toBe("provider");
|
|
1682
|
+
await admitted.requireConnectionReconciliation(
|
|
1683
|
+
"user-1",
|
|
1684
|
+
"connection-1",
|
|
1685
|
+
"revoke",
|
|
1686
|
+
"Revocation outcome requires reconciliation",
|
|
1687
|
+
);
|
|
1688
|
+
await makeReconciliationDue(storage);
|
|
1689
|
+
let reads = 0;
|
|
1690
|
+
const recovered = createComposioUserBackendContribution(
|
|
1691
|
+
backendHost(storage, () => {
|
|
1692
|
+
reads += 1;
|
|
1693
|
+
return Promise.resolve({ status: "revoked" });
|
|
1694
|
+
}),
|
|
1695
|
+
);
|
|
1696
|
+
|
|
1697
|
+
await recovered.alarm();
|
|
1698
|
+
|
|
1699
|
+
expect(reads).toBe(1);
|
|
1700
|
+
expect(
|
|
1701
|
+
await recovered.getConnection("user-1", "connection-1"),
|
|
1702
|
+
).toMatchObject({ state: "revoked" });
|
|
1703
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1704
|
+
});
|
|
1705
|
+
|
|
1706
|
+
test("accepts an ACTIVE account after callback authorization expires", async () => {
|
|
1707
|
+
const storage = new MemoryStorage();
|
|
1708
|
+
let reads = 0;
|
|
1709
|
+
const contribution = createComposioUserBackendContribution(
|
|
1710
|
+
backendHost(storage, () => {
|
|
1711
|
+
reads += 1;
|
|
1712
|
+
return Promise.resolve({
|
|
1713
|
+
status: "active",
|
|
1714
|
+
account: {
|
|
1715
|
+
id: "account-1",
|
|
1716
|
+
status: "ACTIVE",
|
|
1717
|
+
toolkitSlug: "gmail",
|
|
1718
|
+
alias: "link-command",
|
|
1719
|
+
},
|
|
1720
|
+
});
|
|
1721
|
+
}),
|
|
1722
|
+
);
|
|
1723
|
+
await startInstalledConnection(contribution, {
|
|
1724
|
+
connectionId: "link-command",
|
|
1725
|
+
packageId: "composio",
|
|
1726
|
+
connectionTypeId: "gmail",
|
|
1727
|
+
displayName: "Gmail",
|
|
1728
|
+
safeMetadata: {
|
|
1729
|
+
providerAlias: "link-command",
|
|
1730
|
+
toolkitSlug: "gmail",
|
|
1731
|
+
authorizationStateExpiresAt: Date.now() - 1,
|
|
1732
|
+
},
|
|
1733
|
+
});
|
|
1734
|
+
await contribution.requireConnectionReconciliation(
|
|
1735
|
+
"user-1",
|
|
1736
|
+
"link-command",
|
|
1737
|
+
"link",
|
|
1738
|
+
"Connect Link outcome requires reconciliation",
|
|
1739
|
+
);
|
|
1740
|
+
await makeReconciliationDue(storage);
|
|
1741
|
+
|
|
1742
|
+
await contribution.alarm();
|
|
1743
|
+
|
|
1744
|
+
expect(reads).toBe(1);
|
|
1745
|
+
expect(
|
|
1746
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1747
|
+
).toMatchObject({
|
|
1748
|
+
state: "ready",
|
|
1749
|
+
safeMetadata: {
|
|
1750
|
+
connectedAccountId: "account-1",
|
|
1751
|
+
authorizationStateConsumed: true,
|
|
1752
|
+
},
|
|
1753
|
+
});
|
|
1754
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1755
|
+
});
|
|
1756
|
+
|
|
1757
|
+
test("dispatches a requested revocation after Link identity recovery", async () => {
|
|
1758
|
+
const storage = new MemoryStorage();
|
|
1759
|
+
let revokeCalls = 0;
|
|
1760
|
+
const contribution = createComposioUserBackendContribution(
|
|
1761
|
+
backendHost(
|
|
1762
|
+
storage,
|
|
1763
|
+
() =>
|
|
1764
|
+
Promise.resolve({
|
|
1765
|
+
status: "pending",
|
|
1766
|
+
account: {
|
|
1767
|
+
id: "account-1",
|
|
1768
|
+
status: "INITIALIZING",
|
|
1769
|
+
toolkitSlug: "gmail",
|
|
1770
|
+
alias: "link-command",
|
|
1771
|
+
},
|
|
1772
|
+
}),
|
|
1773
|
+
() => {
|
|
1774
|
+
revokeCalls += 1;
|
|
1775
|
+
return Promise.resolve({ success: true });
|
|
1776
|
+
},
|
|
1777
|
+
),
|
|
1778
|
+
);
|
|
1779
|
+
await startInstalledConnection(contribution, {
|
|
1780
|
+
connectionId: "link-command",
|
|
1781
|
+
packageId: "composio",
|
|
1782
|
+
connectionTypeId: "gmail",
|
|
1783
|
+
displayName: "Gmail",
|
|
1784
|
+
safeMetadata: {
|
|
1785
|
+
providerAlias: "link-command",
|
|
1786
|
+
toolkitSlug: "gmail",
|
|
1787
|
+
authorizationStateExpiresAt: Date.now() + 60_000,
|
|
1788
|
+
},
|
|
1789
|
+
});
|
|
1790
|
+
await contribution.requireConnectionReconciliation(
|
|
1791
|
+
"user-1",
|
|
1792
|
+
"link-command",
|
|
1793
|
+
"link",
|
|
1794
|
+
"Connect Link outcome requires reconciliation",
|
|
1795
|
+
);
|
|
1796
|
+
await contribution.claimConnectionRevocation("user-1", "link-command");
|
|
1797
|
+
await makeReconciliationDue(storage);
|
|
1798
|
+
|
|
1799
|
+
await contribution.alarm();
|
|
1800
|
+
|
|
1801
|
+
expect(revokeCalls).toBe(1);
|
|
1802
|
+
expect(
|
|
1803
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1804
|
+
).toMatchObject({
|
|
1805
|
+
state: "revoked",
|
|
1806
|
+
safeMetadata: { connectedAccountId: "account-1" },
|
|
1807
|
+
});
|
|
1808
|
+
});
|
|
1809
|
+
|
|
1810
|
+
test("keeps expired revocation scheduled until REVOKED is observed", async () => {
|
|
1811
|
+
const storage = new MemoryStorage();
|
|
1812
|
+
let reads = 0;
|
|
1813
|
+
let revokeCalls = 0;
|
|
1814
|
+
const contribution = createComposioUserBackendContribution(
|
|
1815
|
+
backendHost(
|
|
1816
|
+
storage,
|
|
1817
|
+
() => {
|
|
1818
|
+
reads += 1;
|
|
1819
|
+
return Promise.resolve(
|
|
1820
|
+
reads === 1
|
|
1821
|
+
? { status: "pending" as const }
|
|
1822
|
+
: {
|
|
1823
|
+
status: "revoked" as const,
|
|
1824
|
+
account: {
|
|
1825
|
+
id: "account-1",
|
|
1826
|
+
status: "REVOKED",
|
|
1827
|
+
toolkitSlug: "gmail",
|
|
1828
|
+
alias: "link-command",
|
|
1829
|
+
},
|
|
1830
|
+
},
|
|
1831
|
+
);
|
|
1832
|
+
},
|
|
1833
|
+
() => {
|
|
1834
|
+
revokeCalls += 1;
|
|
1835
|
+
return Promise.resolve({ success: true });
|
|
1836
|
+
},
|
|
1837
|
+
),
|
|
1838
|
+
);
|
|
1839
|
+
await startInstalledConnection(contribution, {
|
|
1840
|
+
connectionId: "link-command",
|
|
1841
|
+
packageId: "composio",
|
|
1842
|
+
connectionTypeId: "gmail",
|
|
1843
|
+
displayName: "Gmail",
|
|
1844
|
+
safeMetadata: {
|
|
1845
|
+
providerAlias: "link-command",
|
|
1846
|
+
toolkitSlug: "gmail",
|
|
1847
|
+
expiresAt: new Date(Date.now() - 1).toISOString(),
|
|
1848
|
+
authorizationStateExpiresAt: Date.now() - 1,
|
|
1849
|
+
},
|
|
1850
|
+
});
|
|
1851
|
+
await contribution.requireConnectionReconciliation(
|
|
1852
|
+
"user-1",
|
|
1853
|
+
"link-command",
|
|
1854
|
+
"link",
|
|
1855
|
+
"Connect Link outcome requires reconciliation",
|
|
1856
|
+
);
|
|
1857
|
+
await contribution.claimConnectionRevocation("user-1", "link-command");
|
|
1858
|
+
await makeReconciliationDue(storage);
|
|
1859
|
+
|
|
1860
|
+
await contribution.alarm();
|
|
1861
|
+
|
|
1862
|
+
expect(reads).toBe(1);
|
|
1863
|
+
expect(
|
|
1864
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1865
|
+
).toMatchObject({
|
|
1866
|
+
state: "reconciliation-required",
|
|
1867
|
+
safeMetadata: {
|
|
1868
|
+
reconciliationOperation: "link",
|
|
1869
|
+
revocationRequested: true,
|
|
1870
|
+
},
|
|
1871
|
+
});
|
|
1872
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1873
|
+
await makeReconciliationDue(storage);
|
|
1874
|
+
|
|
1875
|
+
await contribution.alarm();
|
|
1876
|
+
|
|
1877
|
+
expect(reads).toBe(2);
|
|
1878
|
+
expect(revokeCalls).toBe(0);
|
|
1879
|
+
expect(
|
|
1880
|
+
await contribution.getConnection("user-1", "link-command"),
|
|
1881
|
+
).toMatchObject({
|
|
1882
|
+
state: "revoked",
|
|
1883
|
+
safeMetadata: { connectedAccountId: "account-1" },
|
|
1884
|
+
});
|
|
1885
|
+
expect(storage.alarmAt).toBeUndefined();
|
|
1886
|
+
});
|
|
1887
|
+
|
|
1888
|
+
test("keeps non-definitive revocation status scheduled", async () => {
|
|
1889
|
+
const storage = new MemoryStorage();
|
|
1890
|
+
const client = new ComposioClient({
|
|
1891
|
+
apiKey: "secret",
|
|
1892
|
+
fetch: () =>
|
|
1893
|
+
Promise.resolve(
|
|
1894
|
+
Response.json({
|
|
1895
|
+
id: "account-1",
|
|
1896
|
+
user_id: "user-1",
|
|
1897
|
+
status: "FAILED",
|
|
1898
|
+
toolkit: { slug: "gmail" },
|
|
1899
|
+
}),
|
|
1900
|
+
),
|
|
1901
|
+
});
|
|
1902
|
+
const contribution = createComposioUserBackendContribution(
|
|
1903
|
+
backendHost(storage, (request) =>
|
|
1904
|
+
reconcileComposioProviderConnection(client, request),
|
|
1905
|
+
),
|
|
1906
|
+
);
|
|
1907
|
+
await startInstalledConnection(contribution, {
|
|
1908
|
+
connectionId: "connection-1",
|
|
1909
|
+
packageId: "composio",
|
|
1910
|
+
connectionTypeId: "gmail",
|
|
1911
|
+
displayName: "Gmail",
|
|
1912
|
+
});
|
|
1913
|
+
await contribution.recordConnectLinkResult("user-1", "connection-1", {
|
|
1914
|
+
connectedAccountId: "account-1",
|
|
1915
|
+
providerAlias: "connection-1",
|
|
1916
|
+
toolkitSlug: "gmail",
|
|
1917
|
+
});
|
|
1918
|
+
await contribution.finishConnectionAuthorization("user-1", "connection-1", {
|
|
1919
|
+
state: "ready",
|
|
1920
|
+
});
|
|
1921
|
+
await contribution.claimConnectionRevocation("user-1", "connection-1");
|
|
1922
|
+
await contribution.requireConnectionReconciliation(
|
|
1923
|
+
"user-1",
|
|
1924
|
+
"connection-1",
|
|
1925
|
+
"revoke",
|
|
1926
|
+
"Revocation outcome requires reconciliation",
|
|
1927
|
+
);
|
|
1928
|
+
await makeReconciliationDue(storage);
|
|
1929
|
+
|
|
1930
|
+
await contribution.alarm();
|
|
1931
|
+
|
|
1932
|
+
expect(
|
|
1933
|
+
await contribution.getConnection("user-1", "connection-1"),
|
|
1934
|
+
).toMatchObject({ state: "reconciliation-required" });
|
|
1935
|
+
expect(storage.alarmAt).toBeGreaterThan(Date.now());
|
|
1936
|
+
});
|
|
1937
|
+
});
|