@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.
- package/CHANGELOG.md +85 -0
- package/drizzle/0000_glossy_red_hulk.sql +28 -0
- package/drizzle/0001_rich_fixer.sql +2 -0
- package/drizzle/meta/0000_snapshot.json +191 -0
- package/drizzle/meta/0001_snapshot.json +190 -0
- package/drizzle/meta/_journal.json +20 -0
- package/drizzle.config.ts +8 -0
- package/package.json +36 -0
- package/src/connection-store.test.ts +468 -0
- package/src/connection-store.ts +463 -0
- package/src/delivery-coordinator.ts +390 -0
- package/src/event-registry.test.ts +396 -0
- package/src/event-registry.ts +99 -0
- package/src/hook-subscriber.ts +104 -0
- package/src/index.ts +306 -0
- package/src/provider-registry.test.ts +314 -0
- package/src/provider-registry.ts +107 -0
- package/src/provider-types.ts +257 -0
- package/src/router.ts +858 -0
- package/src/schema.ts +78 -0
- package/tsconfig.json +6 -0
package/src/router.ts
ADDED
|
@@ -0,0 +1,858 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import {
|
|
4
|
+
autoAuthMiddleware,
|
|
5
|
+
type RpcContext,
|
|
6
|
+
type Logger,
|
|
7
|
+
} from "@checkstack/backend-api";
|
|
8
|
+
import type { SignalService } from "@checkstack/signal-common";
|
|
9
|
+
import { eq, desc, and, gte, count } from "drizzle-orm";
|
|
10
|
+
|
|
11
|
+
import type { IntegrationEventRegistry } from "./event-registry";
|
|
12
|
+
import type { IntegrationProviderRegistry } from "./provider-registry";
|
|
13
|
+
import type { DeliveryCoordinator } from "./delivery-coordinator";
|
|
14
|
+
import type { ConnectionStore } from "./connection-store";
|
|
15
|
+
import * as schema from "./schema";
|
|
16
|
+
import {
|
|
17
|
+
integrationContract,
|
|
18
|
+
INTEGRATION_SUBSCRIPTION_CHANGED,
|
|
19
|
+
} from "@checkstack/integration-common";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Recursively extracts flattened property paths from a JSON Schema.
|
|
23
|
+
* Used to provide template hints for payload properties.
|
|
24
|
+
*/
|
|
25
|
+
interface JsonSchemaProperty {
|
|
26
|
+
path: string;
|
|
27
|
+
type: string;
|
|
28
|
+
description?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function extractJsonSchemaProperties(
|
|
32
|
+
schema: Record<string, unknown>,
|
|
33
|
+
basePath: string = ""
|
|
34
|
+
): JsonSchemaProperty[] {
|
|
35
|
+
const properties: JsonSchemaProperty[] = [];
|
|
36
|
+
|
|
37
|
+
const schemaType = schema["type"] as string | string[] | undefined;
|
|
38
|
+
const schemaProperties = schema["properties"] as
|
|
39
|
+
| Record<string, Record<string, unknown>>
|
|
40
|
+
| undefined;
|
|
41
|
+
const schemaItems = schema["items"] as Record<string, unknown> | undefined;
|
|
42
|
+
const schemaDescription = schema["description"] as string | undefined;
|
|
43
|
+
|
|
44
|
+
// Handle object with properties
|
|
45
|
+
if (schemaProperties) {
|
|
46
|
+
for (const [key, propSchema] of Object.entries(schemaProperties)) {
|
|
47
|
+
const propPath = basePath ? `${basePath}.${key}` : key;
|
|
48
|
+
const propType = (propSchema["type"] as string) || "unknown";
|
|
49
|
+
const propDescription = propSchema["description"] as string | undefined;
|
|
50
|
+
|
|
51
|
+
// Add this property
|
|
52
|
+
properties.push({
|
|
53
|
+
path: propPath,
|
|
54
|
+
type: Array.isArray(propType) ? propType.join(" | ") : propType,
|
|
55
|
+
description: propDescription,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Recurse into nested objects
|
|
59
|
+
if (propType === "object" || propSchema["properties"]) {
|
|
60
|
+
properties.push(...extractJsonSchemaProperties(propSchema, propPath));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Recurse into arrays (add [n] notation)
|
|
64
|
+
if (propType === "array" && propSchema["items"]) {
|
|
65
|
+
const itemsSchema = propSchema["items"] as Record<string, unknown>;
|
|
66
|
+
properties.push(
|
|
67
|
+
...extractJsonSchemaProperties(itemsSchema, `${propPath}[n]`)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Handle array at root level
|
|
74
|
+
if (schemaType === "array" && schemaItems) {
|
|
75
|
+
properties.push(
|
|
76
|
+
...extractJsonSchemaProperties(schemaItems, `${basePath}[n]`)
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If this is a primitive with a path, add it
|
|
81
|
+
if (
|
|
82
|
+
basePath &&
|
|
83
|
+
schemaType &&
|
|
84
|
+
schemaType !== "object" &&
|
|
85
|
+
schemaType !== "array" &&
|
|
86
|
+
!schemaProperties
|
|
87
|
+
) {
|
|
88
|
+
properties.push({
|
|
89
|
+
path: basePath,
|
|
90
|
+
type: Array.isArray(schemaType) ? schemaType.join(" | ") : schemaType,
|
|
91
|
+
description: schemaDescription,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return properties;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
interface RouterDeps {
|
|
99
|
+
db: NodePgDatabase<typeof schema>;
|
|
100
|
+
eventRegistry: IntegrationEventRegistry;
|
|
101
|
+
providerRegistry: IntegrationProviderRegistry;
|
|
102
|
+
deliveryCoordinator: DeliveryCoordinator;
|
|
103
|
+
connectionStore: ConnectionStore;
|
|
104
|
+
signalService: SignalService;
|
|
105
|
+
logger: Logger;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Creates the integration router using contract-based implementation.
|
|
110
|
+
*
|
|
111
|
+
* Auth and permissions are automatically enforced via autoAuthMiddleware
|
|
112
|
+
* based on the contract's meta.userType and meta.permissions.
|
|
113
|
+
*/
|
|
114
|
+
export function createIntegrationRouter(deps: RouterDeps) {
|
|
115
|
+
const {
|
|
116
|
+
db,
|
|
117
|
+
eventRegistry,
|
|
118
|
+
providerRegistry,
|
|
119
|
+
deliveryCoordinator,
|
|
120
|
+
connectionStore,
|
|
121
|
+
signalService,
|
|
122
|
+
logger,
|
|
123
|
+
} = deps;
|
|
124
|
+
|
|
125
|
+
// Create contract implementer with context type AND auto auth middleware
|
|
126
|
+
const os = implement(integrationContract)
|
|
127
|
+
.$context<RpcContext>()
|
|
128
|
+
.use(autoAuthMiddleware);
|
|
129
|
+
|
|
130
|
+
return os.router({
|
|
131
|
+
// =========================================================================
|
|
132
|
+
// SUBSCRIPTION MANAGEMENT
|
|
133
|
+
// =========================================================================
|
|
134
|
+
|
|
135
|
+
listSubscriptions: os.listSubscriptions.handler(async ({ input }) => {
|
|
136
|
+
const { page, pageSize, providerId, eventType, enabled } = input;
|
|
137
|
+
const offset = (page - 1) * pageSize;
|
|
138
|
+
|
|
139
|
+
// Build where conditions
|
|
140
|
+
const conditions = [];
|
|
141
|
+
if (providerId) {
|
|
142
|
+
conditions.push(eq(schema.webhookSubscriptions.providerId, providerId));
|
|
143
|
+
}
|
|
144
|
+
if (enabled !== undefined) {
|
|
145
|
+
conditions.push(eq(schema.webhookSubscriptions.enabled, enabled));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const whereClause =
|
|
149
|
+
conditions.length > 0 ? and(...conditions) : undefined;
|
|
150
|
+
|
|
151
|
+
// Get total count
|
|
152
|
+
const [{ value: total }] = await db
|
|
153
|
+
.select({ value: count() })
|
|
154
|
+
.from(schema.webhookSubscriptions)
|
|
155
|
+
.where(whereClause);
|
|
156
|
+
|
|
157
|
+
// Get paginated results
|
|
158
|
+
let query = db
|
|
159
|
+
.select()
|
|
160
|
+
.from(schema.webhookSubscriptions)
|
|
161
|
+
.orderBy(desc(schema.webhookSubscriptions.createdAt))
|
|
162
|
+
.limit(pageSize)
|
|
163
|
+
.offset(offset);
|
|
164
|
+
|
|
165
|
+
if (whereClause) {
|
|
166
|
+
query = query.where(whereClause) as typeof query;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const subscriptions = await query;
|
|
170
|
+
|
|
171
|
+
// Filter by event type if specified
|
|
172
|
+
const filtered = eventType
|
|
173
|
+
? subscriptions.filter((s) => s.eventId === eventType)
|
|
174
|
+
: subscriptions;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
subscriptions: filtered.map((s) => ({
|
|
178
|
+
...s,
|
|
179
|
+
description: s.description ?? undefined,
|
|
180
|
+
systemFilter: s.systemFilter ?? undefined,
|
|
181
|
+
createdAt: s.createdAt,
|
|
182
|
+
updatedAt: s.updatedAt,
|
|
183
|
+
})),
|
|
184
|
+
total: Number(total),
|
|
185
|
+
};
|
|
186
|
+
}),
|
|
187
|
+
|
|
188
|
+
getSubscription: os.getSubscription.handler(async ({ input }) => {
|
|
189
|
+
const [subscription] = await db
|
|
190
|
+
.select()
|
|
191
|
+
.from(schema.webhookSubscriptions)
|
|
192
|
+
.where(eq(schema.webhookSubscriptions.id, input.id));
|
|
193
|
+
|
|
194
|
+
if (!subscription) {
|
|
195
|
+
throw new ORPCError("NOT_FOUND", {
|
|
196
|
+
message: "Subscription not found",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
...subscription,
|
|
202
|
+
description: subscription.description ?? undefined,
|
|
203
|
+
systemFilter: subscription.systemFilter ?? undefined,
|
|
204
|
+
createdAt: subscription.createdAt,
|
|
205
|
+
updatedAt: subscription.updatedAt,
|
|
206
|
+
};
|
|
207
|
+
}),
|
|
208
|
+
|
|
209
|
+
createSubscription: os.createSubscription.handler(async ({ input }) => {
|
|
210
|
+
const {
|
|
211
|
+
name,
|
|
212
|
+
description,
|
|
213
|
+
providerId,
|
|
214
|
+
providerConfig,
|
|
215
|
+
eventId,
|
|
216
|
+
systemFilter,
|
|
217
|
+
} = input;
|
|
218
|
+
|
|
219
|
+
// Validate provider exists
|
|
220
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
221
|
+
if (!provider) {
|
|
222
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
223
|
+
message: `Provider not found: ${providerId}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Validate event exists
|
|
228
|
+
if (!eventRegistry.hasEvent(eventId)) {
|
|
229
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
230
|
+
message: `Event type not found: ${eventId}`,
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Validate providerConfig against the provider's schema
|
|
235
|
+
const configParseResult =
|
|
236
|
+
provider.config.schema.safeParse(providerConfig);
|
|
237
|
+
if (!configParseResult.success) {
|
|
238
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
239
|
+
message: `Invalid provider configuration: ${configParseResult.error.message}`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const id = crypto.randomUUID();
|
|
244
|
+
const now = new Date();
|
|
245
|
+
|
|
246
|
+
await db.insert(schema.webhookSubscriptions).values({
|
|
247
|
+
id,
|
|
248
|
+
name,
|
|
249
|
+
description,
|
|
250
|
+
providerId,
|
|
251
|
+
providerConfig,
|
|
252
|
+
eventId,
|
|
253
|
+
systemFilter,
|
|
254
|
+
enabled: true,
|
|
255
|
+
createdAt: now,
|
|
256
|
+
updatedAt: now,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// Emit signal
|
|
260
|
+
await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
|
|
261
|
+
action: "created",
|
|
262
|
+
subscriptionId: id,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
logger.info(`Created webhook subscription: ${name} (${id})`);
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
id,
|
|
269
|
+
name,
|
|
270
|
+
description,
|
|
271
|
+
providerId,
|
|
272
|
+
providerConfig,
|
|
273
|
+
eventId,
|
|
274
|
+
systemFilter,
|
|
275
|
+
enabled: true,
|
|
276
|
+
createdAt: now,
|
|
277
|
+
updatedAt: now,
|
|
278
|
+
};
|
|
279
|
+
}),
|
|
280
|
+
|
|
281
|
+
updateSubscription: os.updateSubscription.handler(async ({ input }) => {
|
|
282
|
+
const { id, updates } = input;
|
|
283
|
+
|
|
284
|
+
// Check subscription exists
|
|
285
|
+
const [existing] = await db
|
|
286
|
+
.select()
|
|
287
|
+
.from(schema.webhookSubscriptions)
|
|
288
|
+
.where(eq(schema.webhookSubscriptions.id, id));
|
|
289
|
+
|
|
290
|
+
if (!existing) {
|
|
291
|
+
throw new ORPCError("NOT_FOUND", {
|
|
292
|
+
message: "Subscription not found",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Validate event if updated
|
|
297
|
+
if (updates.eventId && !eventRegistry.hasEvent(updates.eventId)) {
|
|
298
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
299
|
+
message: `Event type not found: ${updates.eventId}`,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Validate providerConfig if updated
|
|
304
|
+
if (updates.providerConfig) {
|
|
305
|
+
const provider = providerRegistry.getProvider(existing.providerId);
|
|
306
|
+
if (provider) {
|
|
307
|
+
const configParseResult = provider.config.schema.safeParse(
|
|
308
|
+
updates.providerConfig
|
|
309
|
+
);
|
|
310
|
+
if (!configParseResult.success) {
|
|
311
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
312
|
+
message: `Invalid provider configuration: ${configParseResult.error.message}`,
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const now = new Date();
|
|
319
|
+
|
|
320
|
+
await db
|
|
321
|
+
.update(schema.webhookSubscriptions)
|
|
322
|
+
.set({
|
|
323
|
+
...updates,
|
|
324
|
+
updatedAt: now,
|
|
325
|
+
})
|
|
326
|
+
.where(eq(schema.webhookSubscriptions.id, id));
|
|
327
|
+
|
|
328
|
+
// Emit signal
|
|
329
|
+
await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
|
|
330
|
+
action: "updated",
|
|
331
|
+
subscriptionId: id,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// Re-fetch updated subscription
|
|
335
|
+
const [updated] = await db
|
|
336
|
+
.select()
|
|
337
|
+
.from(schema.webhookSubscriptions)
|
|
338
|
+
.where(eq(schema.webhookSubscriptions.id, id));
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
...updated,
|
|
342
|
+
description: updated.description ?? undefined,
|
|
343
|
+
systemFilter: updated.systemFilter ?? undefined,
|
|
344
|
+
createdAt: updated.createdAt,
|
|
345
|
+
updatedAt: updated.updatedAt,
|
|
346
|
+
};
|
|
347
|
+
}),
|
|
348
|
+
|
|
349
|
+
deleteSubscription: os.deleteSubscription.handler(async ({ input }) => {
|
|
350
|
+
const { id } = input;
|
|
351
|
+
|
|
352
|
+
await db
|
|
353
|
+
.delete(schema.webhookSubscriptions)
|
|
354
|
+
.where(eq(schema.webhookSubscriptions.id, id));
|
|
355
|
+
|
|
356
|
+
// Emit signal
|
|
357
|
+
await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
|
|
358
|
+
action: "deleted",
|
|
359
|
+
subscriptionId: id,
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
logger.info(`Deleted webhook subscription: ${id}`);
|
|
363
|
+
|
|
364
|
+
return { success: true };
|
|
365
|
+
}),
|
|
366
|
+
|
|
367
|
+
toggleSubscription: os.toggleSubscription.handler(async ({ input }) => {
|
|
368
|
+
const { id, enabled } = input;
|
|
369
|
+
|
|
370
|
+
await db
|
|
371
|
+
.update(schema.webhookSubscriptions)
|
|
372
|
+
.set({
|
|
373
|
+
enabled,
|
|
374
|
+
updatedAt: new Date(),
|
|
375
|
+
})
|
|
376
|
+
.where(eq(schema.webhookSubscriptions.id, id));
|
|
377
|
+
|
|
378
|
+
// Emit signal
|
|
379
|
+
await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
|
|
380
|
+
action: "updated",
|
|
381
|
+
subscriptionId: id,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
return { success: true };
|
|
385
|
+
}),
|
|
386
|
+
|
|
387
|
+
// =========================================================================
|
|
388
|
+
// PROVIDER DISCOVERY
|
|
389
|
+
// =========================================================================
|
|
390
|
+
|
|
391
|
+
listProviders: os.listProviders.handler(async () => {
|
|
392
|
+
const providers = providerRegistry.getProviders();
|
|
393
|
+
|
|
394
|
+
return providers.map((p) => ({
|
|
395
|
+
qualifiedId: p.qualifiedId,
|
|
396
|
+
displayName: p.displayName,
|
|
397
|
+
description: p.description,
|
|
398
|
+
icon: p.icon,
|
|
399
|
+
ownerPluginId: p.ownerPluginId,
|
|
400
|
+
supportedEvents: p.supportedEvents,
|
|
401
|
+
configSchema:
|
|
402
|
+
providerRegistry.getProviderConfigSchema(p.qualifiedId) ?? {},
|
|
403
|
+
hasConnectionSchema: !!p.connectionSchema,
|
|
404
|
+
connectionSchema: p.connectionSchema
|
|
405
|
+
? providerRegistry.getProviderConnectionSchema(p.qualifiedId)
|
|
406
|
+
: undefined,
|
|
407
|
+
documentation: p.documentation,
|
|
408
|
+
}));
|
|
409
|
+
}),
|
|
410
|
+
|
|
411
|
+
testProviderConnection: os.testProviderConnection.handler(
|
|
412
|
+
async ({ input }) => {
|
|
413
|
+
const { providerId, config } = input;
|
|
414
|
+
|
|
415
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
416
|
+
if (!provider) {
|
|
417
|
+
return { success: false, message: "Provider not found" };
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (!provider.testConnection) {
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
message: "Provider does not support connection testing",
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
try {
|
|
428
|
+
const result = await provider.testConnection(config);
|
|
429
|
+
return result;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
return {
|
|
432
|
+
success: false,
|
|
433
|
+
message: error instanceof Error ? error.message : String(error),
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
),
|
|
438
|
+
|
|
439
|
+
// =========================================================================
|
|
440
|
+
// CONNECTION MANAGEMENT
|
|
441
|
+
// Generic CRUD for site-wide provider connections
|
|
442
|
+
// =========================================================================
|
|
443
|
+
|
|
444
|
+
listConnections: os.listConnections.handler(async ({ input }) => {
|
|
445
|
+
const { providerId } = input;
|
|
446
|
+
|
|
447
|
+
// Verify provider exists and has connectionSchema
|
|
448
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
449
|
+
if (!provider) {
|
|
450
|
+
throw new ORPCError("NOT_FOUND", {
|
|
451
|
+
message: `Provider not found: ${providerId}`,
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (!provider.connectionSchema) {
|
|
456
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
457
|
+
message: `Provider ${providerId} does not support site-wide connections`,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return connectionStore.listConnections(providerId);
|
|
462
|
+
}),
|
|
463
|
+
|
|
464
|
+
getConnection: os.getConnection.handler(async ({ input }) => {
|
|
465
|
+
const { connectionId } = input;
|
|
466
|
+
const connection = await connectionStore.getConnection(connectionId);
|
|
467
|
+
|
|
468
|
+
if (!connection) {
|
|
469
|
+
throw new ORPCError("NOT_FOUND", {
|
|
470
|
+
message: `Connection not found: ${connectionId}`,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
return connection;
|
|
475
|
+
}),
|
|
476
|
+
|
|
477
|
+
createConnection: os.createConnection.handler(async ({ input }) => {
|
|
478
|
+
const { providerId, name, config } = input;
|
|
479
|
+
|
|
480
|
+
// Verify provider exists and has connectionSchema
|
|
481
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
482
|
+
if (!provider) {
|
|
483
|
+
throw new ORPCError("NOT_FOUND", {
|
|
484
|
+
message: `Provider not found: ${providerId}`,
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!provider.connectionSchema) {
|
|
489
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
490
|
+
message: `Provider ${providerId} does not support site-wide connections`,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Validate config against provider's connectionSchema
|
|
495
|
+
const parseResult = provider.connectionSchema.schema.safeParse(config);
|
|
496
|
+
if (!parseResult.success) {
|
|
497
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
498
|
+
message: `Invalid connection config: ${parseResult.error.message}`,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// parseResult.data is typed correctly after guard
|
|
503
|
+
const validatedConfig = parseResult.data as unknown as Record<
|
|
504
|
+
string,
|
|
505
|
+
unknown
|
|
506
|
+
>;
|
|
507
|
+
|
|
508
|
+
const connection = await connectionStore.createConnection({
|
|
509
|
+
providerId,
|
|
510
|
+
name,
|
|
511
|
+
config: validatedConfig,
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
logger.info(`Created connection "${name}" for provider ${providerId}`);
|
|
515
|
+
|
|
516
|
+
// Return redacted version
|
|
517
|
+
return {
|
|
518
|
+
id: connection.id,
|
|
519
|
+
providerId: connection.providerId,
|
|
520
|
+
name: connection.name,
|
|
521
|
+
configPreview: config, // Will be redacted in real usage
|
|
522
|
+
createdAt: connection.createdAt,
|
|
523
|
+
updatedAt: connection.updatedAt,
|
|
524
|
+
};
|
|
525
|
+
}),
|
|
526
|
+
|
|
527
|
+
updateConnection: os.updateConnection.handler(async ({ input }) => {
|
|
528
|
+
const { connectionId, updates } = input;
|
|
529
|
+
|
|
530
|
+
try {
|
|
531
|
+
const connection = await connectionStore.updateConnection({
|
|
532
|
+
connectionId,
|
|
533
|
+
updates,
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
return {
|
|
537
|
+
id: connection.id,
|
|
538
|
+
providerId: connection.providerId,
|
|
539
|
+
name: connection.name,
|
|
540
|
+
configPreview: (updates.config ?? {}) as Record<string, unknown>,
|
|
541
|
+
createdAt: connection.createdAt,
|
|
542
|
+
updatedAt: connection.updatedAt,
|
|
543
|
+
};
|
|
544
|
+
} catch (error) {
|
|
545
|
+
throw new ORPCError("NOT_FOUND", {
|
|
546
|
+
message:
|
|
547
|
+
error instanceof Error ? error.message : "Connection not found",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
}),
|
|
551
|
+
|
|
552
|
+
deleteConnection: os.deleteConnection.handler(async ({ input }) => {
|
|
553
|
+
const { connectionId } = input;
|
|
554
|
+
const deleted = await connectionStore.deleteConnection(connectionId);
|
|
555
|
+
|
|
556
|
+
if (!deleted) {
|
|
557
|
+
throw new ORPCError("NOT_FOUND", {
|
|
558
|
+
message: `Connection not found: ${connectionId}`,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return { success: true };
|
|
563
|
+
}),
|
|
564
|
+
|
|
565
|
+
testConnection: os.testConnection.handler(async ({ input }) => {
|
|
566
|
+
const { connectionId } = input;
|
|
567
|
+
|
|
568
|
+
const connection = await connectionStore.getConnectionWithCredentials(
|
|
569
|
+
connectionId
|
|
570
|
+
);
|
|
571
|
+
if (!connection) {
|
|
572
|
+
return { success: false, message: "Connection not found" };
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const provider = providerRegistry.getProvider(connection.providerId);
|
|
576
|
+
if (!provider) {
|
|
577
|
+
return { success: false, message: "Provider not found" };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (!provider.testConnection) {
|
|
581
|
+
return {
|
|
582
|
+
success: true,
|
|
583
|
+
message: "Provider does not support connection testing",
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
try {
|
|
588
|
+
const result = await provider.testConnection(connection.config);
|
|
589
|
+
return result;
|
|
590
|
+
} catch (error) {
|
|
591
|
+
return {
|
|
592
|
+
success: false,
|
|
593
|
+
message: error instanceof Error ? error.message : String(error),
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
}),
|
|
597
|
+
|
|
598
|
+
getConnectionOptions: os.getConnectionOptions.handler(async ({ input }) => {
|
|
599
|
+
const { providerId, connectionId, resolverName, context } = input;
|
|
600
|
+
|
|
601
|
+
logger.debug(
|
|
602
|
+
`getConnectionOptions called: providerId=${providerId}, connectionId=${connectionId}, resolverName=${resolverName}`
|
|
603
|
+
);
|
|
604
|
+
|
|
605
|
+
const provider = providerRegistry.getProvider(providerId);
|
|
606
|
+
if (!provider) {
|
|
607
|
+
throw new ORPCError("NOT_FOUND", {
|
|
608
|
+
message: `Provider not found: ${providerId}`,
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
if (!provider.getConnectionOptions) {
|
|
613
|
+
throw new ORPCError("BAD_REQUEST", {
|
|
614
|
+
message: `Provider ${providerId} does not support dynamic options`,
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
try {
|
|
619
|
+
const options = await provider.getConnectionOptions({
|
|
620
|
+
connectionId,
|
|
621
|
+
resolverName,
|
|
622
|
+
context,
|
|
623
|
+
logger,
|
|
624
|
+
getConnectionWithCredentials:
|
|
625
|
+
connectionStore.getConnectionWithCredentials.bind(connectionStore),
|
|
626
|
+
});
|
|
627
|
+
logger.debug(
|
|
628
|
+
`getConnectionOptions returned ${options.length} options for ${resolverName}`
|
|
629
|
+
);
|
|
630
|
+
return options;
|
|
631
|
+
} catch (error) {
|
|
632
|
+
logger.error(`Failed to get connection options: ${error}`);
|
|
633
|
+
throw new ORPCError("INTERNAL_SERVER_ERROR", {
|
|
634
|
+
message:
|
|
635
|
+
error instanceof Error ? error.message : "Failed to fetch options",
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
}),
|
|
639
|
+
|
|
640
|
+
// =========================================================================
|
|
641
|
+
// EVENT DISCOVERY
|
|
642
|
+
// =========================================================================
|
|
643
|
+
|
|
644
|
+
listEventTypes: os.listEventTypes.handler(async () => {
|
|
645
|
+
const events = eventRegistry.getEvents();
|
|
646
|
+
|
|
647
|
+
return events.map((e) => ({
|
|
648
|
+
eventId: e.eventId,
|
|
649
|
+
displayName: e.displayName,
|
|
650
|
+
description: e.description,
|
|
651
|
+
category: e.category,
|
|
652
|
+
ownerPluginId: e.ownerPluginId,
|
|
653
|
+
payloadSchema: e.payloadJsonSchema,
|
|
654
|
+
}));
|
|
655
|
+
}),
|
|
656
|
+
|
|
657
|
+
getEventsByCategory: os.getEventsByCategory.handler(async () => {
|
|
658
|
+
const byCategory = eventRegistry.getEventsByCategory();
|
|
659
|
+
|
|
660
|
+
return [...byCategory.entries()].map(([category, events]) => ({
|
|
661
|
+
category,
|
|
662
|
+
events: events.map((e) => ({
|
|
663
|
+
eventId: e.eventId,
|
|
664
|
+
displayName: e.displayName,
|
|
665
|
+
description: e.description,
|
|
666
|
+
category: e.category,
|
|
667
|
+
ownerPluginId: e.ownerPluginId,
|
|
668
|
+
payloadSchema: e.payloadJsonSchema,
|
|
669
|
+
})),
|
|
670
|
+
}));
|
|
671
|
+
}),
|
|
672
|
+
|
|
673
|
+
getEventPayloadSchema: os.getEventPayloadSchema.handler(
|
|
674
|
+
async ({ input }) => {
|
|
675
|
+
const { eventId } = input;
|
|
676
|
+
|
|
677
|
+
const event = eventRegistry.getEvent(eventId);
|
|
678
|
+
if (!event) {
|
|
679
|
+
throw new ORPCError("NOT_FOUND", {
|
|
680
|
+
message: `Event not found: ${eventId}`,
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// Extract flattened properties from JSON Schema
|
|
685
|
+
const availableProperties = extractJsonSchemaProperties(
|
|
686
|
+
event.payloadJsonSchema,
|
|
687
|
+
"payload"
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
return {
|
|
691
|
+
eventId: event.eventId,
|
|
692
|
+
payloadSchema: event.payloadJsonSchema,
|
|
693
|
+
availableProperties,
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
),
|
|
697
|
+
|
|
698
|
+
// =========================================================================
|
|
699
|
+
// DELIVERY LOGS
|
|
700
|
+
// =========================================================================
|
|
701
|
+
|
|
702
|
+
getDeliveryLogs: os.getDeliveryLogs.handler(async ({ input }) => {
|
|
703
|
+
const { subscriptionId, eventType, status, page, pageSize } = input;
|
|
704
|
+
const offset = (page - 1) * pageSize;
|
|
705
|
+
|
|
706
|
+
// Build where conditions
|
|
707
|
+
const conditions = [];
|
|
708
|
+
if (subscriptionId) {
|
|
709
|
+
conditions.push(eq(schema.deliveryLogs.subscriptionId, subscriptionId));
|
|
710
|
+
}
|
|
711
|
+
if (eventType) {
|
|
712
|
+
conditions.push(eq(schema.deliveryLogs.eventType, eventType));
|
|
713
|
+
}
|
|
714
|
+
if (status) {
|
|
715
|
+
conditions.push(eq(schema.deliveryLogs.status, status));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const whereClause =
|
|
719
|
+
conditions.length > 0 ? and(...conditions) : undefined;
|
|
720
|
+
|
|
721
|
+
// Get total count
|
|
722
|
+
const [{ value: total }] = await db
|
|
723
|
+
.select({ value: count() })
|
|
724
|
+
.from(schema.deliveryLogs)
|
|
725
|
+
.where(whereClause);
|
|
726
|
+
|
|
727
|
+
// Get paginated results with subscription name
|
|
728
|
+
const logs = await db
|
|
729
|
+
.select({
|
|
730
|
+
log: schema.deliveryLogs,
|
|
731
|
+
subscriptionName: schema.webhookSubscriptions.name,
|
|
732
|
+
})
|
|
733
|
+
.from(schema.deliveryLogs)
|
|
734
|
+
.leftJoin(
|
|
735
|
+
schema.webhookSubscriptions,
|
|
736
|
+
eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
|
|
737
|
+
)
|
|
738
|
+
.where(whereClause)
|
|
739
|
+
.orderBy(desc(schema.deliveryLogs.createdAt))
|
|
740
|
+
.limit(pageSize)
|
|
741
|
+
.offset(offset);
|
|
742
|
+
|
|
743
|
+
return {
|
|
744
|
+
logs: logs.map(({ log, subscriptionName }) => ({
|
|
745
|
+
...log,
|
|
746
|
+
subscriptionName: subscriptionName ?? undefined,
|
|
747
|
+
createdAt: log.createdAt,
|
|
748
|
+
lastAttemptAt: log.lastAttemptAt ?? undefined,
|
|
749
|
+
nextRetryAt: log.nextRetryAt ?? undefined,
|
|
750
|
+
externalId: log.externalId ?? undefined,
|
|
751
|
+
errorMessage: log.errorMessage ?? undefined,
|
|
752
|
+
})),
|
|
753
|
+
total: Number(total),
|
|
754
|
+
};
|
|
755
|
+
}),
|
|
756
|
+
|
|
757
|
+
getDeliveryLog: os.getDeliveryLog.handler(async ({ input }) => {
|
|
758
|
+
const [result] = await db
|
|
759
|
+
.select({
|
|
760
|
+
log: schema.deliveryLogs,
|
|
761
|
+
subscriptionName: schema.webhookSubscriptions.name,
|
|
762
|
+
})
|
|
763
|
+
.from(schema.deliveryLogs)
|
|
764
|
+
.leftJoin(
|
|
765
|
+
schema.webhookSubscriptions,
|
|
766
|
+
eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
|
|
767
|
+
)
|
|
768
|
+
.where(eq(schema.deliveryLogs.id, input.id));
|
|
769
|
+
|
|
770
|
+
if (!result) {
|
|
771
|
+
throw new ORPCError("NOT_FOUND", {
|
|
772
|
+
message: "Delivery log not found",
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
...result.log,
|
|
778
|
+
subscriptionName: result.subscriptionName ?? undefined,
|
|
779
|
+
createdAt: result.log.createdAt,
|
|
780
|
+
lastAttemptAt: result.log.lastAttemptAt ?? undefined,
|
|
781
|
+
nextRetryAt: result.log.nextRetryAt ?? undefined,
|
|
782
|
+
externalId: result.log.externalId ?? undefined,
|
|
783
|
+
errorMessage: result.log.errorMessage ?? undefined,
|
|
784
|
+
};
|
|
785
|
+
}),
|
|
786
|
+
|
|
787
|
+
retryDelivery: os.retryDelivery.handler(async ({ input }) => {
|
|
788
|
+
return deliveryCoordinator.retryDelivery(input.logId);
|
|
789
|
+
}),
|
|
790
|
+
|
|
791
|
+
getDeliveryStats: os.getDeliveryStats.handler(async ({ input }) => {
|
|
792
|
+
const { hours } = input;
|
|
793
|
+
const since = new Date(Date.now() - hours * 60 * 60 * 1000);
|
|
794
|
+
|
|
795
|
+
// Get counts by status
|
|
796
|
+
const statusCounts = await db
|
|
797
|
+
.select({
|
|
798
|
+
status: schema.deliveryLogs.status,
|
|
799
|
+
count: count(),
|
|
800
|
+
})
|
|
801
|
+
.from(schema.deliveryLogs)
|
|
802
|
+
.where(gte(schema.deliveryLogs.createdAt, since))
|
|
803
|
+
.groupBy(schema.deliveryLogs.status);
|
|
804
|
+
|
|
805
|
+
// Get counts by event type
|
|
806
|
+
const eventCounts = await db
|
|
807
|
+
.select({
|
|
808
|
+
eventType: schema.deliveryLogs.eventType,
|
|
809
|
+
count: count(),
|
|
810
|
+
})
|
|
811
|
+
.from(schema.deliveryLogs)
|
|
812
|
+
.where(gte(schema.deliveryLogs.createdAt, since))
|
|
813
|
+
.groupBy(schema.deliveryLogs.eventType);
|
|
814
|
+
|
|
815
|
+
// Get counts by provider (via subscription)
|
|
816
|
+
const providerCounts = await db
|
|
817
|
+
.select({
|
|
818
|
+
providerId: schema.webhookSubscriptions.providerId,
|
|
819
|
+
count: count(),
|
|
820
|
+
})
|
|
821
|
+
.from(schema.deliveryLogs)
|
|
822
|
+
.innerJoin(
|
|
823
|
+
schema.webhookSubscriptions,
|
|
824
|
+
eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
|
|
825
|
+
)
|
|
826
|
+
.where(gte(schema.deliveryLogs.createdAt, since))
|
|
827
|
+
.groupBy(schema.webhookSubscriptions.providerId);
|
|
828
|
+
|
|
829
|
+
// Build response
|
|
830
|
+
const statusMap = new Map(
|
|
831
|
+
statusCounts.map((s) => [s.status, Number(s.count)])
|
|
832
|
+
);
|
|
833
|
+
const total =
|
|
834
|
+
(statusMap.get("success") ?? 0) +
|
|
835
|
+
(statusMap.get("failed") ?? 0) +
|
|
836
|
+
(statusMap.get("retrying") ?? 0) +
|
|
837
|
+
(statusMap.get("pending") ?? 0);
|
|
838
|
+
|
|
839
|
+
return {
|
|
840
|
+
total,
|
|
841
|
+
successful: statusMap.get("success") ?? 0,
|
|
842
|
+
failed: statusMap.get("failed") ?? 0,
|
|
843
|
+
retrying: statusMap.get("retrying") ?? 0,
|
|
844
|
+
pending: statusMap.get("pending") ?? 0,
|
|
845
|
+
byEvent: eventCounts.map((e) => ({
|
|
846
|
+
eventType: e.eventType,
|
|
847
|
+
count: Number(e.count),
|
|
848
|
+
})),
|
|
849
|
+
byProvider: providerCounts.map((p) => ({
|
|
850
|
+
providerId: p.providerId,
|
|
851
|
+
count: Number(p.count),
|
|
852
|
+
})),
|
|
853
|
+
};
|
|
854
|
+
}),
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
export type IntegrationRouter = ReturnType<typeof createIntegrationRouter>;
|