@context-use/open-sync 0.1.0 → 0.2.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/package.json +3 -2
- package/src/api.ts +2 -0
- package/src/build.ts +20 -4
- package/src/http/providers.ts +13 -0
- package/src/models/provider-catalog.ts +57 -0
- package/src/open-sync.ts +20 -0
- package/src/repositories/catalog/contract.ts +2 -1
- package/src/repositories/catalog/sqlite.ts +16 -1
- package/src/repositories/providers/contract.ts +2 -1
- package/src/repositories/providers/sqlite.ts +22 -6
- package/src/services/management.ts +28 -1
- package/src/services/providers/service.ts +87 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@context-use/open-sync",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": "./src/open-sync.ts",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@cfworker/json-schema": "4.1.1",
|
|
25
25
|
"elysia": "^1.4.30",
|
|
26
|
-
"
|
|
26
|
+
"minisearch": "^7.2.0",
|
|
27
|
+
"@oomol-lab/open-connector": "1.6.3"
|
|
27
28
|
},
|
|
28
29
|
"devDependencies": {
|
|
29
30
|
"typescript": "^7.0.2"
|
package/src/api.ts
CHANGED
|
@@ -10,6 +10,7 @@ export type SyncApi = Pick<
|
|
|
10
10
|
| 'destinations'
|
|
11
11
|
| 'createDestination'
|
|
12
12
|
| 'createInstallation'
|
|
13
|
+
| 'connectInstallation'
|
|
13
14
|
| 'installations'
|
|
14
15
|
| 'installation'
|
|
15
16
|
| 'setEnabled'
|
|
@@ -31,6 +32,7 @@ export function syncApi(service: SyncManagement): SyncApi {
|
|
|
31
32
|
runs: service.runs.bind(service),
|
|
32
33
|
createDestination: service.createDestination.bind(service),
|
|
33
34
|
createInstallation: service.createInstallation.bind(service),
|
|
35
|
+
connectInstallation: service.connectInstallation.bind(service),
|
|
34
36
|
installations: service.installations.bind(service),
|
|
35
37
|
installation: service.installation.bind(service),
|
|
36
38
|
setEnabled: service.setEnabled.bind(service),
|
package/src/build.ts
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getConnectorBuildOptions } from '@oomol-lab/open-connector/build';
|
|
2
|
+
import type { SyncRegistration } from './models/definition';
|
|
2
3
|
|
|
3
|
-
/**
|
|
4
|
-
export function
|
|
5
|
-
return
|
|
4
|
+
/** Provider dependencies declared by registrations, without loading their sync executables. */
|
|
5
|
+
export function providersFromDefinitions(definitions: readonly SyncRegistration[]): string[] {
|
|
6
|
+
return [
|
|
7
|
+
...new Set(
|
|
8
|
+
definitions.flatMap(({ definition }) =>
|
|
9
|
+
definition.provider ? [definition.provider.service] : [],
|
|
10
|
+
),
|
|
11
|
+
),
|
|
12
|
+
].sort();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Prepare selected providers for a Bun executable. Pass plugins/external to Bun.build and assets
|
|
17
|
+
* to compile.assets. Await dispose() after the build (also on failure); it removes staged files.
|
|
18
|
+
* An empty provider list packages no providers. The installed dependency is never modified.
|
|
19
|
+
*/
|
|
20
|
+
export function getOpenSyncBuildOptions(options: { providers: readonly string[] }) {
|
|
21
|
+
return getConnectorBuildOptions(options);
|
|
6
22
|
}
|
package/src/http/providers.ts
CHANGED
|
@@ -28,6 +28,17 @@ export function createProviderController(input: {
|
|
|
28
28
|
async ({ request }) => await input.providers.connections(await scope(request)),
|
|
29
29
|
)
|
|
30
30
|
.get('/', async ({ request }) => await input.providers.catalog(await scope(request)))
|
|
31
|
+
.get(
|
|
32
|
+
'/catalog',
|
|
33
|
+
async ({ request, query }) =>
|
|
34
|
+
input.providers.catalogPage({ ...(await scope(request)), ...query }),
|
|
35
|
+
{
|
|
36
|
+
query: t.Object({
|
|
37
|
+
q: t.Optional(t.String({ maxLength: 200 })),
|
|
38
|
+
offset: t.Optional(t.Integer({ minimum: 0, maximum: 1000000 })),
|
|
39
|
+
}),
|
|
40
|
+
},
|
|
41
|
+
)
|
|
31
42
|
.get(
|
|
32
43
|
'/:service',
|
|
33
44
|
async ({ request, params }) =>
|
|
@@ -50,6 +61,7 @@ export function createProviderController(input: {
|
|
|
50
61
|
{
|
|
51
62
|
params: t.Object({ service }),
|
|
52
63
|
body: t.Object({
|
|
64
|
+
connectionId: t.Optional(id),
|
|
53
65
|
authorizationOptionIds: t.Optional(
|
|
54
66
|
t.Array(t.String({ maxLength: 256 }), { maxItems: 128 }),
|
|
55
67
|
),
|
|
@@ -63,6 +75,7 @@ export function createProviderController(input: {
|
|
|
63
75
|
{
|
|
64
76
|
params: t.Object({ service }),
|
|
65
77
|
body: t.Object({
|
|
78
|
+
connectionId: t.Optional(id),
|
|
66
79
|
authType: t.Union([t.Literal('api_key'), t.Literal('custom_credential')]),
|
|
67
80
|
values,
|
|
68
81
|
}),
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import MiniSearch from 'minisearch';
|
|
2
|
+
import type { ProviderCatalogEntry } from './providers';
|
|
3
|
+
|
|
4
|
+
/** Provider metadata is fixed for the lifetime of a connector runtime. */
|
|
5
|
+
export class ProviderCatalog {
|
|
6
|
+
private index?: MiniSearch;
|
|
7
|
+
private byService?: Map<string, ProviderCatalogEntry>;
|
|
8
|
+
readonly entries: ProviderCatalogEntry[];
|
|
9
|
+
constructor(entries: ProviderCatalogEntry[]) {
|
|
10
|
+
this.entries = entries.map(
|
|
11
|
+
({ service, displayName, iconUrl, categories, scenario, authTypes }) => ({
|
|
12
|
+
service,
|
|
13
|
+
displayName,
|
|
14
|
+
iconUrl,
|
|
15
|
+
categories,
|
|
16
|
+
scenario,
|
|
17
|
+
authTypes,
|
|
18
|
+
}),
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
page(input: { q?: string; offset?: number }) {
|
|
23
|
+
const providers = this.entries;
|
|
24
|
+
const query = input.q?.trim();
|
|
25
|
+
let matches = providers;
|
|
26
|
+
if (query) {
|
|
27
|
+
if (!this.index) {
|
|
28
|
+
this.index = new MiniSearch({
|
|
29
|
+
idField: 'service',
|
|
30
|
+
fields: ['service', 'displayName', 'categoryNames', 'scenario', 'authentication'],
|
|
31
|
+
searchOptions: {
|
|
32
|
+
prefix: true,
|
|
33
|
+
combineWith: 'AND',
|
|
34
|
+
boost: { displayName: 3, service: 2 },
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
this.index.addAll(
|
|
38
|
+
providers.map((provider) => ({
|
|
39
|
+
...provider,
|
|
40
|
+
categoryNames: provider.categories.map((category) => category.displayName).join(' '),
|
|
41
|
+
authentication: provider.authTypes.map((type) => type.replaceAll('_', ' ')).join(' '),
|
|
42
|
+
})),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
this.byService ??= new Map(providers.map((provider) => [provider.service, provider]));
|
|
46
|
+
matches = this.index.search(query).map((result) => this.byService!.get(result.id)!);
|
|
47
|
+
}
|
|
48
|
+
const pageSize = 30;
|
|
49
|
+
const offset = input.offset ?? 0;
|
|
50
|
+
return {
|
|
51
|
+
providers: matches.slice(offset, offset + pageSize),
|
|
52
|
+
total: matches.length,
|
|
53
|
+
pageSize,
|
|
54
|
+
hasMore: offset + pageSize < matches.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
package/src/open-sync.ts
CHANGED
|
@@ -31,6 +31,10 @@ export interface OpenSyncOptions {
|
|
|
31
31
|
authorize(request: Request): Scope | null | Promise<Scope | null>;
|
|
32
32
|
/** OAuth application settings are instance-wide, so require the host's administrator policy. */
|
|
33
33
|
canConfigureProviders(scope: Scope): Promise<boolean>;
|
|
34
|
+
/** Called after an owned connection is saved, including a retried OAuth completion. */
|
|
35
|
+
onProviderConnected?(
|
|
36
|
+
input: Scope & { connection: { id: string; service: string } },
|
|
37
|
+
): Promise<void>;
|
|
34
38
|
/** Final host UI location after Open Sync completes authorization. */
|
|
35
39
|
authorizationRedirect?(input: { service: string; outcome: 'connected' | 'failed' }): string;
|
|
36
40
|
}
|
|
@@ -75,6 +79,21 @@ export async function createOpenSync(options: OpenSyncOptions): Promise<OpenSync
|
|
|
75
79
|
runtimeToken,
|
|
76
80
|
signal: lifetime.signal,
|
|
77
81
|
});
|
|
82
|
+
const requiredProviders = new Set(
|
|
83
|
+
options.definitions.flatMap(({ definition }) =>
|
|
84
|
+
definition.provider ? [definition.provider.service] : [],
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
if (requiredProviders.size) {
|
|
88
|
+
const available = new Set((await management.catalog()).map((entry) => entry.service));
|
|
89
|
+
for (const service of requiredProviders) {
|
|
90
|
+
if (!available.has(service)) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Open Sync provider ${JSON.stringify(service)} is unavailable. Include it in getOpenSyncBuildOptions({ providers }) and rebuild.`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
78
97
|
const client = createConnectorClient({
|
|
79
98
|
fetch: transport,
|
|
80
99
|
baseUrl: publicUrl,
|
|
@@ -111,6 +130,7 @@ export async function createOpenSync(options: OpenSyncOptions): Promise<OpenSync
|
|
|
111
130
|
connector: management,
|
|
112
131
|
signal: lifetime.signal,
|
|
113
132
|
canConfigure: options.canConfigureProviders,
|
|
133
|
+
onConnected: options.onProviderConnected,
|
|
114
134
|
returnUrl: (input) =>
|
|
115
135
|
`${publicUrl}/providers/${encodeURIComponent(input.service)}/return/${input.id}`,
|
|
116
136
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { SyncDefinition } from '../../models/definition';
|
|
1
|
+
import type { ConnectionRef, SyncDefinition } from '../../models/definition';
|
|
2
2
|
import type { Destination } from '../../models/delivery';
|
|
3
3
|
import type { Resource, Scope } from '../../models/identity';
|
|
4
4
|
import type { CreateInstallation, Installation, SyncRun } from '../../models/installation';
|
|
@@ -18,6 +18,7 @@ export interface CatalogRepository {
|
|
|
18
18
|
hasMore: boolean;
|
|
19
19
|
pageSize: number;
|
|
20
20
|
};
|
|
21
|
+
connectInstallation(input: Resource & { connection: ConnectionRef }): Installation;
|
|
21
22
|
setEnabled(input: Resource & { enabled: boolean }): Installation;
|
|
22
23
|
queue(input: Resource & { checkpoint?: JsonValue }): void;
|
|
23
24
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Database } from 'bun:sqlite';
|
|
2
|
-
import type { SyncDefinition } from '../../models/definition';
|
|
2
|
+
import type { ConnectionRef, SyncDefinition } from '../../models/definition';
|
|
3
3
|
import { fail } from '../../models/error';
|
|
4
4
|
import type { Resource, Scope } from '../../models/identity';
|
|
5
5
|
import type { CreateInstallation } from '../../models/installation';
|
|
@@ -113,6 +113,21 @@ export class SqliteCatalog implements CatalogRepository {
|
|
|
113
113
|
pageSize: limit,
|
|
114
114
|
};
|
|
115
115
|
}
|
|
116
|
+
connectInstallation(input: Resource & { connection: ConnectionRef }) {
|
|
117
|
+
return this.db
|
|
118
|
+
.transaction(() => {
|
|
119
|
+
const installation = this.installation(input);
|
|
120
|
+
if (installation.connection || installation.enabled) {
|
|
121
|
+
fail('already_connected');
|
|
122
|
+
}
|
|
123
|
+
this.db
|
|
124
|
+
.query(`UPDATE installations SET connection=?,enabled=1,
|
|
125
|
+
binding_epoch=binding_epoch+1,status='ready',next_due_at=? WHERE owner_id=? AND id=?`)
|
|
126
|
+
.run(canonicalJson(input.connection).json, Date.now(), input.ownerId, input.id);
|
|
127
|
+
return this.installation(input);
|
|
128
|
+
})
|
|
129
|
+
.immediate();
|
|
130
|
+
}
|
|
116
131
|
setEnabled(input: Resource & { enabled: boolean }) {
|
|
117
132
|
return this.db
|
|
118
133
|
.transaction(() => {
|
|
@@ -10,6 +10,7 @@ export interface ProviderRepository {
|
|
|
10
10
|
owns(input: ProviderScope & { connectorId: string }): Promise<boolean>;
|
|
11
11
|
start(input: ProviderScope & ProviderAuthorization): Promise<void>;
|
|
12
12
|
pending(input: ProviderScope & { id: string }): Promise<ProviderAuthorization | null>;
|
|
13
|
+
updateAccount(input: ProviderScope & ProviderConnection): Promise<void>;
|
|
13
14
|
add(input: ProviderScope & ProviderConnection): Promise<void>;
|
|
14
|
-
complete(input: ProviderScope & ProviderConnection): Promise<void>;
|
|
15
|
+
complete(input: ProviderScope & ProviderConnection & { requestId: string }): Promise<void>;
|
|
15
16
|
}
|
|
@@ -52,6 +52,14 @@ export class SqliteProviders implements ProviderRepository {
|
|
|
52
52
|
.get(input.ownerId, input.id),
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
|
+
updateAccount(input: ProviderScope & ProviderConnection) {
|
|
56
|
+
this.db
|
|
57
|
+
.query(
|
|
58
|
+
'UPDATE provider_connections SET account=? WHERE owner_id=? AND id=? AND connector_id=?',
|
|
59
|
+
)
|
|
60
|
+
.run(input.account, input.ownerId, input.id, input.connectorId);
|
|
61
|
+
return Promise.resolve();
|
|
62
|
+
}
|
|
55
63
|
add(input: ProviderScope & ProviderConnection) {
|
|
56
64
|
this.db
|
|
57
65
|
.query(
|
|
@@ -60,18 +68,26 @@ export class SqliteProviders implements ProviderRepository {
|
|
|
60
68
|
.run(input.ownerId, input.id, input.connectorId, input.account, input.service);
|
|
61
69
|
return Promise.resolve();
|
|
62
70
|
}
|
|
63
|
-
complete(input: ProviderScope & ProviderConnection) {
|
|
71
|
+
complete(input: ProviderScope & ProviderConnection & { requestId: string }) {
|
|
64
72
|
this.db
|
|
65
73
|
.transaction(() => {
|
|
66
74
|
// A superseded authorization must never claim a connection.
|
|
67
75
|
this.db
|
|
68
76
|
.query(`INSERT INTO provider_connections(owner_id,id,connector_id,account,service)
|
|
69
|
-
SELECT owner_id,id,?,?,? FROM provider_authorizations WHERE owner_id=? AND id=?
|
|
70
|
-
ON CONFLICT(owner_id,id) DO
|
|
71
|
-
|
|
77
|
+
SELECT owner_id,id,?,?,? FROM provider_authorizations WHERE owner_id=? AND id=? AND request_id=?
|
|
78
|
+
ON CONFLICT(owner_id,id) DO UPDATE SET account=excluded.account
|
|
79
|
+
WHERE provider_connections.connector_id=excluded.connector_id`)
|
|
80
|
+
.run(
|
|
81
|
+
input.connectorId,
|
|
82
|
+
input.account,
|
|
83
|
+
input.service,
|
|
84
|
+
input.ownerId,
|
|
85
|
+
input.id,
|
|
86
|
+
input.requestId,
|
|
87
|
+
);
|
|
72
88
|
this.db
|
|
73
|
-
.query('DELETE FROM provider_authorizations WHERE owner_id=? AND id=?')
|
|
74
|
-
.run(input.ownerId, input.id);
|
|
89
|
+
.query('DELETE FROM provider_authorizations WHERE owner_id=? AND id=? AND request_id=?')
|
|
90
|
+
.run(input.ownerId, input.id, input.requestId);
|
|
75
91
|
})
|
|
76
92
|
.immediate();
|
|
77
93
|
return Promise.resolve();
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { bindProvider, type ProviderGateway } from '../execution/provider';
|
|
2
2
|
import type { WorkerControl } from '../execution/worker';
|
|
3
|
+
import type { ConnectionRef } from '../models/definition';
|
|
3
4
|
import { fail } from '../models/error';
|
|
4
5
|
import type { Resource, Scope } from '../models/identity';
|
|
5
6
|
import type { CreateInstallation } from '../models/installation';
|
|
@@ -65,7 +66,7 @@ export class SyncManagement {
|
|
|
65
66
|
if (input.intervalMs !== undefined) {
|
|
66
67
|
positive(input.intervalMs);
|
|
67
68
|
}
|
|
68
|
-
if (definition.provider) {
|
|
69
|
+
if (definition.provider && (input.connection || input.enabled !== false)) {
|
|
69
70
|
await bindProvider({
|
|
70
71
|
actorId: input.actorId,
|
|
71
72
|
ownerId: input.ownerId,
|
|
@@ -92,8 +93,34 @@ export class SyncManagement {
|
|
|
92
93
|
this.guard(input);
|
|
93
94
|
return this.input.catalog.installation(input);
|
|
94
95
|
}
|
|
96
|
+
async connectInstallation(input: Resource & { connection: ConnectionRef }) {
|
|
97
|
+
this.guard(input);
|
|
98
|
+
const installation = this.input.catalog.installation(input);
|
|
99
|
+
if (installation.connection || installation.enabled) {
|
|
100
|
+
fail('already_connected');
|
|
101
|
+
}
|
|
102
|
+
const { definition } = this.input.registry.definition(installation.definition);
|
|
103
|
+
if (!definition.provider) {
|
|
104
|
+
fail('unexpected_connection');
|
|
105
|
+
}
|
|
106
|
+
await bindProvider({
|
|
107
|
+
...input,
|
|
108
|
+
requirements: definition.provider,
|
|
109
|
+
gateway: this.input.gateway,
|
|
110
|
+
signal: AbortSignal.timeout(this.input.timeoutMs),
|
|
111
|
+
});
|
|
112
|
+
this.guard(input);
|
|
113
|
+
return this.input.catalog.connectInstallation(input);
|
|
114
|
+
}
|
|
95
115
|
async setEnabled(input: Resource & { enabled: boolean }) {
|
|
96
116
|
this.guard(input);
|
|
117
|
+
const installation = this.input.catalog.installation(input);
|
|
118
|
+
if (input.enabled && !installation.connection) {
|
|
119
|
+
const { definition } = this.input.registry.definition(installation.definition);
|
|
120
|
+
if (definition.provider) {
|
|
121
|
+
fail('connection_required');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
97
124
|
const result = this.input.catalog.setEnabled(input);
|
|
98
125
|
await this.input.worker.cancel(input);
|
|
99
126
|
return result;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { fail, SyncError } from '../../models/error';
|
|
2
|
+
import { ProviderCatalog } from '../../models/provider-catalog';
|
|
2
3
|
import { oauthClientValues, publicConnection } from '../../models/provider-values';
|
|
3
4
|
import type {
|
|
4
5
|
ConnectorManagement,
|
|
@@ -9,6 +10,7 @@ import { identifier } from '../../models/validation';
|
|
|
9
10
|
import type { ProviderRepository } from '../../repositories/providers/contract';
|
|
10
11
|
|
|
11
12
|
export class ProviderService {
|
|
13
|
+
private providerCatalog?: Promise<ProviderCatalog>;
|
|
12
14
|
constructor(
|
|
13
15
|
private readonly input: {
|
|
14
16
|
repository: ProviderRepository;
|
|
@@ -16,9 +18,23 @@ export class ProviderService {
|
|
|
16
18
|
returnUrl(input: { service: string; id: string }): string;
|
|
17
19
|
canConfigure(scope: ProviderScope): Promise<boolean>;
|
|
18
20
|
signal: AbortSignal;
|
|
21
|
+
onConnected?(
|
|
22
|
+
input: ProviderScope & { connection: { id: string; service: string } },
|
|
23
|
+
): Promise<void>;
|
|
19
24
|
},
|
|
20
25
|
) {}
|
|
21
26
|
|
|
27
|
+
private loadCatalog() {
|
|
28
|
+
this.providerCatalog ??= this.input.connector
|
|
29
|
+
.catalog()
|
|
30
|
+
.then((entries) => new ProviderCatalog(entries))
|
|
31
|
+
.catch((error) => {
|
|
32
|
+
this.providerCatalog = undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
});
|
|
35
|
+
return this.providerCatalog;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
private guard(scope: ProviderScope) {
|
|
23
39
|
this.input.signal.throwIfAborted();
|
|
24
40
|
identifier(scope.ownerId);
|
|
@@ -42,15 +58,11 @@ export class ProviderService {
|
|
|
42
58
|
}
|
|
43
59
|
async catalog(scope: ProviderScope) {
|
|
44
60
|
this.guard(scope);
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
authTypes,
|
|
51
|
-
categories,
|
|
52
|
-
scenario,
|
|
53
|
-
}));
|
|
61
|
+
return (await this.loadCatalog()).entries;
|
|
62
|
+
}
|
|
63
|
+
async catalogPage(input: ProviderScope & { q?: string; offset?: number }) {
|
|
64
|
+
this.guard(input);
|
|
65
|
+
return (await this.loadCatalog()).page(input);
|
|
54
66
|
}
|
|
55
67
|
private async setup(service: string): Promise<RuntimeProviderSetup> {
|
|
56
68
|
const setup = await this.input.connector.call({
|
|
@@ -82,7 +94,17 @@ export class ProviderService {
|
|
|
82
94
|
(metadata.status === 'active' || metadata.status === 'reauth_required')
|
|
83
95
|
? metadata.status
|
|
84
96
|
: 'unknown';
|
|
85
|
-
|
|
97
|
+
const authType = metadata?.authType;
|
|
98
|
+
const supportedAuth: RuntimeProviderSetup['auth'][number]['type'] | undefined =
|
|
99
|
+
authType === 'oauth2' || authType === 'api_key' || authType === 'custom_credential'
|
|
100
|
+
? authType
|
|
101
|
+
: undefined;
|
|
102
|
+
return {
|
|
103
|
+
id,
|
|
104
|
+
account,
|
|
105
|
+
status,
|
|
106
|
+
authType: supportedAuth,
|
|
107
|
+
};
|
|
86
108
|
}),
|
|
87
109
|
),
|
|
88
110
|
};
|
|
@@ -107,11 +129,30 @@ export class ProviderService {
|
|
|
107
129
|
});
|
|
108
130
|
return { configured: true };
|
|
109
131
|
}
|
|
110
|
-
async
|
|
132
|
+
private async reconnectTarget(input: ProviderScope & { service: string; connectionId?: string }) {
|
|
133
|
+
if (!input.connectionId) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const target = await this.input.repository.connection({ ...input, id: input.connectionId });
|
|
137
|
+
if (!target || target.service !== input.service) {
|
|
138
|
+
fail('not_found');
|
|
139
|
+
}
|
|
140
|
+
return target;
|
|
141
|
+
}
|
|
142
|
+
async start(
|
|
143
|
+
input: ProviderScope & {
|
|
144
|
+
service: string;
|
|
145
|
+
authorizationOptionIds?: string[];
|
|
146
|
+
connectionId?: string;
|
|
147
|
+
},
|
|
148
|
+
) {
|
|
111
149
|
this.guard(input);
|
|
112
|
-
const
|
|
150
|
+
const target = await this.reconnectTarget(input);
|
|
151
|
+
const id = target?.id ?? `connection_${crypto.randomUUID()}`;
|
|
113
152
|
const result = await this.input.connector.call({
|
|
114
|
-
path:
|
|
153
|
+
path: target
|
|
154
|
+
? `/v1/connections/by-id/${encodeURIComponent(target.connectorId)}/connect`
|
|
155
|
+
: `/v1/connections/${encodeURIComponent(input.service)}/connect`,
|
|
115
156
|
method: 'POST',
|
|
116
157
|
body: {
|
|
117
158
|
returnUri: this.input.returnUrl({ service: input.service, id }),
|
|
@@ -135,33 +176,51 @@ export class ProviderService {
|
|
|
135
176
|
input: ProviderScope & {
|
|
136
177
|
service: string;
|
|
137
178
|
authType: 'api_key' | 'custom_credential';
|
|
179
|
+
connectionId?: string;
|
|
138
180
|
values: Record<string, string>;
|
|
139
181
|
},
|
|
140
182
|
) {
|
|
141
183
|
this.guard(input);
|
|
184
|
+
const target = await this.reconnectTarget(input);
|
|
185
|
+
const path = target
|
|
186
|
+
? `/v1/connections/by-id/${encodeURIComponent(target.connectorId)}/connect`
|
|
187
|
+
: `/v1/connections/${encodeURIComponent(input.service)}/connect`;
|
|
142
188
|
const { apiKey, ...extra } = input.values;
|
|
143
189
|
const result = await this.input.connector.call({
|
|
144
|
-
path:
|
|
190
|
+
path: `${path}/${input.authType === 'api_key' ? 'api-key' : 'custom-credential'}`,
|
|
145
191
|
method: 'POST',
|
|
146
192
|
body: input.authType === 'api_key' ? { apiKey, extra } : { values: input.values },
|
|
147
193
|
});
|
|
148
194
|
const connection = publicConnection({ metadata: result, service: input.service });
|
|
149
195
|
this.input.signal.throwIfAborted();
|
|
150
|
-
|
|
196
|
+
const id = target?.id ?? `connection_${crypto.randomUUID()}`;
|
|
197
|
+
const owned = { actorId: input.actorId, ownerId: input.ownerId, id, ...connection };
|
|
198
|
+
if (target) {
|
|
199
|
+
if (connection.connectorId !== target.connectorId) {
|
|
200
|
+
fail('connection_unavailable');
|
|
201
|
+
}
|
|
202
|
+
await this.input.repository.updateAccount(owned);
|
|
203
|
+
} else {
|
|
204
|
+
await this.input.repository.add(owned);
|
|
205
|
+
}
|
|
206
|
+
await this.input.onConnected?.({
|
|
151
207
|
actorId: input.actorId,
|
|
152
208
|
ownerId: input.ownerId,
|
|
153
|
-
id:
|
|
154
|
-
...connection,
|
|
209
|
+
connection: { id, service: input.service },
|
|
155
210
|
});
|
|
156
211
|
return { connected: true };
|
|
157
212
|
}
|
|
158
213
|
async complete(input: ProviderScope & { service: string; id: string }): Promise<void> {
|
|
159
214
|
this.guard(input);
|
|
160
215
|
const existing = await this.input.repository.connection(input);
|
|
161
|
-
|
|
216
|
+
const pending = await this.input.repository.pending(input);
|
|
217
|
+
if (!pending && existing?.service === input.service) {
|
|
218
|
+
await this.input.onConnected?.({
|
|
219
|
+
...input,
|
|
220
|
+
connection: { id: input.id, service: input.service },
|
|
221
|
+
});
|
|
162
222
|
return;
|
|
163
223
|
}
|
|
164
|
-
const pending = await this.input.repository.pending(input);
|
|
165
224
|
if (!pending || pending.service !== input.service) {
|
|
166
225
|
fail('not_found');
|
|
167
226
|
}
|
|
@@ -178,13 +237,20 @@ export class ProviderService {
|
|
|
178
237
|
path: `/v1/connections/by-id/${encodeURIComponent(result.appId)}`,
|
|
179
238
|
});
|
|
180
239
|
const connection = publicConnection({ metadata, service: input.service });
|
|
181
|
-
if (
|
|
240
|
+
if (
|
|
241
|
+
connection.connectorId !== result.appId ||
|
|
242
|
+
(existing && existing.connectorId !== connection.connectorId)
|
|
243
|
+
) {
|
|
182
244
|
throw new SyncError({ code: 'provider_request_failed', message: 'Invalid connection.' });
|
|
183
245
|
}
|
|
184
246
|
this.input.signal.throwIfAborted();
|
|
185
|
-
await this.input.repository.complete({ ...input, ...connection });
|
|
247
|
+
await this.input.repository.complete({ ...input, ...connection, requestId: pending.requestId });
|
|
186
248
|
if (!(await this.input.repository.connection(input))) {
|
|
187
249
|
fail('not_found');
|
|
188
250
|
}
|
|
251
|
+
await this.input.onConnected?.({
|
|
252
|
+
...input,
|
|
253
|
+
connection: { id: input.id, service: input.service },
|
|
254
|
+
});
|
|
189
255
|
}
|
|
190
256
|
}
|