@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@context-use/open-sync",
3
- "version": "0.1.0",
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
- "@oomol-lab/open-connector": "1.6.0"
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 { getConnectorAssetDirectory } from '@oomol-lab/open-connector';
1
+ import { getConnectorBuildOptions } from '@oomol-lab/open-connector/build';
2
+ import type { SyncRegistration } from './models/definition';
2
3
 
3
- /** Options needed when a host embeds Open Sync in a Bun executable. */
4
- export function getOpenSyncBuildOptions() {
5
- return { assets: [getConnectorAssetDirectory()], external: ['proxy-agent'] };
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
  }
@@ -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 NOTHING`)
71
- .run(input.connectorId, input.account, input.service, input.ownerId, input.id);
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
- const providers = await this.input.connector.catalog();
46
- return providers.map(({ service, displayName, iconUrl, authTypes, categories, scenario }) => ({
47
- service,
48
- displayName,
49
- iconUrl,
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
- return { id, account, status };
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 start(input: ProviderScope & { service: string; authorizationOptionIds?: string[] }) {
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 id = `connection_${crypto.randomUUID()}`;
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: `/v1/connections/${encodeURIComponent(input.service)}/connect`,
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: `/v1/connections/${encodeURIComponent(input.service)}/connect/${input.authType === 'api_key' ? 'api-key' : 'custom-credential'}`,
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
- await this.input.repository.add({
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: `connection_${crypto.randomUUID()}`,
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
- if (existing?.service === input.service) {
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 (connection.connectorId !== result.appId) {
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
  }