@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,1366 @@
|
|
|
1
|
+
import {
|
|
2
|
+
decodeConnectionDependencyRequirementV1,
|
|
3
|
+
USER_PROFILE_PLACEHOLDER_NAME_V1,
|
|
4
|
+
type ConnectionDependencyRequirementV1,
|
|
5
|
+
type UserSettingsViewV1,
|
|
6
|
+
} from "@frockbot/configuration-core";
|
|
7
|
+
import {
|
|
8
|
+
createUserSettingsBackendContribution,
|
|
9
|
+
type UserSettingsBackendContribution,
|
|
10
|
+
type UserSettingsTransaction,
|
|
11
|
+
// pi-lens-ignore: ts:2307
|
|
12
|
+
} from "@frockbot/plugin-settings/user";
|
|
13
|
+
import type { Plugin } from "cordis";
|
|
14
|
+
import {
|
|
15
|
+
acknowledgeDependentAssignment,
|
|
16
|
+
claimDependentAssignment,
|
|
17
|
+
compensateDependentAssignment,
|
|
18
|
+
} from "./dependency-coordination.js";
|
|
19
|
+
import {
|
|
20
|
+
completeAssignmentCompensation,
|
|
21
|
+
isSettledBotCompensation,
|
|
22
|
+
} from "./connection-recovery.js";
|
|
23
|
+
import {
|
|
24
|
+
linkReconciliationDisposition,
|
|
25
|
+
type ComposioProviderReconciliationRequest,
|
|
26
|
+
type ComposioProviderReconciliationResult,
|
|
27
|
+
} from "./provider-reconciliation.js";
|
|
28
|
+
|
|
29
|
+
const STATE_KEY = "user-configuration";
|
|
30
|
+
const IDENTITY_KEY = "user-id";
|
|
31
|
+
const CONNECTION_EFFECT_ALARM_MS = 60_000;
|
|
32
|
+
|
|
33
|
+
function readyAuthorizationMetadata(
|
|
34
|
+
connection: UserSettingsViewV1["connections"][number],
|
|
35
|
+
safeMetadata: UserSettingsViewV1["connections"][number]["safeMetadata"],
|
|
36
|
+
): UserSettingsViewV1["connections"][number]["safeMetadata"] | undefined {
|
|
37
|
+
const commandFingerprint = connection.safeMetadata.startCommandFingerprint;
|
|
38
|
+
if (typeof commandFingerprint !== "string") {
|
|
39
|
+
return { ...safeMetadata, authorizationStateConsumed: true };
|
|
40
|
+
}
|
|
41
|
+
const connectedAccountId = safeMetadata.connectedAccountId;
|
|
42
|
+
const admittedConnectedAccountId = connection.safeMetadata.connectedAccountId;
|
|
43
|
+
if (
|
|
44
|
+
typeof connectedAccountId !== "string" ||
|
|
45
|
+
(typeof admittedConnectedAccountId === "string" &&
|
|
46
|
+
admittedConnectedAccountId !== connectedAccountId)
|
|
47
|
+
) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const nativeReturnNonce = connection.safeMetadata.nativeReturnNonce;
|
|
51
|
+
return {
|
|
52
|
+
...safeMetadata,
|
|
53
|
+
authorizationStateConsumed: true,
|
|
54
|
+
connectionStartReplay: {
|
|
55
|
+
schemaVersion: 1,
|
|
56
|
+
commandFingerprint,
|
|
57
|
+
connectionId: connection.connectionId,
|
|
58
|
+
status: "ready",
|
|
59
|
+
...(typeof nativeReturnNonce === "string" ? { nativeReturnNonce } : {}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function revocationCompensationId(
|
|
65
|
+
botId: string,
|
|
66
|
+
generation: string,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
const digest = await crypto.subtle.digest(
|
|
69
|
+
"SHA-256",
|
|
70
|
+
new TextEncoder().encode(`${botId}\u0000${generation}`),
|
|
71
|
+
);
|
|
72
|
+
return `revocation-${Array.from(new Uint8Array(digest), (byte) =>
|
|
73
|
+
byte.toString(16).padStart(2, "0"),
|
|
74
|
+
).join("")}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function deriveRevocationCompensations(
|
|
78
|
+
connection: UserSettingsViewV1["connections"][number],
|
|
79
|
+
): Promise<Array<{ botId: string; id: string; expectedGeneration: string }>> {
|
|
80
|
+
const dependencies = Array.isArray(
|
|
81
|
+
connection.safeMetadata.dependentAssignments,
|
|
82
|
+
)
|
|
83
|
+
? connection.safeMetadata.dependentAssignments
|
|
84
|
+
: [];
|
|
85
|
+
const dependenciesByKey = new Map<string, string>();
|
|
86
|
+
for (const candidate of dependencies) {
|
|
87
|
+
if (
|
|
88
|
+
!candidate ||
|
|
89
|
+
typeof candidate !== "object" ||
|
|
90
|
+
Array.isArray(candidate)
|
|
91
|
+
) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const dependency = candidate as Record<string, unknown>;
|
|
95
|
+
if (
|
|
96
|
+
typeof dependency.botId === "string" &&
|
|
97
|
+
typeof dependency.generation === "string" &&
|
|
98
|
+
dependency.status === "acknowledged"
|
|
99
|
+
) {
|
|
100
|
+
dependenciesByKey.set(
|
|
101
|
+
`${dependency.botId}\u0000${dependency.generation}`,
|
|
102
|
+
dependency.generation,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return Promise.all(
|
|
107
|
+
[...dependenciesByKey].map(async ([key, expectedGeneration]) => {
|
|
108
|
+
const botId = key.slice(0, key.indexOf("\u0000"));
|
|
109
|
+
return {
|
|
110
|
+
botId,
|
|
111
|
+
id: await revocationCompensationId(botId, expectedGeneration),
|
|
112
|
+
expectedGeneration,
|
|
113
|
+
};
|
|
114
|
+
}),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface UserConfigurationEnv {
|
|
119
|
+
BOT_STATES: DurableObjectNamespace;
|
|
120
|
+
COMPOSIO_API_KEY?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export interface ComposioUserBackendHost {
|
|
124
|
+
state: DurableObjectState;
|
|
125
|
+
env: UserConfigurationEnv;
|
|
126
|
+
availablePackages: readonly { packageId: string; version: string }[];
|
|
127
|
+
reconcileProviderConnection(
|
|
128
|
+
request: ComposioProviderReconciliationRequest,
|
|
129
|
+
): Promise<ComposioProviderReconciliationResult>;
|
|
130
|
+
revokeConnectedAccount(connectedAccountId: string): Promise<unknown>;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface StartConnectionInput {
|
|
134
|
+
connectionId: string;
|
|
135
|
+
packageId: string;
|
|
136
|
+
connectionTypeId: string;
|
|
137
|
+
displayName: string;
|
|
138
|
+
safeMetadata?: UserSettingsViewV1["connections"][number]["safeMetadata"];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function nextConnectionAlarm(settings: UserSettingsViewV1): number | undefined {
|
|
142
|
+
const deadlines = settings.connections.flatMap((connection) => {
|
|
143
|
+
const values: number[] = [];
|
|
144
|
+
const metadata = connection.safeMetadata;
|
|
145
|
+
if (
|
|
146
|
+
((connection.state === "authorizing" &&
|
|
147
|
+
typeof metadata.connectedAccountId !== "string") ||
|
|
148
|
+
connection.state === "revoking") &&
|
|
149
|
+
typeof metadata.effectDeadlineAt === "number"
|
|
150
|
+
) {
|
|
151
|
+
values.push(metadata.effectDeadlineAt);
|
|
152
|
+
}
|
|
153
|
+
if (
|
|
154
|
+
metadata.revocationRequested !== true &&
|
|
155
|
+
connection.state === "authorizing"
|
|
156
|
+
) {
|
|
157
|
+
if (typeof metadata.authorizationStateExpiresAt === "number") {
|
|
158
|
+
values.push(metadata.authorizationStateExpiresAt);
|
|
159
|
+
}
|
|
160
|
+
if (typeof metadata.expiresAt === "string") {
|
|
161
|
+
const expiresAt = Date.parse(metadata.expiresAt);
|
|
162
|
+
values.push(Number.isFinite(expiresAt) ? expiresAt : 0);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (
|
|
166
|
+
connection.state === "reconciliation-required" &&
|
|
167
|
+
(metadata.reconciliationOperation === "link" ||
|
|
168
|
+
metadata.reconciliationOperation === "revoke") &&
|
|
169
|
+
typeof metadata.reconciliationRetryAt === "number"
|
|
170
|
+
) {
|
|
171
|
+
values.push(metadata.reconciliationRetryAt);
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
metadata.assignmentCompensationPending === true &&
|
|
175
|
+
typeof metadata.compensationRetryAt === "number"
|
|
176
|
+
) {
|
|
177
|
+
values.push(metadata.compensationRetryAt);
|
|
178
|
+
}
|
|
179
|
+
return values;
|
|
180
|
+
});
|
|
181
|
+
return deadlines.length > 0 ? Math.min(...deadlines) : undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function connectionAuthorizationExpired(
|
|
185
|
+
connection: UserSettingsViewV1["connections"][number],
|
|
186
|
+
now: number,
|
|
187
|
+
): boolean {
|
|
188
|
+
const metadata = connection.safeMetadata;
|
|
189
|
+
if (metadata.revocationRequested === true) return false;
|
|
190
|
+
if (
|
|
191
|
+
connection.state !== "authorizing" &&
|
|
192
|
+
!(
|
|
193
|
+
connection.state === "reconciliation-required" &&
|
|
194
|
+
metadata.reconciliationOperation === "link"
|
|
195
|
+
)
|
|
196
|
+
) {
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
const stateExpired =
|
|
200
|
+
typeof metadata.authorizationStateExpiresAt === "number" &&
|
|
201
|
+
metadata.authorizationStateExpiresAt <= now;
|
|
202
|
+
const linkExpiry =
|
|
203
|
+
typeof metadata.expiresAt === "string"
|
|
204
|
+
? Date.parse(metadata.expiresAt)
|
|
205
|
+
: undefined;
|
|
206
|
+
const linkExpired =
|
|
207
|
+
linkExpiry !== undefined &&
|
|
208
|
+
(!Number.isFinite(linkExpiry) || linkExpiry <= now);
|
|
209
|
+
return stateExpired || linkExpired;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function hasUnresolvedLinkEffect(
|
|
213
|
+
connection: UserSettingsViewV1["connections"][number],
|
|
214
|
+
): boolean {
|
|
215
|
+
const metadata = connection.safeMetadata;
|
|
216
|
+
if (
|
|
217
|
+
connection.state === "authorizing" &&
|
|
218
|
+
typeof metadata.connectedAccountId !== "string"
|
|
219
|
+
) {
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
if (
|
|
223
|
+
connection.state === "reconciliation-required" &&
|
|
224
|
+
metadata.reconciliationOperation === "link"
|
|
225
|
+
) {
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
return (
|
|
229
|
+
metadata.lostLinkCleanup === true &&
|
|
230
|
+
(connection.state === "revoking" ||
|
|
231
|
+
(connection.state === "reconciliation-required" &&
|
|
232
|
+
metadata.reconciliationOperation === "revoke"))
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const initialState = (): UserSettingsViewV1 => ({
|
|
237
|
+
schemaVersion: 1,
|
|
238
|
+
revision: 0,
|
|
239
|
+
profile: { name: USER_PROFILE_PLACEHOLDER_NAME_V1 },
|
|
240
|
+
packages: [],
|
|
241
|
+
connections: [],
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
export class ComposioUserBackendContribution {
|
|
245
|
+
readonly ctx: DurableObjectState;
|
|
246
|
+
readonly env: UserConfigurationEnv;
|
|
247
|
+
private readonly settings: UserSettingsBackendContribution;
|
|
248
|
+
private readonly availablePackages: ReadonlySet<string>;
|
|
249
|
+
private readonly reconcileProviderConnection: ComposioUserBackendHost["reconcileProviderConnection"];
|
|
250
|
+
private readonly revokeConnectedAccount: ComposioUserBackendHost["revokeConnectedAccount"];
|
|
251
|
+
|
|
252
|
+
constructor(host: ComposioUserBackendHost) {
|
|
253
|
+
this.ctx = host.state;
|
|
254
|
+
this.env = host.env;
|
|
255
|
+
this.settings = createUserSettingsBackendContribution({
|
|
256
|
+
storage: host.state.storage,
|
|
257
|
+
availablePackages: host.availablePackages,
|
|
258
|
+
});
|
|
259
|
+
this.availablePackages = new Set(
|
|
260
|
+
host.availablePackages.map(
|
|
261
|
+
({ packageId, version }) => `${packageId}\u0000${version}`,
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
this.reconcileProviderConnection = host.reconcileProviderConnection;
|
|
265
|
+
this.revokeConnectedAccount = host.revokeConnectedAccount;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
readConfiguration(input: unknown): Promise<UserSettingsViewV1> {
|
|
269
|
+
return this.settings.readConfiguration(input);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
executeConfiguration(
|
|
273
|
+
input: unknown,
|
|
274
|
+
): ReturnType<UserSettingsBackendContribution["executeConfiguration"]> {
|
|
275
|
+
return this.settings.executeConfiguration(input);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
readSnapshot(storage: UserSettingsTransaction): Promise<UserSettingsViewV1> {
|
|
279
|
+
return this.settings.readSnapshot(storage);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
read(userId: string): Promise<UserSettingsViewV1> {
|
|
283
|
+
return this.settings.read(userId);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
isPackageInstalled(userId: string, packageId: string): Promise<boolean> {
|
|
287
|
+
return this.settings.isPackageInstalled(userId, packageId);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
async getConnection(
|
|
291
|
+
userId: string,
|
|
292
|
+
connectionId: string,
|
|
293
|
+
): Promise<UserSettingsViewV1["connections"][number] | undefined> {
|
|
294
|
+
const settings = await this.read(userId);
|
|
295
|
+
return settings.connections.find(
|
|
296
|
+
(connection) => connection.connectionId === connectionId,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
async startConnection(
|
|
301
|
+
userId: string,
|
|
302
|
+
input: StartConnectionInput,
|
|
303
|
+
): Promise<boolean> {
|
|
304
|
+
await this.assertIdentity(userId);
|
|
305
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
306
|
+
const current =
|
|
307
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
308
|
+
initialState();
|
|
309
|
+
const installed = current.packages.find(
|
|
310
|
+
(installedPackage) =>
|
|
311
|
+
installedPackage.packageId === input.packageId &&
|
|
312
|
+
installedPackage.state === "installed",
|
|
313
|
+
);
|
|
314
|
+
if (!installed) {
|
|
315
|
+
throw new Error(`Package "${input.packageId}" is not installed`);
|
|
316
|
+
}
|
|
317
|
+
if (
|
|
318
|
+
!this.availablePackages.has(
|
|
319
|
+
`${installed.packageId}\u0000${installed.version}`,
|
|
320
|
+
)
|
|
321
|
+
) {
|
|
322
|
+
throw new Error(`Package "${input.packageId}" is not available`);
|
|
323
|
+
}
|
|
324
|
+
const existing = current.connections.find(
|
|
325
|
+
(connection) => connection.connectionId === input.connectionId,
|
|
326
|
+
);
|
|
327
|
+
if (existing) return false;
|
|
328
|
+
const unresolved = current.connections.find(
|
|
329
|
+
(connection) =>
|
|
330
|
+
connection.packageId === input.packageId &&
|
|
331
|
+
connection.connectionTypeId === input.connectionTypeId &&
|
|
332
|
+
hasUnresolvedLinkEffect(connection),
|
|
333
|
+
);
|
|
334
|
+
if (unresolved) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
"Previous Connection authorization requires reconciliation",
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
const effectDeadlineAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
|
|
340
|
+
const next = {
|
|
341
|
+
...current,
|
|
342
|
+
revision: current.revision + 1,
|
|
343
|
+
connections: [
|
|
344
|
+
...current.connections,
|
|
345
|
+
{
|
|
346
|
+
...input,
|
|
347
|
+
state: "authorizing" as const,
|
|
348
|
+
safeMetadata: {
|
|
349
|
+
...(input.safeMetadata ?? {}),
|
|
350
|
+
effectDeadlineAt,
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
],
|
|
354
|
+
} satisfies UserSettingsViewV1;
|
|
355
|
+
await transaction.put(STATE_KEY, next);
|
|
356
|
+
await transaction.setAlarm(nextConnectionAlarm(next) ?? effectDeadlineAt);
|
|
357
|
+
return true;
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async recordConnectLinkResult(
|
|
362
|
+
userId: string,
|
|
363
|
+
connectionId: string,
|
|
364
|
+
safeMetadata: UserSettingsViewV1["connections"][number]["safeMetadata"],
|
|
365
|
+
): Promise<boolean> {
|
|
366
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
367
|
+
if (connection.state === "failed") {
|
|
368
|
+
return {
|
|
369
|
+
...connection,
|
|
370
|
+
state: "reconciliation-required",
|
|
371
|
+
safeMetadata: {
|
|
372
|
+
...safeMetadata,
|
|
373
|
+
authorizationStateConsumed: true,
|
|
374
|
+
revocationRequested: true,
|
|
375
|
+
reconciliationOperation: "link",
|
|
376
|
+
reconciliationRetryAt: Date.now() + CONNECTION_EFFECT_ALARM_MS,
|
|
377
|
+
},
|
|
378
|
+
failure: "Late Connect Link requires cleanup",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
const operation = connection.safeMetadata.reconciliationOperation;
|
|
382
|
+
if (
|
|
383
|
+
connection.state !== "authorizing" &&
|
|
384
|
+
!(
|
|
385
|
+
connection.state === "reconciliation-required" && operation === "link"
|
|
386
|
+
)
|
|
387
|
+
) {
|
|
388
|
+
return undefined;
|
|
389
|
+
}
|
|
390
|
+
if (connection.safeMetadata.revocationRequested === true) {
|
|
391
|
+
return {
|
|
392
|
+
...connection,
|
|
393
|
+
state: "reconciliation-required",
|
|
394
|
+
safeMetadata: {
|
|
395
|
+
...safeMetadata,
|
|
396
|
+
revocationRequested: true,
|
|
397
|
+
reconciliationOperation: "link",
|
|
398
|
+
reconciliationRetryAt:
|
|
399
|
+
typeof connection.safeMetadata.reconciliationRetryAt === "number"
|
|
400
|
+
? connection.safeMetadata.reconciliationRetryAt
|
|
401
|
+
: Date.now() + CONNECTION_EFFECT_ALARM_MS,
|
|
402
|
+
},
|
|
403
|
+
failure:
|
|
404
|
+
"Connection identity requires reconciliation before revocation",
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
...connection,
|
|
409
|
+
state: "authorizing",
|
|
410
|
+
safeMetadata,
|
|
411
|
+
failure: undefined,
|
|
412
|
+
};
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async recordLinkReconciliationIdentity(
|
|
417
|
+
userId: string,
|
|
418
|
+
connectionId: string,
|
|
419
|
+
safeMetadata: UserSettingsViewV1["connections"][number]["safeMetadata"],
|
|
420
|
+
): Promise<boolean> {
|
|
421
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
422
|
+
if (
|
|
423
|
+
connection.state !== "reconciliation-required" ||
|
|
424
|
+
connection.safeMetadata.reconciliationOperation !== "link"
|
|
425
|
+
) {
|
|
426
|
+
return undefined;
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
...connection,
|
|
430
|
+
safeMetadata: {
|
|
431
|
+
...connection.safeMetadata,
|
|
432
|
+
...safeMetadata,
|
|
433
|
+
reconciliationOperation: "link",
|
|
434
|
+
reconciliationRetryAt:
|
|
435
|
+
typeof connection.safeMetadata.reconciliationRetryAt === "number"
|
|
436
|
+
? connection.safeMetadata.reconciliationRetryAt
|
|
437
|
+
: Date.now() + CONNECTION_EFFECT_ALARM_MS,
|
|
438
|
+
},
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async claimLostLinkCleanup(
|
|
444
|
+
userId: string,
|
|
445
|
+
connectionId: string,
|
|
446
|
+
safeMetadata: UserSettingsViewV1["connections"][number]["safeMetadata"],
|
|
447
|
+
): Promise<{
|
|
448
|
+
phase: "provider" | "pending" | "done";
|
|
449
|
+
connection: UserSettingsViewV1["connections"][number];
|
|
450
|
+
}> {
|
|
451
|
+
await this.assertIdentity(userId);
|
|
452
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
453
|
+
const current =
|
|
454
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
455
|
+
initialState();
|
|
456
|
+
const connection = current.connections.find(
|
|
457
|
+
(item) => item.connectionId === connectionId,
|
|
458
|
+
);
|
|
459
|
+
if (!connection) {
|
|
460
|
+
throw new Error(`Connection "${connectionId}" was not admitted`);
|
|
461
|
+
}
|
|
462
|
+
if (connection.state === "revoked") {
|
|
463
|
+
return { phase: "done" as const, connection };
|
|
464
|
+
}
|
|
465
|
+
if (
|
|
466
|
+
connection.safeMetadata.lostLinkCleanup === true &&
|
|
467
|
+
(connection.state === "revoking" ||
|
|
468
|
+
(connection.state === "reconciliation-required" &&
|
|
469
|
+
connection.safeMetadata.reconciliationOperation === "revoke"))
|
|
470
|
+
) {
|
|
471
|
+
return { phase: "pending" as const, connection };
|
|
472
|
+
}
|
|
473
|
+
const connectedAccountId = safeMetadata.connectedAccountId;
|
|
474
|
+
if (
|
|
475
|
+
connection.state !== "reconciliation-required" ||
|
|
476
|
+
connection.safeMetadata.reconciliationOperation !== "link" ||
|
|
477
|
+
connection.safeMetadata.revocationRequested === true ||
|
|
478
|
+
typeof connectedAccountId !== "string" ||
|
|
479
|
+
(typeof connection.safeMetadata.connectedAccountId === "string" &&
|
|
480
|
+
connection.safeMetadata.connectedAccountId !== connectedAccountId)
|
|
481
|
+
) {
|
|
482
|
+
throw new Error("Pending Link cannot enter cleanup");
|
|
483
|
+
}
|
|
484
|
+
const effectDeadlineAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
|
|
485
|
+
const assignmentCompensations =
|
|
486
|
+
await deriveRevocationCompensations(connection);
|
|
487
|
+
const claimed = {
|
|
488
|
+
...connection,
|
|
489
|
+
state: "revoking" as const,
|
|
490
|
+
safeMetadata: {
|
|
491
|
+
...connection.safeMetadata,
|
|
492
|
+
...safeMetadata,
|
|
493
|
+
authorizationStateConsumed: true,
|
|
494
|
+
lostLinkCleanup: true,
|
|
495
|
+
revocationRequested: true,
|
|
496
|
+
reconciliationOperation: "revoke",
|
|
497
|
+
revocationProviderCompleted: false,
|
|
498
|
+
effectDeadlineAt,
|
|
499
|
+
assignmentCompensationPending: assignmentCompensations.length > 0,
|
|
500
|
+
assignmentCompensations,
|
|
501
|
+
...(assignmentCompensations.length > 0
|
|
502
|
+
? { compensationRetryAt: effectDeadlineAt }
|
|
503
|
+
: {}),
|
|
504
|
+
},
|
|
505
|
+
failure: "Lost Connect Link cleanup requires provider reconciliation",
|
|
506
|
+
};
|
|
507
|
+
const next = {
|
|
508
|
+
...current,
|
|
509
|
+
revision: current.revision + 1,
|
|
510
|
+
connections: current.connections.map((item) =>
|
|
511
|
+
item.connectionId === connectionId ? claimed : item,
|
|
512
|
+
),
|
|
513
|
+
} satisfies UserSettingsViewV1;
|
|
514
|
+
await transaction.put(STATE_KEY, next);
|
|
515
|
+
await transaction.setAlarm(nextConnectionAlarm(next) ?? effectDeadlineAt);
|
|
516
|
+
return { phase: "provider" as const, connection: claimed };
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
async finishConnectionAuthorization(
|
|
521
|
+
userId: string,
|
|
522
|
+
connectionId: string,
|
|
523
|
+
update: {
|
|
524
|
+
state: "ready" | "failed";
|
|
525
|
+
safeMetadata?: UserSettingsViewV1["connections"][number]["safeMetadata"];
|
|
526
|
+
failure?: string;
|
|
527
|
+
authorizationStateId?: string;
|
|
528
|
+
},
|
|
529
|
+
): Promise<boolean> {
|
|
530
|
+
if (update.authorizationStateId !== undefined) {
|
|
531
|
+
await this.assertIdentity(userId);
|
|
532
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
533
|
+
const current =
|
|
534
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
535
|
+
initialState();
|
|
536
|
+
const connection = current.connections.find(
|
|
537
|
+
(item) => item.connectionId === connectionId,
|
|
538
|
+
);
|
|
539
|
+
if (
|
|
540
|
+
!connection ||
|
|
541
|
+
connection.safeMetadata.authorizationStateId !==
|
|
542
|
+
update.authorizationStateId
|
|
543
|
+
) {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
if (
|
|
547
|
+
connection.state === "ready" ||
|
|
548
|
+
connection.state === "failed" ||
|
|
549
|
+
connection.safeMetadata.revocationRequested === true
|
|
550
|
+
) {
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
const operation = connection.safeMetadata.reconciliationOperation;
|
|
554
|
+
if (
|
|
555
|
+
connection.state !== "authorizing" &&
|
|
556
|
+
!(
|
|
557
|
+
connection.state === "reconciliation-required" &&
|
|
558
|
+
operation === "link"
|
|
559
|
+
)
|
|
560
|
+
) {
|
|
561
|
+
return false;
|
|
562
|
+
}
|
|
563
|
+
if (
|
|
564
|
+
connection.safeMetadata.authorizationStateConsumed !== true &&
|
|
565
|
+
(typeof connection.safeMetadata.authorizationStateExpiresAt !==
|
|
566
|
+
"number" ||
|
|
567
|
+
connection.safeMetadata.authorizationStateExpiresAt <= Date.now())
|
|
568
|
+
) {
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
const safeMetadata =
|
|
572
|
+
update.state === "ready"
|
|
573
|
+
? readyAuthorizationMetadata(
|
|
574
|
+
connection,
|
|
575
|
+
update.safeMetadata ?? connection.safeMetadata,
|
|
576
|
+
)
|
|
577
|
+
: {
|
|
578
|
+
...(update.safeMetadata ?? connection.safeMetadata),
|
|
579
|
+
authorizationStateConsumed: true,
|
|
580
|
+
};
|
|
581
|
+
if (!safeMetadata) return false;
|
|
582
|
+
const nextConnection = {
|
|
583
|
+
...connection,
|
|
584
|
+
state: update.state,
|
|
585
|
+
safeMetadata,
|
|
586
|
+
failure: update.failure,
|
|
587
|
+
};
|
|
588
|
+
const next = {
|
|
589
|
+
...current,
|
|
590
|
+
revision: current.revision + 1,
|
|
591
|
+
connections: current.connections.map((item) =>
|
|
592
|
+
item.connectionId === connectionId ? nextConnection : item,
|
|
593
|
+
),
|
|
594
|
+
} satisfies UserSettingsViewV1;
|
|
595
|
+
await transaction.put(STATE_KEY, next);
|
|
596
|
+
const alarmAt = nextConnectionAlarm(next);
|
|
597
|
+
if (alarmAt === undefined) await transaction.deleteAlarm();
|
|
598
|
+
else await transaction.setAlarm(alarmAt);
|
|
599
|
+
return true;
|
|
600
|
+
});
|
|
601
|
+
}
|
|
602
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
603
|
+
const operation = connection.safeMetadata.reconciliationOperation;
|
|
604
|
+
if (
|
|
605
|
+
connection.safeMetadata.revocationRequested === true ||
|
|
606
|
+
(connection.state !== "authorizing" &&
|
|
607
|
+
!(
|
|
608
|
+
connection.state === "reconciliation-required" &&
|
|
609
|
+
operation === "link"
|
|
610
|
+
))
|
|
611
|
+
) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
const safeMetadata =
|
|
615
|
+
update.state === "ready"
|
|
616
|
+
? readyAuthorizationMetadata(
|
|
617
|
+
connection,
|
|
618
|
+
update.safeMetadata ?? connection.safeMetadata,
|
|
619
|
+
)
|
|
620
|
+
: {
|
|
621
|
+
...(update.safeMetadata ?? connection.safeMetadata),
|
|
622
|
+
authorizationStateConsumed: true,
|
|
623
|
+
};
|
|
624
|
+
if (!safeMetadata) return undefined;
|
|
625
|
+
return {
|
|
626
|
+
...connection,
|
|
627
|
+
state: update.state,
|
|
628
|
+
safeMetadata,
|
|
629
|
+
failure: update.failure,
|
|
630
|
+
};
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
async recordAssignmentCompensated(
|
|
635
|
+
userId: string,
|
|
636
|
+
connectionId: string,
|
|
637
|
+
compensationId: string,
|
|
638
|
+
): Promise<boolean> {
|
|
639
|
+
return this.transitionConnection(userId, connectionId, (connection) =>
|
|
640
|
+
completeAssignmentCompensation(connection, compensationId),
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async claimConnectionDependency(
|
|
645
|
+
userId: string,
|
|
646
|
+
connectionId: string,
|
|
647
|
+
botId: string,
|
|
648
|
+
generation: string,
|
|
649
|
+
requirement: ConnectionDependencyRequirementV1,
|
|
650
|
+
): Promise<boolean> {
|
|
651
|
+
const decoded = decodeConnectionDependencyRequirementV1(requirement);
|
|
652
|
+
await this.assertIdentity(userId);
|
|
653
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
654
|
+
const current =
|
|
655
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
656
|
+
initialState();
|
|
657
|
+
const installation = current.packages.find(
|
|
658
|
+
(pkg) =>
|
|
659
|
+
pkg.packageId === decoded.packageId &&
|
|
660
|
+
pkg.version === decoded.packageVersion &&
|
|
661
|
+
pkg.state === "installed",
|
|
662
|
+
);
|
|
663
|
+
const connection = current.connections.find(
|
|
664
|
+
(item) => item.connectionId === connectionId,
|
|
665
|
+
);
|
|
666
|
+
if (
|
|
667
|
+
!installation ||
|
|
668
|
+
!connection ||
|
|
669
|
+
connection.packageId !== decoded.packageId ||
|
|
670
|
+
!decoded.connectionTypeIds.includes(connection.connectionTypeId)
|
|
671
|
+
) {
|
|
672
|
+
return false;
|
|
673
|
+
}
|
|
674
|
+
const nextConnection = claimDependentAssignment(
|
|
675
|
+
connection,
|
|
676
|
+
botId,
|
|
677
|
+
generation,
|
|
678
|
+
);
|
|
679
|
+
if (!nextConnection) return false;
|
|
680
|
+
const next = {
|
|
681
|
+
...current,
|
|
682
|
+
revision: current.revision + 1,
|
|
683
|
+
connections: current.connections.map((item) =>
|
|
684
|
+
item.connectionId === connectionId ? nextConnection : item,
|
|
685
|
+
),
|
|
686
|
+
} satisfies UserSettingsViewV1;
|
|
687
|
+
await transaction.put(STATE_KEY, next);
|
|
688
|
+
const alarmAt = nextConnectionAlarm(next);
|
|
689
|
+
if (alarmAt === undefined) {
|
|
690
|
+
await transaction.deleteAlarm();
|
|
691
|
+
} else {
|
|
692
|
+
await transaction.setAlarm(Math.max(Date.now(), alarmAt));
|
|
693
|
+
}
|
|
694
|
+
return true;
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async acknowledgeConnectionDependency(
|
|
699
|
+
userId: string,
|
|
700
|
+
connectionId: string,
|
|
701
|
+
botId: string,
|
|
702
|
+
generation: string,
|
|
703
|
+
): Promise<boolean> {
|
|
704
|
+
return this.transitionConnection(userId, connectionId, (connection) =>
|
|
705
|
+
acknowledgeDependentAssignment(connection, botId, generation),
|
|
706
|
+
);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
async compensateConnectionDependency(
|
|
710
|
+
userId: string,
|
|
711
|
+
connectionId: string,
|
|
712
|
+
botId: string,
|
|
713
|
+
generation: string,
|
|
714
|
+
): Promise<boolean> {
|
|
715
|
+
return this.transitionConnection(userId, connectionId, (connection) =>
|
|
716
|
+
compensateDependentAssignment(connection, botId, generation),
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
async requireConnectionReconciliation(
|
|
721
|
+
userId: string,
|
|
722
|
+
connectionId: string,
|
|
723
|
+
operation: "link" | "revoke",
|
|
724
|
+
failure: string,
|
|
725
|
+
): Promise<boolean> {
|
|
726
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
727
|
+
if (
|
|
728
|
+
operation === "link" &&
|
|
729
|
+
connection.state !== "authorizing" &&
|
|
730
|
+
!(
|
|
731
|
+
connection.state === "reconciliation-required" &&
|
|
732
|
+
connection.safeMetadata.reconciliationOperation === "link"
|
|
733
|
+
)
|
|
734
|
+
) {
|
|
735
|
+
return undefined;
|
|
736
|
+
}
|
|
737
|
+
if (
|
|
738
|
+
operation === "revoke" &&
|
|
739
|
+
connection.state !== "revoking" &&
|
|
740
|
+
!(
|
|
741
|
+
connection.state === "reconciliation-required" &&
|
|
742
|
+
connection.safeMetadata.reconciliationOperation === "revoke"
|
|
743
|
+
)
|
|
744
|
+
) {
|
|
745
|
+
return undefined;
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
...connection,
|
|
749
|
+
state: "reconciliation-required",
|
|
750
|
+
safeMetadata: {
|
|
751
|
+
...connection.safeMetadata,
|
|
752
|
+
reconciliationOperation: operation,
|
|
753
|
+
reconciliationRetryAt: Date.now() + CONNECTION_EFFECT_ALARM_MS,
|
|
754
|
+
},
|
|
755
|
+
failure,
|
|
756
|
+
};
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
async claimConnectionRevocation(
|
|
761
|
+
userId: string,
|
|
762
|
+
connectionId: string,
|
|
763
|
+
recoveredSafeMetadata?: UserSettingsViewV1["connections"][number]["safeMetadata"],
|
|
764
|
+
): Promise<{
|
|
765
|
+
phase: "provider" | "finalize" | "pending" | "done";
|
|
766
|
+
connection: UserSettingsViewV1["connections"][number];
|
|
767
|
+
}> {
|
|
768
|
+
await this.assertIdentity(userId);
|
|
769
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
770
|
+
const current =
|
|
771
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
772
|
+
initialState();
|
|
773
|
+
let connection = current.connections.find(
|
|
774
|
+
(item) => item.connectionId === connectionId,
|
|
775
|
+
);
|
|
776
|
+
if (!connection) {
|
|
777
|
+
throw new Error(`Connection "${connectionId}" was not admitted`);
|
|
778
|
+
}
|
|
779
|
+
if (connection.state === "revoked") {
|
|
780
|
+
return { phase: "done" as const, connection };
|
|
781
|
+
}
|
|
782
|
+
if (recoveredSafeMetadata) {
|
|
783
|
+
if (
|
|
784
|
+
connection.safeMetadata.revocationRequested !== true ||
|
|
785
|
+
typeof recoveredSafeMetadata.connectedAccountId !== "string" ||
|
|
786
|
+
(connection.state !== "authorizing" &&
|
|
787
|
+
!(
|
|
788
|
+
connection.state === "reconciliation-required" &&
|
|
789
|
+
connection.safeMetadata.reconciliationOperation === "link"
|
|
790
|
+
))
|
|
791
|
+
) {
|
|
792
|
+
throw new Error(
|
|
793
|
+
"Recovered Connection identity cannot enter revocation",
|
|
794
|
+
);
|
|
795
|
+
}
|
|
796
|
+
connection = {
|
|
797
|
+
...connection,
|
|
798
|
+
safeMetadata: {
|
|
799
|
+
...connection.safeMetadata,
|
|
800
|
+
...recoveredSafeMetadata,
|
|
801
|
+
revocationRequested: true,
|
|
802
|
+
},
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
const providerCompleted =
|
|
806
|
+
connection.safeMetadata.revocationProviderCompleted === true;
|
|
807
|
+
if (
|
|
808
|
+
connection.state === "revoking" ||
|
|
809
|
+
(connection.state === "reconciliation-required" &&
|
|
810
|
+
connection.safeMetadata.reconciliationOperation === "revoke")
|
|
811
|
+
) {
|
|
812
|
+
return {
|
|
813
|
+
phase: providerCompleted
|
|
814
|
+
? ("finalize" as const)
|
|
815
|
+
: ("pending" as const),
|
|
816
|
+
connection,
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
const connectedAccountId = connection.safeMetadata.connectedAccountId;
|
|
820
|
+
if (typeof connectedAccountId !== "string") {
|
|
821
|
+
const reconciliationRetryAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
|
|
822
|
+
const pending = {
|
|
823
|
+
...connection,
|
|
824
|
+
state: "reconciliation-required" as const,
|
|
825
|
+
safeMetadata: {
|
|
826
|
+
...connection.safeMetadata,
|
|
827
|
+
reconciliationOperation: "link",
|
|
828
|
+
revocationRequested: true,
|
|
829
|
+
reconciliationRetryAt,
|
|
830
|
+
},
|
|
831
|
+
failure:
|
|
832
|
+
"Connection identity requires reconciliation before revocation",
|
|
833
|
+
};
|
|
834
|
+
const next = {
|
|
835
|
+
...current,
|
|
836
|
+
revision: current.revision + 1,
|
|
837
|
+
connections: current.connections.map((item) =>
|
|
838
|
+
item.connectionId === connectionId ? pending : item,
|
|
839
|
+
),
|
|
840
|
+
} satisfies UserSettingsViewV1;
|
|
841
|
+
await transaction.put(STATE_KEY, next);
|
|
842
|
+
await transaction.setAlarm(
|
|
843
|
+
Math.max(
|
|
844
|
+
Date.now(),
|
|
845
|
+
nextConnectionAlarm(next) ?? reconciliationRetryAt,
|
|
846
|
+
),
|
|
847
|
+
);
|
|
848
|
+
return { phase: "pending" as const, connection: pending };
|
|
849
|
+
}
|
|
850
|
+
const effectDeadlineAt = Date.now() + CONNECTION_EFFECT_ALARM_MS;
|
|
851
|
+
const assignmentCompensations =
|
|
852
|
+
await deriveRevocationCompensations(connection);
|
|
853
|
+
const claimed = {
|
|
854
|
+
...connection,
|
|
855
|
+
state: "revoking" as const,
|
|
856
|
+
safeMetadata: {
|
|
857
|
+
...connection.safeMetadata,
|
|
858
|
+
reconciliationOperation: "revoke",
|
|
859
|
+
revocationProviderCompleted: false,
|
|
860
|
+
effectDeadlineAt,
|
|
861
|
+
assignmentCompensationPending: assignmentCompensations.length > 0,
|
|
862
|
+
assignmentCompensations,
|
|
863
|
+
...(assignmentCompensations.length > 0
|
|
864
|
+
? { compensationRetryAt: effectDeadlineAt }
|
|
865
|
+
: {}),
|
|
866
|
+
},
|
|
867
|
+
failure: undefined,
|
|
868
|
+
};
|
|
869
|
+
const next = {
|
|
870
|
+
...current,
|
|
871
|
+
revision: current.revision + 1,
|
|
872
|
+
connections: current.connections.map((item) =>
|
|
873
|
+
item.connectionId === connectionId ? claimed : item,
|
|
874
|
+
),
|
|
875
|
+
} satisfies UserSettingsViewV1;
|
|
876
|
+
await transaction.put(STATE_KEY, next);
|
|
877
|
+
await transaction.setAlarm(nextConnectionAlarm(next) ?? effectDeadlineAt);
|
|
878
|
+
return { phase: "provider" as const, connection: claimed };
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
async recordRevocationProviderCompleted(
|
|
883
|
+
userId: string,
|
|
884
|
+
connectionId: string,
|
|
885
|
+
): Promise<boolean> {
|
|
886
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
887
|
+
if (
|
|
888
|
+
connection.state !== "revoking" &&
|
|
889
|
+
!(
|
|
890
|
+
connection.state === "reconciliation-required" &&
|
|
891
|
+
(connection.safeMetadata.reconciliationOperation === "revoke" ||
|
|
892
|
+
(connection.safeMetadata.reconciliationOperation === "link" &&
|
|
893
|
+
connection.safeMetadata.revocationRequested === true))
|
|
894
|
+
)
|
|
895
|
+
) {
|
|
896
|
+
return undefined;
|
|
897
|
+
}
|
|
898
|
+
return {
|
|
899
|
+
...connection,
|
|
900
|
+
state: "revoking",
|
|
901
|
+
safeMetadata: {
|
|
902
|
+
...connection.safeMetadata,
|
|
903
|
+
reconciliationOperation: "revoke",
|
|
904
|
+
revocationProviderCompleted: true,
|
|
905
|
+
},
|
|
906
|
+
failure: undefined,
|
|
907
|
+
};
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
async finishConnectionRevocation(
|
|
912
|
+
userId: string,
|
|
913
|
+
connectionId: string,
|
|
914
|
+
): Promise<boolean> {
|
|
915
|
+
return this.transitionConnection(userId, connectionId, (connection) => {
|
|
916
|
+
if (
|
|
917
|
+
connection.safeMetadata.revocationProviderCompleted !== true ||
|
|
918
|
+
(Array.isArray(connection.safeMetadata.assignmentCompensations) &&
|
|
919
|
+
connection.safeMetadata.assignmentCompensations.length > 0) ||
|
|
920
|
+
(connection.state !== "revoking" &&
|
|
921
|
+
!(
|
|
922
|
+
connection.state === "reconciliation-required" &&
|
|
923
|
+
connection.safeMetadata.reconciliationOperation === "revoke"
|
|
924
|
+
))
|
|
925
|
+
) {
|
|
926
|
+
return undefined;
|
|
927
|
+
}
|
|
928
|
+
return { ...connection, state: "revoked", failure: undefined };
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
async alarm(): Promise<void> {
|
|
933
|
+
const userId = await this.ctx.storage.get<string>(IDENTITY_KEY);
|
|
934
|
+
if (!userId) return;
|
|
935
|
+
const pending = await this.ctx.storage.transaction(async (transaction) => {
|
|
936
|
+
const current =
|
|
937
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
938
|
+
initialState();
|
|
939
|
+
const now = Date.now();
|
|
940
|
+
let changed = false;
|
|
941
|
+
const pending: Array<{
|
|
942
|
+
connectionId: string;
|
|
943
|
+
botId: string;
|
|
944
|
+
compensationId: string;
|
|
945
|
+
expectedGeneration: string;
|
|
946
|
+
}> = [];
|
|
947
|
+
const reconciliations: Array<{
|
|
948
|
+
connection: UserSettingsViewV1["connections"][number];
|
|
949
|
+
operation: "link" | "revoke";
|
|
950
|
+
}> = [];
|
|
951
|
+
const connections = current.connections.map((connection) => {
|
|
952
|
+
let next = connection;
|
|
953
|
+
if (connectionAuthorizationExpired(next, now)) {
|
|
954
|
+
const providerAlias = next.safeMetadata.providerAlias;
|
|
955
|
+
const toolkitSlug = next.safeMetadata.toolkitSlug;
|
|
956
|
+
if (
|
|
957
|
+
typeof providerAlias === "string" &&
|
|
958
|
+
typeof toolkitSlug === "string"
|
|
959
|
+
) {
|
|
960
|
+
const { effectDeadlineAt: _, ...safeMetadata } = next.safeMetadata;
|
|
961
|
+
next = {
|
|
962
|
+
...next,
|
|
963
|
+
state: "reconciliation-required",
|
|
964
|
+
safeMetadata: {
|
|
965
|
+
...safeMetadata,
|
|
966
|
+
reconciliationOperation: "link",
|
|
967
|
+
reconciliationRetryAt: now,
|
|
968
|
+
},
|
|
969
|
+
failure: "Expired authorization requires provider reconciliation",
|
|
970
|
+
};
|
|
971
|
+
} else {
|
|
972
|
+
const {
|
|
973
|
+
effectDeadlineAt: _,
|
|
974
|
+
reconciliationRetryAt: __,
|
|
975
|
+
...safeMetadata
|
|
976
|
+
} = next.safeMetadata;
|
|
977
|
+
next = {
|
|
978
|
+
...next,
|
|
979
|
+
state: "failed",
|
|
980
|
+
safeMetadata: {
|
|
981
|
+
...safeMetadata,
|
|
982
|
+
authorizationStateConsumed: true,
|
|
983
|
+
},
|
|
984
|
+
failure: "Connection authorization expired",
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
changed = true;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const effectExpired =
|
|
991
|
+
((next.state === "authorizing" &&
|
|
992
|
+
typeof next.safeMetadata.connectedAccountId !== "string") ||
|
|
993
|
+
next.state === "revoking") &&
|
|
994
|
+
typeof next.safeMetadata.effectDeadlineAt === "number" &&
|
|
995
|
+
next.safeMetadata.effectDeadlineAt <= now;
|
|
996
|
+
if (effectExpired) {
|
|
997
|
+
const { effectDeadlineAt: _, ...safeMetadata } = next.safeMetadata;
|
|
998
|
+
const operation = next.state === "authorizing" ? "link" : "revoke";
|
|
999
|
+
next = {
|
|
1000
|
+
...next,
|
|
1001
|
+
state: "reconciliation-required",
|
|
1002
|
+
safeMetadata: {
|
|
1003
|
+
...safeMetadata,
|
|
1004
|
+
reconciliationOperation: operation,
|
|
1005
|
+
reconciliationRetryAt: now,
|
|
1006
|
+
},
|
|
1007
|
+
failure: `${operation === "link" ? "Connect Link" : "Revocation"} outcome requires reconciliation`,
|
|
1008
|
+
};
|
|
1009
|
+
changed = true;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const reconciliationOperation =
|
|
1013
|
+
next.safeMetadata.reconciliationOperation;
|
|
1014
|
+
if (
|
|
1015
|
+
next.state === "reconciliation-required" &&
|
|
1016
|
+
(reconciliationOperation === "link" ||
|
|
1017
|
+
reconciliationOperation === "revoke") &&
|
|
1018
|
+
typeof next.safeMetadata.reconciliationRetryAt === "number" &&
|
|
1019
|
+
next.safeMetadata.reconciliationRetryAt <= now
|
|
1020
|
+
) {
|
|
1021
|
+
reconciliations.push({
|
|
1022
|
+
connection: next,
|
|
1023
|
+
operation: reconciliationOperation,
|
|
1024
|
+
});
|
|
1025
|
+
next = {
|
|
1026
|
+
...next,
|
|
1027
|
+
safeMetadata: {
|
|
1028
|
+
...next.safeMetadata,
|
|
1029
|
+
reconciliationRetryAt: now + CONNECTION_EFFECT_ALARM_MS,
|
|
1030
|
+
},
|
|
1031
|
+
};
|
|
1032
|
+
changed = true;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
if (
|
|
1036
|
+
next.safeMetadata.assignmentCompensationPending === true &&
|
|
1037
|
+
typeof next.safeMetadata.compensationRetryAt === "number" &&
|
|
1038
|
+
next.safeMetadata.compensationRetryAt <= now
|
|
1039
|
+
) {
|
|
1040
|
+
const stored = Array.isArray(
|
|
1041
|
+
next.safeMetadata.assignmentCompensations,
|
|
1042
|
+
)
|
|
1043
|
+
? next.safeMetadata.assignmentCompensations
|
|
1044
|
+
: [];
|
|
1045
|
+
for (const candidate of stored) {
|
|
1046
|
+
if (
|
|
1047
|
+
!candidate ||
|
|
1048
|
+
typeof candidate !== "object" ||
|
|
1049
|
+
Array.isArray(candidate)
|
|
1050
|
+
) {
|
|
1051
|
+
continue;
|
|
1052
|
+
}
|
|
1053
|
+
const compensation = candidate as Record<string, unknown>;
|
|
1054
|
+
if (
|
|
1055
|
+
typeof compensation.botId === "string" &&
|
|
1056
|
+
typeof compensation.id === "string" &&
|
|
1057
|
+
typeof compensation.expectedGeneration === "string"
|
|
1058
|
+
) {
|
|
1059
|
+
pending.push({
|
|
1060
|
+
connectionId: next.connectionId,
|
|
1061
|
+
botId: compensation.botId,
|
|
1062
|
+
compensationId: compensation.id,
|
|
1063
|
+
expectedGeneration: compensation.expectedGeneration,
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (stored.length > 0) {
|
|
1068
|
+
next = {
|
|
1069
|
+
...next,
|
|
1070
|
+
safeMetadata: {
|
|
1071
|
+
...next.safeMetadata,
|
|
1072
|
+
compensationRetryAt: now + CONNECTION_EFFECT_ALARM_MS,
|
|
1073
|
+
},
|
|
1074
|
+
};
|
|
1075
|
+
changed = true;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return next;
|
|
1079
|
+
});
|
|
1080
|
+
const next = {
|
|
1081
|
+
...current,
|
|
1082
|
+
revision: changed ? current.revision + 1 : current.revision,
|
|
1083
|
+
connections,
|
|
1084
|
+
} satisfies UserSettingsViewV1;
|
|
1085
|
+
if (changed) await transaction.put(STATE_KEY, next);
|
|
1086
|
+
const alarmAt = nextConnectionAlarm(next);
|
|
1087
|
+
if (alarmAt === undefined) {
|
|
1088
|
+
await transaction.deleteAlarm();
|
|
1089
|
+
} else {
|
|
1090
|
+
await transaction.setAlarm(Math.max(Date.now(), alarmAt));
|
|
1091
|
+
}
|
|
1092
|
+
return { compensations: pending, reconciliations };
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
for (const reconciliation of pending.reconciliations) {
|
|
1096
|
+
try {
|
|
1097
|
+
const connection = reconciliation.connection;
|
|
1098
|
+
if (reconciliation.operation === "link") {
|
|
1099
|
+
const providerAlias = connection.safeMetadata.providerAlias;
|
|
1100
|
+
const toolkitSlug = connection.safeMetadata.toolkitSlug;
|
|
1101
|
+
if (
|
|
1102
|
+
typeof providerAlias !== "string" ||
|
|
1103
|
+
typeof toolkitSlug !== "string"
|
|
1104
|
+
) {
|
|
1105
|
+
continue;
|
|
1106
|
+
}
|
|
1107
|
+
const result = await this.reconcileProviderConnection({
|
|
1108
|
+
operation: "link",
|
|
1109
|
+
userId,
|
|
1110
|
+
providerAlias,
|
|
1111
|
+
toolkitSlug,
|
|
1112
|
+
});
|
|
1113
|
+
const account =
|
|
1114
|
+
result.status === "active"
|
|
1115
|
+
? result.account
|
|
1116
|
+
: result.status === "pending" ||
|
|
1117
|
+
result.status === "failed" ||
|
|
1118
|
+
result.status === "revoked"
|
|
1119
|
+
? result.account
|
|
1120
|
+
: undefined;
|
|
1121
|
+
const safeMetadata = account
|
|
1122
|
+
? {
|
|
1123
|
+
...connection.safeMetadata,
|
|
1124
|
+
connectedAccountId: account.id,
|
|
1125
|
+
toolkitSlug: account.toolkitSlug,
|
|
1126
|
+
...(account.alias ? { providerAlias: account.alias } : {}),
|
|
1127
|
+
}
|
|
1128
|
+
: undefined;
|
|
1129
|
+
if (
|
|
1130
|
+
connection.safeMetadata.revocationRequested === true &&
|
|
1131
|
+
result.status === "absent"
|
|
1132
|
+
) {
|
|
1133
|
+
const completed = await this.recordRevocationProviderCompleted(
|
|
1134
|
+
userId,
|
|
1135
|
+
connection.connectionId,
|
|
1136
|
+
);
|
|
1137
|
+
if (completed) {
|
|
1138
|
+
await this.finishConnectionRevocation(
|
|
1139
|
+
userId,
|
|
1140
|
+
connection.connectionId,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
continue;
|
|
1144
|
+
}
|
|
1145
|
+
if (
|
|
1146
|
+
connection.safeMetadata.revocationRequested === true &&
|
|
1147
|
+
account &&
|
|
1148
|
+
safeMetadata
|
|
1149
|
+
) {
|
|
1150
|
+
const claim = await this.claimConnectionRevocation(
|
|
1151
|
+
userId,
|
|
1152
|
+
connection.connectionId,
|
|
1153
|
+
safeMetadata,
|
|
1154
|
+
);
|
|
1155
|
+
if (result.status !== "revoked") {
|
|
1156
|
+
if (claim.phase !== "provider") continue;
|
|
1157
|
+
try {
|
|
1158
|
+
await this.revokeConnectedAccount(account.id);
|
|
1159
|
+
} catch (error) {
|
|
1160
|
+
await this.requireConnectionReconciliation(
|
|
1161
|
+
userId,
|
|
1162
|
+
connection.connectionId,
|
|
1163
|
+
"revoke",
|
|
1164
|
+
"Revocation outcome requires reconciliation",
|
|
1165
|
+
);
|
|
1166
|
+
throw error;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (claim.phase !== "done") {
|
|
1170
|
+
const completed = await this.recordRevocationProviderCompleted(
|
|
1171
|
+
userId,
|
|
1172
|
+
connection.connectionId,
|
|
1173
|
+
);
|
|
1174
|
+
if (completed) {
|
|
1175
|
+
await this.finishConnectionRevocation(
|
|
1176
|
+
userId,
|
|
1177
|
+
connection.connectionId,
|
|
1178
|
+
);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
continue;
|
|
1182
|
+
}
|
|
1183
|
+
const disposition = linkReconciliationDisposition(result);
|
|
1184
|
+
if (
|
|
1185
|
+
connection.safeMetadata.revocationRequested !== true &&
|
|
1186
|
+
disposition === "failed"
|
|
1187
|
+
) {
|
|
1188
|
+
await this.finishConnectionAuthorization(
|
|
1189
|
+
userId,
|
|
1190
|
+
connection.connectionId,
|
|
1191
|
+
{
|
|
1192
|
+
state: "failed",
|
|
1193
|
+
safeMetadata: {
|
|
1194
|
+
...connection.safeMetadata,
|
|
1195
|
+
authorizationStateConsumed: true,
|
|
1196
|
+
},
|
|
1197
|
+
failure: "Connection authorization could not be recovered",
|
|
1198
|
+
},
|
|
1199
|
+
);
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (disposition === "pending") {
|
|
1203
|
+
if (safeMetadata) {
|
|
1204
|
+
const cleanup = await this.claimLostLinkCleanup(
|
|
1205
|
+
userId,
|
|
1206
|
+
connection.connectionId,
|
|
1207
|
+
safeMetadata,
|
|
1208
|
+
);
|
|
1209
|
+
if (cleanup.phase === "provider") {
|
|
1210
|
+
const connectedAccountId = safeMetadata.connectedAccountId;
|
|
1211
|
+
if (typeof connectedAccountId !== "string") continue;
|
|
1212
|
+
try {
|
|
1213
|
+
await this.revokeConnectedAccount(connectedAccountId);
|
|
1214
|
+
} finally {
|
|
1215
|
+
await this.requireConnectionReconciliation(
|
|
1216
|
+
userId,
|
|
1217
|
+
connection.connectionId,
|
|
1218
|
+
"revoke",
|
|
1219
|
+
"Lost Connect Link cleanup requires provider reconciliation",
|
|
1220
|
+
);
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
if (!safeMetadata) continue;
|
|
1227
|
+
await this.finishConnectionAuthorization(
|
|
1228
|
+
userId,
|
|
1229
|
+
connection.connectionId,
|
|
1230
|
+
{
|
|
1231
|
+
state: "ready",
|
|
1232
|
+
safeMetadata: {
|
|
1233
|
+
...safeMetadata,
|
|
1234
|
+
authorizationStateConsumed: true,
|
|
1235
|
+
},
|
|
1236
|
+
},
|
|
1237
|
+
);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const connectedAccountId = connection.safeMetadata.connectedAccountId;
|
|
1241
|
+
if (typeof connectedAccountId !== "string") continue;
|
|
1242
|
+
const result = await this.reconcileProviderConnection({
|
|
1243
|
+
operation: "revoke",
|
|
1244
|
+
userId,
|
|
1245
|
+
connectedAccountId,
|
|
1246
|
+
});
|
|
1247
|
+
if (result.status === "revoked" || result.status === "absent") {
|
|
1248
|
+
const completed = await this.recordRevocationProviderCompleted(
|
|
1249
|
+
userId,
|
|
1250
|
+
connection.connectionId,
|
|
1251
|
+
);
|
|
1252
|
+
if (completed) {
|
|
1253
|
+
await this.finishConnectionRevocation(
|
|
1254
|
+
userId,
|
|
1255
|
+
connection.connectionId,
|
|
1256
|
+
);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
} catch {
|
|
1260
|
+
// Durable reconciliation state and its alarm deadline remain stored.
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
for (const compensation of pending.compensations) {
|
|
1265
|
+
try {
|
|
1266
|
+
const id = this.env.BOT_STATES.idFromName(
|
|
1267
|
+
`${userId}:${compensation.botId}`,
|
|
1268
|
+
);
|
|
1269
|
+
// SAFETY: BOT_STATES binds BotState, whose public RPC method below is
|
|
1270
|
+
// stable; workers-types cannot infer the generated Durable Object stub.
|
|
1271
|
+
const bot = this.env.BOT_STATES.get(id) as unknown as {
|
|
1272
|
+
markConnectionUnavailable(request: {
|
|
1273
|
+
schemaVersion: 1;
|
|
1274
|
+
userId: string;
|
|
1275
|
+
botId: string;
|
|
1276
|
+
connectionId: string;
|
|
1277
|
+
compensation: { id: string; expectedGeneration: string };
|
|
1278
|
+
}): Promise<"applied" | "stale">;
|
|
1279
|
+
};
|
|
1280
|
+
const result = await bot.markConnectionUnavailable({
|
|
1281
|
+
schemaVersion: 1,
|
|
1282
|
+
userId,
|
|
1283
|
+
botId: compensation.botId,
|
|
1284
|
+
connectionId: compensation.connectionId,
|
|
1285
|
+
compensation: {
|
|
1286
|
+
id: compensation.compensationId,
|
|
1287
|
+
expectedGeneration: compensation.expectedGeneration,
|
|
1288
|
+
},
|
|
1289
|
+
});
|
|
1290
|
+
if (!isSettledBotCompensation(result)) continue;
|
|
1291
|
+
const cleared = await this.recordAssignmentCompensated(
|
|
1292
|
+
userId,
|
|
1293
|
+
compensation.connectionId,
|
|
1294
|
+
compensation.compensationId,
|
|
1295
|
+
);
|
|
1296
|
+
if (cleared) {
|
|
1297
|
+
await this.finishConnectionRevocation(
|
|
1298
|
+
userId,
|
|
1299
|
+
compensation.connectionId,
|
|
1300
|
+
);
|
|
1301
|
+
}
|
|
1302
|
+
} catch {
|
|
1303
|
+
// Durable compensation intent and its retry deadline remain stored.
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
private async transitionConnection(
|
|
1309
|
+
userId: string,
|
|
1310
|
+
connectionId: string,
|
|
1311
|
+
transition: (
|
|
1312
|
+
connection: UserSettingsViewV1["connections"][number],
|
|
1313
|
+
) => UserSettingsViewV1["connections"][number] | undefined,
|
|
1314
|
+
): Promise<boolean> {
|
|
1315
|
+
await this.assertIdentity(userId);
|
|
1316
|
+
return this.ctx.storage.transaction(async (transaction) => {
|
|
1317
|
+
const current =
|
|
1318
|
+
(await transaction.get<UserSettingsViewV1>(STATE_KEY)) ??
|
|
1319
|
+
initialState();
|
|
1320
|
+
const connection = current.connections.find(
|
|
1321
|
+
(item) => item.connectionId === connectionId,
|
|
1322
|
+
);
|
|
1323
|
+
if (!connection) {
|
|
1324
|
+
throw new Error(`Connection "${connectionId}" was not admitted`);
|
|
1325
|
+
}
|
|
1326
|
+
const nextConnection = transition(connection);
|
|
1327
|
+
if (!nextConnection) return false;
|
|
1328
|
+
const next = {
|
|
1329
|
+
...current,
|
|
1330
|
+
revision: current.revision + 1,
|
|
1331
|
+
connections: current.connections.map((item) =>
|
|
1332
|
+
item.connectionId === connectionId ? nextConnection : item,
|
|
1333
|
+
),
|
|
1334
|
+
} satisfies UserSettingsViewV1;
|
|
1335
|
+
await transaction.put(STATE_KEY, next);
|
|
1336
|
+
const alarmAt = nextConnectionAlarm(next);
|
|
1337
|
+
if (alarmAt === undefined) {
|
|
1338
|
+
await transaction.deleteAlarm();
|
|
1339
|
+
} else {
|
|
1340
|
+
await transaction.setAlarm(Math.max(Date.now(), alarmAt));
|
|
1341
|
+
}
|
|
1342
|
+
return true;
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
private async assertIdentity(userId: string): Promise<void> {
|
|
1347
|
+
const existing = await this.ctx.storage.get<string>(IDENTITY_KEY);
|
|
1348
|
+
if (existing && existing !== userId) {
|
|
1349
|
+
throw new Error("User authority does not match durable identity");
|
|
1350
|
+
}
|
|
1351
|
+
if (!existing) await this.ctx.storage.put(IDENTITY_KEY, userId);
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
export function createComposioUserBackendContribution(
|
|
1356
|
+
host: ComposioUserBackendHost,
|
|
1357
|
+
): ComposioUserBackendContribution {
|
|
1358
|
+
return new ComposioUserBackendContribution(host);
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
export function createComposioUserBackendPlugin(
|
|
1362
|
+
host: ComposioUserBackendHost,
|
|
1363
|
+
lifecycle: { mount(value: ComposioUserBackendContribution): () => void },
|
|
1364
|
+
): Plugin {
|
|
1365
|
+
return () => lifecycle.mount(createComposioUserBackendContribution(host));
|
|
1366
|
+
}
|