@checkstack/integration-backend 0.0.2

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.
@@ -0,0 +1,463 @@
1
+ /**
2
+ * Generic connection store for integration providers.
3
+ * Each connection is stored individually using ConfigService with the provider's
4
+ * actual connectionSchema, which enables proper secret encryption and redaction.
5
+ *
6
+ * Key pattern: integration_connection_{providerId}_{connectionId}
7
+ * Index pattern: integration_connection_index_{providerId} (tracks connection IDs)
8
+ */
9
+ import { z } from "zod";
10
+ import type {
11
+ ConfigService,
12
+ Logger,
13
+ Migration,
14
+ } from "@checkstack/backend-api";
15
+ import type { IntegrationProviderRegistry } from "./provider-registry";
16
+ import type {
17
+ ProviderConnection,
18
+ ProviderConnectionRedacted,
19
+ } from "@checkstack/integration-common";
20
+
21
+ // Schema for connection metadata (stored separately from config)
22
+ const ConnectionMetadataSchema = z.object({
23
+ id: z.string(),
24
+ providerId: z.string(),
25
+ name: z.string(),
26
+ createdAt: z.coerce.date(),
27
+ updatedAt: z.coerce.date(),
28
+ });
29
+
30
+ // Schema for provider's connection index (list of connection IDs)
31
+ const ConnectionIndexSchema = z.object({
32
+ connectionIds: z.array(z.string()),
33
+ });
34
+
35
+ const CONNECTION_STORAGE_VERSION = 1;
36
+
37
+ /**
38
+ * Configuration key for a single connection's config.
39
+ */
40
+ function getConnectionConfigKey(
41
+ providerId: string,
42
+ connectionId: string
43
+ ): string {
44
+ const sanitizedProvider = providerId.replaceAll(".", "_");
45
+ return `integration_connection_${sanitizedProvider}_${connectionId}`;
46
+ }
47
+
48
+ /**
49
+ * Configuration key for a single connection's metadata.
50
+ */
51
+ function getConnectionMetadataKey(
52
+ providerId: string,
53
+ connectionId: string
54
+ ): string {
55
+ const sanitizedProvider = providerId.replaceAll(".", "_");
56
+ return `integration_connection_meta_${sanitizedProvider}_${connectionId}`;
57
+ }
58
+
59
+ /**
60
+ * Configuration key for provider's connection index.
61
+ */
62
+ function getConnectionIndexKey(providerId: string): string {
63
+ const sanitizedProvider = providerId.replaceAll(".", "_");
64
+ return `integration_connection_index_${sanitizedProvider}`;
65
+ }
66
+
67
+ export interface ConnectionStore {
68
+ /** List all connections for a provider (secrets redacted) */
69
+ listConnections(providerId: string): Promise<ProviderConnectionRedacted[]>;
70
+
71
+ /** Get a single connection (secrets redacted) */
72
+ getConnection(
73
+ connectionId: string
74
+ ): Promise<ProviderConnectionRedacted | undefined>;
75
+
76
+ /** Get a connection with full credentials (internal use only) */
77
+ getConnectionWithCredentials(
78
+ connectionId: string
79
+ ): Promise<ProviderConnection | undefined>;
80
+
81
+ /** Create a new connection */
82
+ createConnection(params: {
83
+ providerId: string;
84
+ name: string;
85
+ config: Record<string, unknown>;
86
+ }): Promise<ProviderConnection>;
87
+
88
+ /** Update an existing connection */
89
+ updateConnection(params: {
90
+ connectionId: string;
91
+ updates: {
92
+ name?: string;
93
+ config?: Record<string, unknown>;
94
+ };
95
+ }): Promise<ProviderConnection>;
96
+
97
+ /** Delete a connection */
98
+ deleteConnection(connectionId: string): Promise<boolean>;
99
+
100
+ /** Find which provider owns a connection */
101
+ findConnectionProvider(connectionId: string): Promise<string | undefined>;
102
+ }
103
+
104
+ interface ConnectionStoreDeps {
105
+ configService: ConfigService;
106
+ providerRegistry: IntegrationProviderRegistry;
107
+ logger: Logger;
108
+ }
109
+
110
+ export function createConnectionStore(
111
+ deps: ConnectionStoreDeps
112
+ ): ConnectionStore {
113
+ const { configService, providerRegistry, logger } = deps;
114
+
115
+ // Cache of connectionId -> providerId for efficient lookups
116
+ const connectionProviderCache = new Map<string, string>();
117
+
118
+ /**
119
+ * Get the list of connection IDs for a provider.
120
+ */
121
+ async function getConnectionIndex(providerId: string): Promise<string[]> {
122
+ const key = getConnectionIndexKey(providerId);
123
+ const data = await configService.get(
124
+ key,
125
+ ConnectionIndexSchema,
126
+ CONNECTION_STORAGE_VERSION
127
+ );
128
+ return data?.connectionIds ?? [];
129
+ }
130
+
131
+ /**
132
+ * Save the list of connection IDs for a provider.
133
+ */
134
+ async function setConnectionIndex(
135
+ providerId: string,
136
+ connectionIds: string[]
137
+ ): Promise<void> {
138
+ const key = getConnectionIndexKey(providerId);
139
+ await configService.set(
140
+ key,
141
+ ConnectionIndexSchema,
142
+ CONNECTION_STORAGE_VERSION,
143
+ { connectionIds }
144
+ );
145
+ }
146
+
147
+ /**
148
+ * Get connection metadata.
149
+ */
150
+ async function getConnectionMetadata(
151
+ providerId: string,
152
+ connectionId: string
153
+ ): Promise<z.infer<typeof ConnectionMetadataSchema> | undefined> {
154
+ const key = getConnectionMetadataKey(providerId, connectionId);
155
+ return configService.get(
156
+ key,
157
+ ConnectionMetadataSchema,
158
+ CONNECTION_STORAGE_VERSION
159
+ );
160
+ }
161
+
162
+ /**
163
+ * Save connection metadata.
164
+ */
165
+ async function setConnectionMetadata(
166
+ providerId: string,
167
+ connectionId: string,
168
+ metadata: z.infer<typeof ConnectionMetadataSchema>
169
+ ): Promise<void> {
170
+ const key = getConnectionMetadataKey(providerId, connectionId);
171
+ await configService.set(
172
+ key,
173
+ ConnectionMetadataSchema,
174
+ CONNECTION_STORAGE_VERSION,
175
+ metadata
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Get connection config (with full credentials).
181
+ */
182
+ async function getConnectionConfigRaw(
183
+ providerId: string,
184
+ connectionId: string
185
+ ): Promise<Record<string, unknown> | undefined> {
186
+ const provider = providerRegistry.getProvider(providerId);
187
+ if (!provider?.connectionSchema) return undefined;
188
+
189
+ const key = getConnectionConfigKey(providerId, connectionId);
190
+ const config = await configService.get(
191
+ key,
192
+ provider.connectionSchema.schema,
193
+ provider.connectionSchema.version,
194
+ provider.connectionSchema.migrations as
195
+ | Migration<unknown, unknown>[]
196
+ | undefined
197
+ );
198
+ return config as Record<string, unknown> | undefined;
199
+ }
200
+
201
+ /**
202
+ * Get connection config (secrets redacted).
203
+ */
204
+ async function getConnectionConfigRedacted(
205
+ providerId: string,
206
+ connectionId: string
207
+ ): Promise<Record<string, unknown> | undefined> {
208
+ const provider = providerRegistry.getProvider(providerId);
209
+ if (!provider?.connectionSchema) return undefined;
210
+
211
+ const key = getConnectionConfigKey(providerId, connectionId);
212
+ const config = await configService.getRedacted(
213
+ key,
214
+ provider.connectionSchema.schema,
215
+ provider.connectionSchema.version,
216
+ provider.connectionSchema.migrations as
217
+ | Migration<unknown, unknown>[]
218
+ | undefined
219
+ );
220
+ return config as Record<string, unknown> | undefined;
221
+ }
222
+
223
+ /**
224
+ * Save connection config.
225
+ */
226
+ async function setConnectionConfig(
227
+ providerId: string,
228
+ connectionId: string,
229
+ config: Record<string, unknown>
230
+ ): Promise<void> {
231
+ const provider = providerRegistry.getProvider(providerId);
232
+ if (!provider?.connectionSchema) {
233
+ throw new Error(`Provider ${providerId} has no connectionSchema`);
234
+ }
235
+
236
+ const key = getConnectionConfigKey(providerId, connectionId);
237
+ await configService.set(
238
+ key,
239
+ provider.connectionSchema.schema,
240
+ provider.connectionSchema.version,
241
+ config
242
+ );
243
+ }
244
+
245
+ /**
246
+ * Delete connection config and metadata.
247
+ */
248
+ async function deleteConnectionData(
249
+ providerId: string,
250
+ connectionId: string
251
+ ): Promise<void> {
252
+ const configKey = getConnectionConfigKey(providerId, connectionId);
253
+ await configService.delete(configKey);
254
+
255
+ const metaKey = getConnectionMetadataKey(providerId, connectionId);
256
+ await configService.delete(metaKey);
257
+ }
258
+
259
+ return {
260
+ async listConnections(providerId) {
261
+ // Validate provider exists and has connectionSchema
262
+ const provider = providerRegistry.getProvider(providerId);
263
+ if (!provider?.connectionSchema) {
264
+ return [];
265
+ }
266
+
267
+ const connectionIds = await getConnectionIndex(providerId);
268
+ const connections: ProviderConnectionRedacted[] = [];
269
+
270
+ for (const connectionId of connectionIds) {
271
+ const metadata = await getConnectionMetadata(providerId, connectionId);
272
+ const configPreview = await getConnectionConfigRedacted(
273
+ providerId,
274
+ connectionId
275
+ );
276
+
277
+ if (metadata && configPreview) {
278
+ connectionProviderCache.set(connectionId, providerId);
279
+ connections.push({
280
+ id: metadata.id,
281
+ providerId: metadata.providerId,
282
+ name: metadata.name,
283
+ configPreview,
284
+ createdAt: metadata.createdAt,
285
+ updatedAt: metadata.updatedAt,
286
+ });
287
+ }
288
+ }
289
+
290
+ return connections;
291
+ },
292
+
293
+ async getConnection(connectionId) {
294
+ let providerId = connectionProviderCache.get(connectionId);
295
+
296
+ if (!providerId) {
297
+ providerId = await this.findConnectionProvider(connectionId);
298
+ }
299
+
300
+ if (!providerId) {
301
+ return;
302
+ }
303
+
304
+ const metadata = await getConnectionMetadata(providerId, connectionId);
305
+ const configPreview = await getConnectionConfigRedacted(
306
+ providerId,
307
+ connectionId
308
+ );
309
+
310
+ if (!metadata || !configPreview) return;
311
+
312
+ return {
313
+ id: metadata.id,
314
+ providerId: metadata.providerId,
315
+ name: metadata.name,
316
+ configPreview,
317
+ createdAt: metadata.createdAt,
318
+ updatedAt: metadata.updatedAt,
319
+ };
320
+ },
321
+
322
+ async getConnectionWithCredentials(connectionId) {
323
+ let providerId = connectionProviderCache.get(connectionId);
324
+
325
+ if (!providerId) {
326
+ providerId = await this.findConnectionProvider(connectionId);
327
+ }
328
+
329
+ if (!providerId) {
330
+ return;
331
+ }
332
+
333
+ const metadata = await getConnectionMetadata(providerId, connectionId);
334
+ const config = await getConnectionConfigRaw(providerId, connectionId);
335
+
336
+ if (!metadata || !config) return;
337
+
338
+ return {
339
+ id: metadata.id,
340
+ providerId: metadata.providerId,
341
+ name: metadata.name,
342
+ config,
343
+ createdAt: metadata.createdAt,
344
+ updatedAt: metadata.updatedAt,
345
+ };
346
+ },
347
+
348
+ async createConnection({ providerId, name, config }) {
349
+ const now = new Date();
350
+ const id = crypto.randomUUID();
351
+
352
+ const metadata = {
353
+ id,
354
+ providerId,
355
+ name,
356
+ createdAt: now,
357
+ updatedAt: now,
358
+ };
359
+
360
+ // Save metadata and config separately
361
+ await setConnectionMetadata(providerId, id, metadata);
362
+ await setConnectionConfig(providerId, id, config);
363
+
364
+ // Add to index
365
+ const connectionIds = await getConnectionIndex(providerId);
366
+ connectionIds.push(id);
367
+ await setConnectionIndex(providerId, connectionIds);
368
+
369
+ connectionProviderCache.set(id, providerId);
370
+ logger.info(
371
+ `Created connection "${name}" (${id}) for provider ${providerId}`
372
+ );
373
+
374
+ return { ...metadata, config };
375
+ },
376
+
377
+ async updateConnection({ connectionId, updates }) {
378
+ const providerId =
379
+ connectionProviderCache.get(connectionId) ??
380
+ (await this.findConnectionProvider(connectionId));
381
+
382
+ if (!providerId) {
383
+ throw new Error(`Connection not found: ${connectionId}`);
384
+ }
385
+
386
+ const metadata = await getConnectionMetadata(providerId, connectionId);
387
+ const existingConfig = await getConnectionConfigRaw(
388
+ providerId,
389
+ connectionId
390
+ );
391
+
392
+ if (!metadata || !existingConfig) {
393
+ throw new Error(`Connection not found: ${connectionId}`);
394
+ }
395
+
396
+ const updatedMetadata = {
397
+ ...metadata,
398
+ name: updates.name ?? metadata.name,
399
+ updatedAt: new Date(),
400
+ };
401
+
402
+ const updatedConfig = updates.config
403
+ ? { ...existingConfig, ...updates.config }
404
+ : existingConfig;
405
+
406
+ await setConnectionMetadata(providerId, connectionId, updatedMetadata);
407
+ await setConnectionConfig(providerId, connectionId, updatedConfig);
408
+
409
+ logger.info(
410
+ `Updated connection "${updatedMetadata.name}" (${connectionId})`
411
+ );
412
+ return { ...updatedMetadata, config: updatedConfig };
413
+ },
414
+
415
+ async deleteConnection(connectionId) {
416
+ const providerId =
417
+ connectionProviderCache.get(connectionId) ??
418
+ (await this.findConnectionProvider(connectionId));
419
+
420
+ if (!providerId) {
421
+ return false;
422
+ }
423
+
424
+ // Delete config and metadata
425
+ await deleteConnectionData(providerId, connectionId);
426
+
427
+ // Remove from index
428
+ const connectionIds = await getConnectionIndex(providerId);
429
+ const filtered = connectionIds.filter((id) => id !== connectionId);
430
+
431
+ if (filtered.length === connectionIds.length) {
432
+ return false;
433
+ }
434
+
435
+ await setConnectionIndex(providerId, filtered);
436
+ connectionProviderCache.delete(connectionId);
437
+
438
+ logger.info(
439
+ `Deleted connection ${connectionId} from provider ${providerId}`
440
+ );
441
+ return true;
442
+ },
443
+
444
+ async findConnectionProvider(connectionId) {
445
+ // Check cache first
446
+ const cached = connectionProviderCache.get(connectionId);
447
+ if (cached) return cached;
448
+
449
+ // Iterate through providers that have connectionSchema
450
+ for (const provider of providerRegistry.getProviders()) {
451
+ if (!provider.connectionSchema) continue;
452
+
453
+ const connectionIds = await getConnectionIndex(provider.qualifiedId);
454
+ if (connectionIds.includes(connectionId)) {
455
+ connectionProviderCache.set(connectionId, provider.qualifiedId);
456
+ return provider.qualifiedId;
457
+ }
458
+ }
459
+
460
+ return;
461
+ },
462
+ };
463
+ }