@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/src/index.ts ADDED
@@ -0,0 +1,306 @@
1
+ import {
2
+ createBackendPlugin,
3
+ coreServices,
4
+ createExtensionPoint,
5
+ createServiceRef,
6
+ } from "@checkstack/backend-api";
7
+ import {
8
+ permissionList,
9
+ pluginMetadata,
10
+ integrationContract,
11
+ integrationRoutes,
12
+ permissions,
13
+ } from "@checkstack/integration-common";
14
+ import { resolveRoute } from "@checkstack/common";
15
+ import type { PluginMetadata } from "@checkstack/common";
16
+ import type {
17
+ IntegrationEventDefinition,
18
+ IntegrationProvider,
19
+ } from "./provider-types";
20
+
21
+ import * as schema from "./schema";
22
+ import {
23
+ createIntegrationEventRegistry,
24
+ type IntegrationEventRegistry,
25
+ } from "./event-registry";
26
+ import {
27
+ createIntegrationProviderRegistry,
28
+ type IntegrationProviderRegistry,
29
+ } from "./provider-registry";
30
+ import { createDeliveryCoordinator } from "./delivery-coordinator";
31
+ import {
32
+ createConnectionStore,
33
+ type ConnectionStore,
34
+ } from "./connection-store";
35
+ import { subscribeToRegisteredEvents } from "./hook-subscriber";
36
+ import { createIntegrationRouter } from "./router";
37
+ import { registerSearchProvider } from "@checkstack/command-backend";
38
+
39
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
40
+ // Service References
41
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
42
+
43
+ /**
44
+ * Service reference for the connection store.
45
+ * Provider plugins can inject this to access connection credentials.
46
+ */
47
+ export const connectionStoreRef = createServiceRef<ConnectionStore>(
48
+ "integration.connectionStore"
49
+ );
50
+
51
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
52
+ // Extension Points
53
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
54
+
55
+ /**
56
+ * Extension point for registering integration events.
57
+ * Plugins use this to expose their hooks for external webhook subscriptions.
58
+ */
59
+ export interface IntegrationEventExtensionPoint {
60
+ /**
61
+ * Register a hook as an integration event.
62
+ * The event will be namespaced by the plugin's ID automatically.
63
+ */
64
+ registerEvent<T>(
65
+ definition: IntegrationEventDefinition<T>,
66
+ pluginMetadata: PluginMetadata
67
+ ): void;
68
+ }
69
+
70
+ export const integrationEventExtensionPoint =
71
+ createExtensionPoint<IntegrationEventExtensionPoint>(
72
+ "integration.eventExtensionPoint"
73
+ );
74
+
75
+ /**
76
+ * Extension point for registering integration providers.
77
+ * Plugins use this to register webhook delivery providers.
78
+ */
79
+ export interface IntegrationProviderExtensionPoint {
80
+ /**
81
+ * Register an integration provider.
82
+ * The provider will be namespaced by the plugin's ID automatically.
83
+ * @template TConfig - Per-subscription configuration type
84
+ * @template TConnection - Site-wide connection configuration type (optional)
85
+ */
86
+ addProvider<TConfig, TConnection = undefined>(
87
+ provider: IntegrationProvider<TConfig, TConnection>,
88
+ pluginMetadata: PluginMetadata
89
+ ): void;
90
+ }
91
+
92
+ export const integrationProviderExtensionPoint =
93
+ createExtensionPoint<IntegrationProviderExtensionPoint>(
94
+ "integration.providerExtensionPoint"
95
+ );
96
+
97
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
98
+ // Plugin Definition
99
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
100
+
101
+ export default createBackendPlugin({
102
+ metadata: pluginMetadata,
103
+
104
+ register(env) {
105
+ // Create the registries
106
+ const eventRegistry = createIntegrationEventRegistry();
107
+ const providerRegistry = createIntegrationProviderRegistry();
108
+
109
+ // Register static permissions
110
+ env.registerPermissions(permissionList);
111
+
112
+ // Register the event extension point
113
+ env.registerExtensionPoint(integrationEventExtensionPoint, {
114
+ registerEvent: <T>(
115
+ definition: IntegrationEventDefinition<T>,
116
+ metadata: PluginMetadata
117
+ ) => {
118
+ // Type erasure: cast to unknown for storage (the registry handles this internally)
119
+ eventRegistry.register(
120
+ definition as IntegrationEventDefinition<unknown>,
121
+ metadata
122
+ );
123
+ },
124
+ });
125
+
126
+ // Register the provider extension point
127
+ env.registerExtensionPoint(integrationProviderExtensionPoint, {
128
+ addProvider: (provider, metadata) => {
129
+ // Type erasure: cast to unknown for storage (the registry handles this internally)
130
+ providerRegistry.register(
131
+ provider as IntegrationProvider<unknown>,
132
+ metadata
133
+ );
134
+ },
135
+ });
136
+
137
+ env.registerInit({
138
+ schema,
139
+ deps: {
140
+ logger: coreServices.logger,
141
+ rpc: coreServices.rpc,
142
+ config: coreServices.config,
143
+ signalService: coreServices.signalService,
144
+ queueManager: coreServices.queueManager,
145
+ },
146
+ init: async ({
147
+ logger,
148
+ database,
149
+ rpc,
150
+ config,
151
+ signalService,
152
+ queueManager,
153
+ }) => {
154
+ logger.debug("🔌 Initializing Integration Backend...");
155
+
156
+ const db = database;
157
+
158
+ // Create connection store for generic connection management
159
+ const connectionStore = createConnectionStore({
160
+ configService: config,
161
+ providerRegistry,
162
+ logger,
163
+ });
164
+
165
+ // Publish connection store for provider plugins to inject
166
+ env.registerService(connectionStoreRef, connectionStore);
167
+
168
+ // Create delivery coordinator
169
+ const deliveryCoordinator = createDeliveryCoordinator({
170
+ db,
171
+ providerRegistry,
172
+ connectionStore,
173
+ queueManager,
174
+ signalService,
175
+ logger,
176
+ });
177
+
178
+ // Store for afterPluginsReady access
179
+ (
180
+ env as unknown as {
181
+ eventRegistry: IntegrationEventRegistry;
182
+ providerRegistry: IntegrationProviderRegistry;
183
+ deliveryCoordinator: typeof deliveryCoordinator;
184
+ }
185
+ ).eventRegistry = eventRegistry;
186
+ (
187
+ env as unknown as {
188
+ eventRegistry: IntegrationEventRegistry;
189
+ providerRegistry: IntegrationProviderRegistry;
190
+ deliveryCoordinator: typeof deliveryCoordinator;
191
+ }
192
+ ).providerRegistry = providerRegistry;
193
+ (
194
+ env as unknown as {
195
+ eventRegistry: IntegrationEventRegistry;
196
+ providerRegistry: IntegrationProviderRegistry;
197
+ deliveryCoordinator: typeof deliveryCoordinator;
198
+ }
199
+ ).deliveryCoordinator = deliveryCoordinator;
200
+
201
+ // Create and register the router
202
+ const router = createIntegrationRouter({
203
+ db,
204
+ eventRegistry,
205
+ providerRegistry,
206
+ deliveryCoordinator,
207
+ connectionStore,
208
+ signalService,
209
+ logger,
210
+ });
211
+ rpc.registerRouter(router, integrationContract);
212
+
213
+ // Register command palette commands
214
+ registerSearchProvider({
215
+ pluginMetadata,
216
+ commands: [
217
+ {
218
+ id: "create",
219
+ title: "Create Integration Subscription",
220
+ subtitle: "Create a new subscription for integration events",
221
+ iconName: "Webhook",
222
+ route:
223
+ resolveRoute(integrationRoutes.routes.list) + "?action=create",
224
+ requiredPermissions: [permissions.integrationManage],
225
+ },
226
+ {
227
+ id: "manage",
228
+ title: "Manage Integrations",
229
+ subtitle: "Manage integration subscriptions and connections",
230
+ iconName: "Webhook",
231
+ shortcuts: ["meta+shift+g", "ctrl+shift+g"],
232
+ route: resolveRoute(integrationRoutes.routes.list),
233
+ requiredPermissions: [permissions.integrationManage],
234
+ },
235
+ {
236
+ id: "logs",
237
+ title: "View Integration Logs",
238
+ subtitle: "View integration delivery logs",
239
+ iconName: "FileText",
240
+ route: resolveRoute(integrationRoutes.routes.logs),
241
+ requiredPermissions: [permissions.integrationManage],
242
+ },
243
+ ],
244
+ });
245
+
246
+ logger.debug("✅ Integration Backend initialized.");
247
+ },
248
+ afterPluginsReady: async ({ logger, onHook }) => {
249
+ // Get registries from env
250
+ const stored = env as unknown as {
251
+ eventRegistry: IntegrationEventRegistry;
252
+ providerRegistry: IntegrationProviderRegistry;
253
+ deliveryCoordinator: ReturnType<typeof createDeliveryCoordinator>;
254
+ };
255
+
256
+ const events = stored.eventRegistry.getEvents();
257
+ const providers = stored.providerRegistry.getProviders();
258
+
259
+ logger.debug(
260
+ `🔌 Registered ${events.length} integration events: ${events
261
+ .map((e) => e.eventId)
262
+ .join(", ")}`
263
+ );
264
+
265
+ logger.debug(
266
+ `📡 Registered ${providers.length} integration providers: ${providers
267
+ .map((p) => p.qualifiedId)
268
+ .join(", ")}`
269
+ );
270
+
271
+ // Subscribe to all registered integration events
272
+ // Uses work-queue mode to ensure only ONE instance processes each event
273
+ subscribeToRegisteredEvents({
274
+ onHook,
275
+ eventRegistry: stored.eventRegistry,
276
+ deliveryCoordinator: stored.deliveryCoordinator,
277
+ logger,
278
+ });
279
+
280
+ // Start the delivery worker
281
+ await stored.deliveryCoordinator.startWorker();
282
+
283
+ logger.debug("✅ Integration Backend afterPluginsReady complete.");
284
+ },
285
+ });
286
+ },
287
+ });
288
+
289
+ // Re-export extension points for consumer plugins
290
+ export { integrationEventExtensionPoint as eventExtensionPoint };
291
+ export { integrationProviderExtensionPoint as providerExtensionPoint };
292
+
293
+ // Re-export provider types for consumer plugins
294
+ // All backend-only types are defined here (not in integration-common)
295
+ export type {
296
+ IntegrationEventDefinition,
297
+ IntegrationDeliveryContext,
298
+ IntegrationDeliveryResult,
299
+ TestConnectionResult,
300
+ ProviderDocumentation,
301
+ ConnectionOption,
302
+ GetConnectionOptionsParams,
303
+ IntegrationProvider,
304
+ RegisteredIntegrationProvider,
305
+ RegisteredIntegrationEvent,
306
+ } from "./provider-types";
@@ -0,0 +1,314 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import {
4
+ createIntegrationProviderRegistry,
5
+ type IntegrationProviderRegistry,
6
+ } from "./provider-registry";
7
+ import type { IntegrationProvider } from "./provider-types";
8
+ import { Versioned } from "@checkstack/backend-api";
9
+
10
+ /**
11
+ * Unit tests for IntegrationProviderRegistry.
12
+ *
13
+ * Tests cover:
14
+ * - Provider registration with proper namespacing
15
+ * - Provider retrieval by qualified ID
16
+ * - Config schema JSON conversion
17
+ */
18
+
19
+ // Test plugin metadata
20
+ const testPluginMetadata = {
21
+ pluginId: "test-plugin",
22
+ displayName: "Test Plugin",
23
+ } as const;
24
+
25
+ // Test config schemas
26
+ const webhookConfigSchema = z.object({
27
+ url: z.string().url(),
28
+ method: z.enum(["GET", "POST"]),
29
+ timeout: z.number().default(5000),
30
+ });
31
+
32
+ const slackConfigSchema = z.object({
33
+ webhookUrl: z.string().url(),
34
+ channel: z.string(),
35
+ });
36
+
37
+ // Create test providers
38
+ function createTestProvider(
39
+ id: string,
40
+ schema: z.ZodType<unknown>
41
+ ): IntegrationProvider<unknown> {
42
+ return {
43
+ id,
44
+ displayName: `${id.charAt(0).toUpperCase()}${id.slice(1)} Provider`,
45
+ description: `Deliver events via ${id}`,
46
+ icon: "Webhook",
47
+ config: new Versioned({
48
+ version: 1,
49
+ schema,
50
+ }),
51
+ deliver: async () => ({ success: true }),
52
+ };
53
+ }
54
+
55
+ describe("IntegrationProviderRegistry", () => {
56
+ let registry: IntegrationProviderRegistry;
57
+
58
+ beforeEach(() => {
59
+ registry = createIntegrationProviderRegistry();
60
+ });
61
+
62
+ // ─────────────────────────────────────────────────────────────────────────
63
+ // Provider Registration
64
+ // ─────────────────────────────────────────────────────────────────────────
65
+
66
+ describe("register", () => {
67
+ it("registers a provider with a fully qualified ID", () => {
68
+ const provider = createTestProvider("webhook", webhookConfigSchema);
69
+
70
+ registry.register(provider, testPluginMetadata);
71
+
72
+ expect(registry.hasProvider("test-plugin.webhook")).toBe(true);
73
+ });
74
+
75
+ it("generates correct qualified ID", () => {
76
+ const provider = createTestProvider("webhook", webhookConfigSchema);
77
+
78
+ registry.register(provider, testPluginMetadata);
79
+
80
+ const registered = registry.getProvider("test-plugin.webhook");
81
+ expect(registered?.qualifiedId).toBe("test-plugin.webhook");
82
+ expect(registered?.ownerPluginId).toBe("test-plugin");
83
+ });
84
+
85
+ it("preserves provider metadata", () => {
86
+ const provider: IntegrationProvider<unknown> = {
87
+ id: "custom",
88
+ displayName: "Custom Provider",
89
+ description: "A custom provider for testing",
90
+ icon: "Cog",
91
+ config: new Versioned({ version: 1, schema: webhookConfigSchema }),
92
+ deliver: async () => ({ success: true }),
93
+ };
94
+
95
+ registry.register(provider, testPluginMetadata);
96
+
97
+ const registered = registry.getProvider("test-plugin.custom");
98
+ expect(registered?.displayName).toBe("Custom Provider");
99
+ expect(registered?.description).toBe("A custom provider for testing");
100
+ expect(registered?.icon).toBe("Cog");
101
+ });
102
+
103
+ it("preserves deliver function", () => {
104
+ const deliverFn = async () => ({ success: true, externalId: "ext-123" });
105
+ const provider: IntegrationProvider<unknown> = {
106
+ id: "webhook",
107
+ displayName: "Webhook",
108
+ config: new Versioned({ version: 1, schema: webhookConfigSchema }),
109
+ deliver: deliverFn,
110
+ };
111
+
112
+ registry.register(provider, testPluginMetadata);
113
+
114
+ const registered = registry.getProvider("test-plugin.webhook");
115
+ expect(registered?.deliver).toBeDefined();
116
+ });
117
+
118
+ it("preserves testConnection function if provided", () => {
119
+ const provider: IntegrationProvider<unknown> = {
120
+ id: "webhook",
121
+ displayName: "Webhook",
122
+ config: new Versioned({ version: 1, schema: webhookConfigSchema }),
123
+ deliver: async () => ({ success: true }),
124
+ testConnection: async () => ({ success: true, message: "OK" }),
125
+ };
126
+
127
+ registry.register(provider, testPluginMetadata);
128
+
129
+ const registered = registry.getProvider("test-plugin.webhook");
130
+ expect(registered?.testConnection).toBeDefined();
131
+ });
132
+ });
133
+
134
+ // ─────────────────────────────────────────────────────────────────────────
135
+ // Provider Retrieval
136
+ // ─────────────────────────────────────────────────────────────────────────
137
+
138
+ describe("getProviders", () => {
139
+ it("returns empty array when no providers registered", () => {
140
+ expect(registry.getProviders()).toEqual([]);
141
+ });
142
+
143
+ it("returns all registered providers", () => {
144
+ registry.register(
145
+ createTestProvider("webhook", webhookConfigSchema),
146
+ testPluginMetadata
147
+ );
148
+ registry.register(
149
+ createTestProvider("slack", slackConfigSchema),
150
+ testPluginMetadata
151
+ );
152
+
153
+ const providers = registry.getProviders();
154
+ expect(providers.length).toBe(2);
155
+ expect(providers.map((p) => p.id).sort()).toEqual(["slack", "webhook"]);
156
+ });
157
+ });
158
+
159
+ describe("getProvider", () => {
160
+ it("returns undefined for non-existent provider", () => {
161
+ expect(registry.getProvider("non-existent.provider")).toBeUndefined();
162
+ });
163
+
164
+ it("returns provider by qualified ID", () => {
165
+ registry.register(
166
+ createTestProvider("webhook", webhookConfigSchema),
167
+ testPluginMetadata
168
+ );
169
+
170
+ const provider = registry.getProvider("test-plugin.webhook");
171
+ expect(provider?.displayName).toBe("Webhook Provider");
172
+ });
173
+ });
174
+
175
+ describe("hasProvider", () => {
176
+ it("returns false for non-existent provider", () => {
177
+ expect(registry.hasProvider("non-existent.provider")).toBe(false);
178
+ });
179
+
180
+ it("returns true for registered provider", () => {
181
+ registry.register(
182
+ createTestProvider("webhook", webhookConfigSchema),
183
+ testPluginMetadata
184
+ );
185
+
186
+ expect(registry.hasProvider("test-plugin.webhook")).toBe(true);
187
+ });
188
+ });
189
+
190
+ // ─────────────────────────────────────────────────────────────────────────
191
+ // Config Schema
192
+ // ─────────────────────────────────────────────────────────────────────────
193
+
194
+ describe("getProviderConfigSchema", () => {
195
+ it("returns undefined for non-existent provider", () => {
196
+ expect(
197
+ registry.getProviderConfigSchema("non-existent.provider")
198
+ ).toBeUndefined();
199
+ });
200
+
201
+ it("returns JSON Schema for provider config", () => {
202
+ registry.register(
203
+ createTestProvider("webhook", webhookConfigSchema),
204
+ testPluginMetadata
205
+ );
206
+
207
+ const schema = registry.getProviderConfigSchema("test-plugin.webhook");
208
+ expect(schema).toBeDefined();
209
+ expect(typeof schema).toBe("object");
210
+ expect(schema?.type).toBe("object");
211
+ });
212
+
213
+ it("JSON Schema includes property definitions", () => {
214
+ registry.register(
215
+ createTestProvider("webhook", webhookConfigSchema),
216
+ testPluginMetadata
217
+ );
218
+
219
+ const schema = registry.getProviderConfigSchema("test-plugin.webhook");
220
+ const properties = schema?.properties as Record<string, unknown>;
221
+
222
+ expect(properties).toBeDefined();
223
+ expect(properties.url).toBeDefined();
224
+ expect(properties.method).toBeDefined();
225
+ expect(properties.timeout).toBeDefined();
226
+ });
227
+ });
228
+
229
+ // ─────────────────────────────────────────────────────────────────────────
230
+ // Multi-Plugin Registration
231
+ // ─────────────────────────────────────────────────────────────────────────
232
+
233
+ describe("multi-plugin registration", () => {
234
+ it("handles providers from multiple plugins", () => {
235
+ const plugin1 = { pluginId: "plugin-1" } as const;
236
+ const plugin2 = { pluginId: "plugin-2" } as const;
237
+
238
+ registry.register(
239
+ createTestProvider("webhook", webhookConfigSchema),
240
+ plugin1
241
+ );
242
+ registry.register(
243
+ createTestProvider("webhook", webhookConfigSchema),
244
+ plugin2
245
+ );
246
+
247
+ expect(registry.hasProvider("plugin-1.webhook")).toBe(true);
248
+ expect(registry.hasProvider("plugin-2.webhook")).toBe(true);
249
+
250
+ const providers = registry.getProviders();
251
+ expect(providers.length).toBe(2);
252
+ });
253
+
254
+ it("correctly namespaces providers by plugin", () => {
255
+ const plugin1 = { pluginId: "integration-webhook" } as const;
256
+ const plugin2 = { pluginId: "integration-slack" } as const;
257
+
258
+ registry.register(
259
+ {
260
+ ...createTestProvider("default", webhookConfigSchema),
261
+ displayName: "Webhook",
262
+ },
263
+ plugin1
264
+ );
265
+ registry.register(
266
+ {
267
+ ...createTestProvider("default", slackConfigSchema),
268
+ displayName: "Slack",
269
+ },
270
+ plugin2
271
+ );
272
+
273
+ expect(
274
+ registry.getProvider("integration-webhook.default")?.displayName
275
+ ).toBe("Webhook");
276
+ expect(
277
+ registry.getProvider("integration-slack.default")?.displayName
278
+ ).toBe("Slack");
279
+ });
280
+ });
281
+
282
+ // ─────────────────────────────────────────────────────────────────────────
283
+ // Supported Events
284
+ // ─────────────────────────────────────────────────────────────────────────
285
+
286
+ describe("supportedEvents", () => {
287
+ it("preserves supportedEvents array", () => {
288
+ const provider: IntegrationProvider<unknown> = {
289
+ id: "limited",
290
+ displayName: "Limited Provider",
291
+ config: new Versioned({ version: 1, schema: webhookConfigSchema }),
292
+ supportedEvents: ["incident.created", "incident.resolved"],
293
+ deliver: async () => ({ success: true }),
294
+ };
295
+
296
+ registry.register(provider, testPluginMetadata);
297
+
298
+ const registered = registry.getProvider("test-plugin.limited");
299
+ expect(registered?.supportedEvents).toEqual([
300
+ "incident.created",
301
+ "incident.resolved",
302
+ ]);
303
+ });
304
+
305
+ it("handles provider with no supportedEvents (accepts all)", () => {
306
+ const provider = createTestProvider("webhook", webhookConfigSchema);
307
+
308
+ registry.register(provider, testPluginMetadata);
309
+
310
+ const registered = registry.getProvider("test-plugin.webhook");
311
+ expect(registered?.supportedEvents).toBeUndefined();
312
+ });
313
+ });
314
+ });