@context-use/open-sync 0.2.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 +3 -21
- package/src/http/providers.ts +13 -0
- package/src/models/provider-catalog.ts +57 -0
- package/src/open-sync.ts +5 -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/src/build/connector.ts +0 -143
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@context-use/open-sync",
|
|
3
|
-
"version": "0.2.
|
|
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,8 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { tmpdir } from 'node:os';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { prepareConnector } from './build/connector';
|
|
1
|
+
import { getConnectorBuildOptions } from '@oomol-lab/open-connector/build';
|
|
6
2
|
import type { SyncRegistration } from './models/definition';
|
|
7
3
|
|
|
8
4
|
/** Provider dependencies declared by registrations, without loading their sync executables. */
|
|
@@ -21,20 +17,6 @@ export function providersFromDefinitions(definitions: readonly SyncRegistration[
|
|
|
21
17
|
* to compile.assets. Await dispose() after the build (also on failure); it removes staged files.
|
|
22
18
|
* An empty provider list packages no providers. The installed dependency is never modified.
|
|
23
19
|
*/
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
const dispose = () => rm(directory, { recursive: true, force: true });
|
|
27
|
-
try {
|
|
28
|
-
const entrypoint = fileURLToPath(import.meta.resolve('@oomol-lab/open-connector'));
|
|
29
|
-
const assets = join(directory, 'open-connector');
|
|
30
|
-
const plugin = await prepareConnector({
|
|
31
|
-
root: dirname(dirname(dirname(entrypoint))),
|
|
32
|
-
providers: [...new Set(options.providers)].sort(),
|
|
33
|
-
assets,
|
|
34
|
-
});
|
|
35
|
-
return { assets: [assets], external: ['proxy-agent'], plugins: [plugin], dispose };
|
|
36
|
-
} catch (error) {
|
|
37
|
-
await dispose();
|
|
38
|
-
throw error;
|
|
39
|
-
}
|
|
20
|
+
export function getOpenSyncBuildOptions(options: { providers: readonly string[] }) {
|
|
21
|
+
return getConnectorBuildOptions(options);
|
|
40
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
|
}
|
|
@@ -126,6 +130,7 @@ export async function createOpenSync(options: OpenSyncOptions): Promise<OpenSync
|
|
|
126
130
|
connector: management,
|
|
127
131
|
signal: lifetime.signal,
|
|
128
132
|
canConfigure: options.canConfigureProviders,
|
|
133
|
+
onConnected: options.onProviderConnected,
|
|
129
134
|
returnUrl: (input) =>
|
|
130
135
|
`${publicUrl}/providers/${encodeURIComponent(input.service)}/return/${input.id}`,
|
|
131
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
|
}
|
package/src/build/connector.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import { access, cp, mkdir, readFile, realpath, writeFile } from 'node:fs/promises';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import type { BunPlugin } from 'bun';
|
|
4
|
-
|
|
5
|
-
// This adapter deliberately supports one published layout. Review it when upgrading Connector.
|
|
6
|
-
const supportedVersion = '1.6.0';
|
|
7
|
-
const indexVersion = 1;
|
|
8
|
-
interface CatalogEntry {
|
|
9
|
-
file: string;
|
|
10
|
-
bytes: number;
|
|
11
|
-
provider: { service: string };
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function unsupported(detail: string): never {
|
|
15
|
-
throw new Error(
|
|
16
|
-
`Unsupported Open Connector package: ${detail}. Review the Open Sync build adapter.`,
|
|
17
|
-
);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Parse only the pinned generator's format; never evaluate dependency code during preparation. */
|
|
21
|
-
function executorRegistry(source: string): Map<string, string> {
|
|
22
|
-
const lines = source.trim().split('\n');
|
|
23
|
-
if (
|
|
24
|
-
lines.shift() !== '/** Generated lazy imports for provider executors. Do not hand-edit. */' ||
|
|
25
|
-
lines.shift() !== 'export const executorModules = {' ||
|
|
26
|
-
lines.pop() !== '};'
|
|
27
|
-
) {
|
|
28
|
-
unsupported('executor registry format changed');
|
|
29
|
-
}
|
|
30
|
-
const modules = new Map<string, string>();
|
|
31
|
-
for (const line of lines) {
|
|
32
|
-
const match =
|
|
33
|
-
/^ {4}(?:"([\w-]+)"|([\w]+)): \(\) => import\("(\.\/[\w-]+\/executors\.js)"\),$/.exec(line);
|
|
34
|
-
const service = match?.[1] ?? match?.[2];
|
|
35
|
-
const path = match?.[3];
|
|
36
|
-
if (!service || path !== `./${service}/executors.js` || modules.has(service)) {
|
|
37
|
-
unsupported('executor registry entry changed');
|
|
38
|
-
}
|
|
39
|
-
modules.set(service, path);
|
|
40
|
-
}
|
|
41
|
-
return modules;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function catalogEntries(value: unknown): CatalogEntry[] {
|
|
45
|
-
const index = value as { version?: unknown; providers?: CatalogEntry[] } | null;
|
|
46
|
-
if (index?.version !== indexVersion || !Array.isArray(index.providers)) {
|
|
47
|
-
unsupported('catalog index format changed');
|
|
48
|
-
}
|
|
49
|
-
const services = new Set<string>();
|
|
50
|
-
for (const entry of index.providers) {
|
|
51
|
-
const service = entry?.provider?.service;
|
|
52
|
-
if (
|
|
53
|
-
typeof service !== 'string' ||
|
|
54
|
-
!/^[\w-]+$/.test(service) ||
|
|
55
|
-
entry.file !== `${service}.json` ||
|
|
56
|
-
!Number.isInteger(entry.bytes) ||
|
|
57
|
-
entry.bytes < 0 ||
|
|
58
|
-
services.has(service)
|
|
59
|
-
) {
|
|
60
|
-
unsupported('catalog index entry changed');
|
|
61
|
-
}
|
|
62
|
-
services.add(service);
|
|
63
|
-
}
|
|
64
|
-
return index.providers;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** All knowledge of Connector's private package layout stays behind this build adapter. */
|
|
68
|
-
export async function prepareConnector(input: {
|
|
69
|
-
root: string;
|
|
70
|
-
providers: readonly string[];
|
|
71
|
-
assets: string;
|
|
72
|
-
}): Promise<BunPlugin> {
|
|
73
|
-
const root = await realpath(input.root);
|
|
74
|
-
const metadata = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'));
|
|
75
|
-
if (metadata.name !== '@oomol-lab/open-connector' || metadata.version !== supportedVersion) {
|
|
76
|
-
unsupported(
|
|
77
|
-
`expected @oomol-lab/open-connector ${supportedVersion}, found ${metadata.version}`,
|
|
78
|
-
);
|
|
79
|
-
}
|
|
80
|
-
const registry = join(root, 'src/providers/registry.generated.js');
|
|
81
|
-
const modules = executorRegistry(await readFile(registry, 'utf8'));
|
|
82
|
-
const sourceAssets = join(root, 'assets/open-connector');
|
|
83
|
-
const entries = catalogEntries(
|
|
84
|
-
JSON.parse(await readFile(join(sourceAssets, 'catalog/apps-index.json'), 'utf8')),
|
|
85
|
-
);
|
|
86
|
-
const selected = input.providers.map((service) => {
|
|
87
|
-
const entry = entries.find((item) => item.provider.service === service);
|
|
88
|
-
if (!entry || !modules.has(service)) {
|
|
89
|
-
throw new Error(
|
|
90
|
-
`Cannot bundle Open Connector provider ${JSON.stringify(service)}: catalog or executor is missing.`,
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
return entry;
|
|
94
|
-
});
|
|
95
|
-
await mkdir(join(input.assets, 'catalog/apps'), { recursive: true });
|
|
96
|
-
for (const entry of selected) {
|
|
97
|
-
await access(join(root, 'src/providers', modules.get(entry.provider.service)!));
|
|
98
|
-
const content = await readFile(join(sourceAssets, 'catalog/apps', entry.file));
|
|
99
|
-
if (
|
|
100
|
-
content.byteLength !== entry.bytes ||
|
|
101
|
-
JSON.parse(content.toString()).service !== entry.provider.service
|
|
102
|
-
) {
|
|
103
|
-
unsupported(`catalog file does not match its index: ${entry.file}`);
|
|
104
|
-
}
|
|
105
|
-
await writeFile(join(input.assets, 'catalog/apps', entry.file), content);
|
|
106
|
-
}
|
|
107
|
-
if (!selected.length) {
|
|
108
|
-
// Bun embeds files, so retain the directory that Connector enumerates even with zero providers.
|
|
109
|
-
await writeFile(join(input.assets, 'catalog/apps/empty'), '');
|
|
110
|
-
}
|
|
111
|
-
await writeFile(
|
|
112
|
-
join(input.assets, 'catalog/apps-index.json'),
|
|
113
|
-
JSON.stringify({ version: indexVersion, providers: selected }),
|
|
114
|
-
);
|
|
115
|
-
await cp(join(sourceAssets, 'migrations'), join(input.assets, 'migrations'), { recursive: true });
|
|
116
|
-
const contents = `export const executorModules = {\n${selected
|
|
117
|
-
.map(
|
|
118
|
-
({ provider }) =>
|
|
119
|
-
`${JSON.stringify(provider.service)}: () => import(${JSON.stringify(modules.get(provider.service))}),`,
|
|
120
|
-
)
|
|
121
|
-
.join('\n')}\n};`;
|
|
122
|
-
return {
|
|
123
|
-
name: 'open-sync-providers',
|
|
124
|
-
setup(build) {
|
|
125
|
-
let replaced = false;
|
|
126
|
-
build.onStart(() => {
|
|
127
|
-
replaced = false;
|
|
128
|
-
});
|
|
129
|
-
build.onLoad({ filter: /[/\\]providers[/\\]registry\.generated\.js$/ }, ({ path }) => {
|
|
130
|
-
if (path !== registry) {
|
|
131
|
-
return;
|
|
132
|
-
}
|
|
133
|
-
replaced = true;
|
|
134
|
-
return { contents, loader: 'js' };
|
|
135
|
-
});
|
|
136
|
-
build.onEnd((result) => {
|
|
137
|
-
if (result.success && !replaced) {
|
|
138
|
-
unsupported('the build did not load the expected executor registry');
|
|
139
|
-
}
|
|
140
|
-
});
|
|
141
|
-
},
|
|
142
|
-
};
|
|
143
|
-
}
|