@checkstack/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.
Files changed (46) hide show
  1. package/CHANGELOG.md +225 -0
  2. package/drizzle/0000_loose_yellow_claw.sql +28 -0
  3. package/drizzle/meta/0000_snapshot.json +187 -0
  4. package/drizzle/meta/_journal.json +13 -0
  5. package/drizzle.config.ts +10 -0
  6. package/package.json +42 -0
  7. package/src/db.ts +20 -0
  8. package/src/health-check-plugin-integration.test.ts +93 -0
  9. package/src/index.ts +419 -0
  10. package/src/integration/event-bus.integration.test.ts +313 -0
  11. package/src/logger.ts +65 -0
  12. package/src/openapi-router.ts +177 -0
  13. package/src/plugin-lifecycle.test.ts +276 -0
  14. package/src/plugin-manager/api-router.ts +163 -0
  15. package/src/plugin-manager/core-services.ts +312 -0
  16. package/src/plugin-manager/dependency-sorter.ts +103 -0
  17. package/src/plugin-manager/deregistration-guard.ts +41 -0
  18. package/src/plugin-manager/extension-points.ts +85 -0
  19. package/src/plugin-manager/index.ts +13 -0
  20. package/src/plugin-manager/plugin-admin-router.ts +89 -0
  21. package/src/plugin-manager/plugin-loader.ts +464 -0
  22. package/src/plugin-manager/types.ts +14 -0
  23. package/src/plugin-manager.test.ts +464 -0
  24. package/src/plugin-manager.ts +431 -0
  25. package/src/rpc-rest-compat.test.ts +80 -0
  26. package/src/schema.ts +46 -0
  27. package/src/services/config-service.test.ts +66 -0
  28. package/src/services/config-service.ts +322 -0
  29. package/src/services/event-bus.test.ts +469 -0
  30. package/src/services/event-bus.ts +317 -0
  31. package/src/services/health-check-registry.test.ts +101 -0
  32. package/src/services/health-check-registry.ts +27 -0
  33. package/src/services/jwt.ts +45 -0
  34. package/src/services/keystore.test.ts +198 -0
  35. package/src/services/keystore.ts +136 -0
  36. package/src/services/plugin-installer.test.ts +90 -0
  37. package/src/services/plugin-installer.ts +70 -0
  38. package/src/services/queue-manager.ts +382 -0
  39. package/src/services/queue-plugin-registry.ts +17 -0
  40. package/src/services/queue-proxy.ts +182 -0
  41. package/src/services/service-registry.ts +35 -0
  42. package/src/test-preload.ts +114 -0
  43. package/src/utils/plugin-discovery.test.ts +383 -0
  44. package/src/utils/plugin-discovery.ts +157 -0
  45. package/src/utils/strip-public-schema.ts +40 -0
  46. package/tsconfig.json +6 -0
@@ -0,0 +1,313 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { EventBus } from "../services/event-bus";
3
+ import { createHook } from "@checkstack/backend-api";
4
+ import {
5
+ createMockLogger,
6
+ createMockQueueManager,
7
+ } from "@checkstack/test-utils-backend";
8
+ import type { Logger } from "@checkstack/backend-api";
9
+ import type { QueueManager } from "@checkstack/queue-api";
10
+
11
+ describe("EventBus Integration Tests", () => {
12
+ let eventBus: EventBus;
13
+ let mockQueueManager: QueueManager;
14
+ let mockLogger: Logger;
15
+
16
+ beforeEach(() => {
17
+ mockQueueManager = createMockQueueManager();
18
+ mockLogger = createMockLogger();
19
+ eventBus = new EventBus(mockQueueManager, mockLogger);
20
+ });
21
+
22
+ describe("Permission Sync Scenario", () => {
23
+ it("should sync permissions across plugins using work-queue mode", async () => {
24
+ // Simulate the permissionsRegistered hook
25
+ const permissionsRegistered = createHook<{
26
+ pluginId: string;
27
+ permissions: Array<{ id: string; description?: string }>;
28
+ }>("core.permissionsRegistered");
29
+
30
+ const syncedPermissions: Array<{ id: string; description?: string }> = [];
31
+
32
+ // Auth-backend subscribes to sync permissions (work-queue mode)
33
+ await eventBus.subscribe(
34
+ "auth-backend",
35
+ permissionsRegistered,
36
+ async ({ permissions }) => {
37
+ // Simulate DB sync
38
+ syncedPermissions.push(...permissions);
39
+ },
40
+ {
41
+ mode: "work-queue",
42
+ workerGroup: "permission-db-sync",
43
+ maxRetries: 3,
44
+ }
45
+ );
46
+
47
+ // Emit permission registration events from different plugins
48
+ await eventBus.emit(permissionsRegistered, {
49
+ pluginId: "catalog",
50
+ permissions: [
51
+ { id: "catalog-backend.read", description: "Read catalog" },
52
+ { id: "catalog-backend.manage", description: "Manage catalog" },
53
+ ],
54
+ });
55
+
56
+ await eventBus.emit(permissionsRegistered, {
57
+ pluginId: "queue",
58
+ permissions: [
59
+ { id: "queue-backend.read", description: "Read queue" },
60
+ { id: "queue-backend.manage", description: "Manage queue" },
61
+ ],
62
+ });
63
+
64
+ // Wait for async processing
65
+ await new Promise((resolve) => setTimeout(resolve, 100));
66
+
67
+ // All permissions should be synced
68
+ expect(syncedPermissions.length).toBe(4);
69
+ expect(syncedPermissions.map((p) => p.id)).toContain(
70
+ "catalog-backend.read"
71
+ );
72
+ expect(syncedPermissions.map((p) => p.id)).toContain(
73
+ "catalog-backend.manage"
74
+ );
75
+ expect(syncedPermissions.map((p) => p.id)).toContain(
76
+ "queue-backend.read"
77
+ );
78
+ expect(syncedPermissions.map((p) => p.id)).toContain(
79
+ "queue-backend.manage"
80
+ );
81
+ });
82
+
83
+ it("should distribute jobs across instances in work-queue mode", async () => {
84
+ const testHook = createHook<{ data: string }>("test.hook");
85
+
86
+ let instance1Count = 0;
87
+ let instance2Count = 0;
88
+
89
+ // Simulate two different backend instances (different plugin IDs)
90
+ // Both use same workerGroup name but get namespaced differently
91
+ await eventBus.subscribe(
92
+ "plugin-instance-1",
93
+ testHook,
94
+ async () => {
95
+ instance1Count++;
96
+ },
97
+ {
98
+ mode: "work-queue",
99
+ workerGroup: "sync",
100
+ }
101
+ );
102
+
103
+ await eventBus.subscribe(
104
+ "plugin-instance-2",
105
+ testHook,
106
+ async () => {
107
+ instance2Count++;
108
+ },
109
+ {
110
+ mode: "work-queue",
111
+ workerGroup: "sync",
112
+ }
113
+ );
114
+
115
+ // Emit multiple events
116
+ await eventBus.emit(testHook, { data: "test1" });
117
+ await eventBus.emit(testHook, { data: "test2" });
118
+ await eventBus.emit(testHook, { data: "test3" });
119
+
120
+ await new Promise((resolve) => setTimeout(resolve, 100));
121
+
122
+ // Both instances should process jobs (different namespaces due to different plugin IDs)
123
+ const total = instance1Count + instance2Count;
124
+ expect(total).toBe(6); // Each instance gets all 3 jobs (different namespaces)
125
+
126
+ // Each instance should have processed the jobs
127
+ expect(instance1Count).toBe(3);
128
+ expect(instance2Count).toBe(3);
129
+ });
130
+ });
131
+
132
+ describe("Broadcast Scenario", () => {
133
+ it("should notify all plugin instances in broadcast mode", async () => {
134
+ const configUpdated = createHook<{ key: string; value: string }>(
135
+ "core.configUpdated"
136
+ );
137
+
138
+ const plugin1Notifications: string[] = [];
139
+ const plugin2Notifications: string[] = [];
140
+
141
+ // Multiple plugins subscribe to config updates
142
+ await eventBus.subscribe("plugin-1", configUpdated, async ({ key }) => {
143
+ plugin1Notifications.push(key);
144
+ });
145
+
146
+ await eventBus.subscribe("plugin-2", configUpdated, async ({ key }) => {
147
+ plugin2Notifications.push(key);
148
+ });
149
+
150
+ // Emit config update
151
+ await eventBus.emit(configUpdated, {
152
+ key: "database.url",
153
+ value: "postgresql://localhost",
154
+ });
155
+
156
+ await new Promise((resolve) => setTimeout(resolve, 50));
157
+
158
+ // Both plugins should receive the notification
159
+ expect(plugin1Notifications).toContain("database.url");
160
+ expect(plugin2Notifications).toContain("database.url");
161
+ });
162
+ });
163
+
164
+ describe("Mixed Mode Scenario", () => {
165
+ it("should handle both broadcast and work-queue for same hook", async () => {
166
+ const dataProcessed = createHook<{ id: string }>("data.processed");
167
+
168
+ const broadcastNotifications: string[] = [];
169
+ const workQueueProcessed: string[] = [];
170
+
171
+ // Broadcast subscriber (logging/monitoring)
172
+ await eventBus.subscribe(
173
+ "logger-plugin",
174
+ dataProcessed,
175
+ async ({ id }) => {
176
+ broadcastNotifications.push(`logged-${id}`);
177
+ }
178
+ );
179
+
180
+ // Work-queue subscribers (actual processing - only one should handle)
181
+ await eventBus.subscribe(
182
+ "processor-plugin",
183
+ dataProcessed,
184
+ async ({ id }) => {
185
+ workQueueProcessed.push(`processed-${id}`);
186
+ },
187
+ {
188
+ mode: "work-queue",
189
+ workerGroup: "processor",
190
+ }
191
+ );
192
+
193
+ // Different work-queue subscriber with different group
194
+ await eventBus.subscribe(
195
+ "archiver-plugin",
196
+ dataProcessed,
197
+ async ({ id }) => {
198
+ workQueueProcessed.push(`archived-${id}`);
199
+ },
200
+ {
201
+ mode: "work-queue",
202
+ workerGroup: "archiver",
203
+ }
204
+ );
205
+
206
+ // Emit event
207
+ await eventBus.emit(dataProcessed, { id: "data-123" });
208
+
209
+ await new Promise((resolve) => setTimeout(resolve, 100));
210
+
211
+ // Broadcast should always receive
212
+ expect(broadcastNotifications).toContain("logged-data-123");
213
+
214
+ // Work-queue should process (both groups should handle it)
215
+ expect(workQueueProcessed).toContain("processed-data-123");
216
+ expect(workQueueProcessed).toContain("archived-data-123");
217
+ expect(workQueueProcessed.length).toBe(2);
218
+ });
219
+ });
220
+
221
+ describe("Error Resilience", () => {
222
+ it("should continue processing other listeners if one fails", async () => {
223
+ const testHook = createHook<{ value: number }>("test.hook");
224
+
225
+ const successful: number[] = [];
226
+
227
+ // First listener fails
228
+ await eventBus.subscribe("plugin-1", testHook, async () => {
229
+ throw new Error("Simulated failure");
230
+ });
231
+
232
+ // Second listener succeeds
233
+ await eventBus.subscribe("plugin-2", testHook, async ({ value }) => {
234
+ successful.push(value);
235
+ });
236
+
237
+ // Third listener succeeds
238
+ await eventBus.subscribe("plugin-3", testHook, async ({ value }) => {
239
+ successful.push(value * 2);
240
+ });
241
+
242
+ await eventBus.emit(testHook, { value: 10 });
243
+
244
+ await new Promise((resolve) => setTimeout(resolve, 100));
245
+
246
+ // Despite first listener failing, others should succeed
247
+ expect(successful).toContain(10);
248
+ expect(successful).toContain(20);
249
+ expect(mockLogger.error).toHaveBeenCalled();
250
+ });
251
+ });
252
+
253
+ describe("Lifecycle", () => {
254
+ it("should properly clean up on shutdown", async () => {
255
+ const hook1 = createHook<{ test: string }>("hook1");
256
+ const hook2 = createHook<{ test: string }>("hook2");
257
+
258
+ await eventBus.subscribe("test-plugin", hook1, async () => {});
259
+ await eventBus.subscribe("test-plugin", hook2, async () => {});
260
+
261
+ await eventBus.shutdown();
262
+
263
+ // Should log shutdown
264
+ expect(mockLogger.info).toHaveBeenCalledWith("EventBus shut down");
265
+ });
266
+
267
+ it("should allow unsubscribe and re-subscribe with same workerGroup", async () => {
268
+ const testHook = createHook<{ value: number }>("test.hook");
269
+ const values: number[] = [];
270
+
271
+ // Subscribe
272
+ const unsub = await eventBus.subscribe(
273
+ "test-plugin",
274
+ testHook,
275
+ async ({ value }) => {
276
+ values.push(value);
277
+ },
278
+ {
279
+ mode: "work-queue",
280
+ workerGroup: "processor",
281
+ }
282
+ );
283
+
284
+ await eventBus.emit(testHook, { value: 1 });
285
+ await new Promise((resolve) => setTimeout(resolve, 50));
286
+
287
+ expect(values).toContain(1);
288
+
289
+ // Unsubscribe
290
+ await unsub();
291
+
292
+ // Re-subscribe with same workerGroup (should work)
293
+ await eventBus.subscribe(
294
+ "test-plugin",
295
+ testHook,
296
+ async ({ value }) => {
297
+ values.push(value * 10);
298
+ },
299
+ {
300
+ mode: "work-queue",
301
+ workerGroup: "processor", // Same name OK after unsubscribe
302
+ }
303
+ );
304
+
305
+ await eventBus.emit(testHook, { value: 2 });
306
+ await new Promise((resolve) => setTimeout(resolve, 50));
307
+
308
+ // First value from first subscription, second value from second subscription
309
+ expect(values).toContain(1);
310
+ expect(values).toContain(20); // 2 * 10
311
+ });
312
+ });
313
+ });
package/src/logger.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { createLogger, format, transports } from "winston";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+
5
+ const { combine, timestamp, printf, colorize, json } = format;
6
+
7
+ const devFormat = printf(({ level, message, timestamp, ...meta }) => {
8
+ const plugin = meta.plugin ? `[${meta.plugin}] ` : "";
9
+ // Stringify rest of meta if it exists and isn't just plugin
10
+ const { plugin: _p, ...rest } = meta;
11
+ const metaStr = Object.keys(rest).length > 0 ? JSON.stringify(rest) : "";
12
+
13
+ return `${timestamp} ${level}: ${plugin}${message} ${metaStr}`;
14
+ });
15
+
16
+ // Plain text format for file logging (without colors)
17
+ const fileFormat = printf(({ level, message, timestamp, ...meta }) => {
18
+ const plugin = meta.plugin ? `[${meta.plugin}] ` : "";
19
+ const { plugin: _p, ...rest } = meta;
20
+ const metaStr = Object.keys(rest).length > 0 ? JSON.stringify(rest) : "";
21
+
22
+ return `${timestamp} ${level}: ${plugin}${message} ${metaStr}`;
23
+ });
24
+
25
+ // Setup file transports for development
26
+ const developmentTransports: transports.StreamTransportInstance[] = [
27
+ new transports.Console(),
28
+ ];
29
+
30
+ if (process.env.NODE_ENV !== "production") {
31
+ // Create logs directory if it doesn't exist
32
+ const logsDir = path.join(process.cwd(), ".dev", "logs");
33
+ if (!fs.existsSync(logsDir)) {
34
+ fs.mkdirSync(logsDir, { recursive: true });
35
+ }
36
+
37
+ // Add file transports
38
+ developmentTransports.push(
39
+ // Timestamped log file
40
+ new transports.File({
41
+ filename: path.join(
42
+ logsDir,
43
+ `backend-${
44
+ new Date().toISOString().replaceAll(":", "-").split(".")[0]
45
+ }.log`
46
+ ),
47
+ format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), fileFormat),
48
+ }),
49
+ // Latest log file (always overwritten)
50
+ new transports.File({
51
+ filename: path.join(logsDir, "latest.log"),
52
+ format: combine(timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), fileFormat),
53
+ options: { flags: "w" }, // Overwrite on each start
54
+ })
55
+ );
56
+ }
57
+
58
+ export const rootLogger = createLogger({
59
+ level: process.env.LOG_LEVEL || "info",
60
+ format:
61
+ process.env.NODE_ENV === "production"
62
+ ? json()
63
+ : combine(colorize(), timestamp({ format: "HH:mm:ss" }), devFormat),
64
+ transports: developmentTransports,
65
+ });
@@ -0,0 +1,177 @@
1
+ /**
2
+ * OpenAPI Router - Exposes OpenAPI specification for external applications.
3
+ *
4
+ * This router provides a `/api/openapi.json` endpoint that returns the
5
+ * aggregated OpenAPI specification for all endpoints accessible by
6
+ * external applications (userType: "authenticated" | "public").
7
+ */
8
+ import { OpenAPIGenerator } from "@orpc/openapi";
9
+ import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
10
+ import type { AnyContractRouter } from "@orpc/contract";
11
+ import type { PluginManager } from "./plugin-manager";
12
+ import type { AuthService } from "@checkstack/backend-api";
13
+
14
+ /**
15
+ * Check if a user has a specific permission.
16
+ * Supports wildcard (*) for admin access.
17
+ */
18
+ function hasPermission(
19
+ user: { permissions?: string[] },
20
+ permission: string
21
+ ): boolean {
22
+ if (!user.permissions) return false;
23
+ return (
24
+ user.permissions.includes("*") || user.permissions.includes(permission)
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Extract procedure metadata from a contract using oRPC internal structure.
30
+ */
31
+ function extractProcedureMetadata(
32
+ contract: unknown
33
+ ): { userType?: string; permissions?: string[] } | undefined {
34
+ const orpcData = (contract as Record<string, unknown>)?.["~orpc"] as
35
+ | { meta?: { userType?: string; permissions?: string[] } }
36
+ | undefined;
37
+ return orpcData?.meta;
38
+ }
39
+
40
+ /**
41
+ * Build a lookup map of operationId -> metadata from all contracts.
42
+ * operationId format: "pluginId.procedureName"
43
+ */
44
+ function buildMetadataLookup(
45
+ contracts: Map<string, AnyContractRouter>
46
+ ): Map<string, { userType?: string; permissions?: string[] }> {
47
+ const lookup = new Map<
48
+ string,
49
+ { userType?: string; permissions?: string[] }
50
+ >();
51
+
52
+ for (const [pluginId, contract] of contracts) {
53
+ // Contract is an object with procedure names as keys
54
+ for (const [procedureName, procedure] of Object.entries(
55
+ contract as Record<string, unknown>
56
+ )) {
57
+ const meta = extractProcedureMetadata(procedure);
58
+ if (meta) {
59
+ const operationId = `${pluginId}.${procedureName}`;
60
+ lookup.set(operationId, meta);
61
+ }
62
+ }
63
+ }
64
+
65
+ return lookup;
66
+ }
67
+
68
+ /**
69
+ * Generate OpenAPI specification from registered plugin contracts.
70
+ * Returns all endpoints with their userType metadata visible as x-orpc-meta.
71
+ */
72
+ export async function generateOpenApiSpec({
73
+ pluginManager,
74
+ baseUrl,
75
+ }: {
76
+ pluginManager: PluginManager;
77
+ baseUrl: string;
78
+ }): Promise<Record<string, unknown>> {
79
+ const contracts = pluginManager.getAllContracts();
80
+
81
+ // Build aggregated contract object: { pluginId: contract, ... }
82
+ const aggregatedContract: Record<string, AnyContractRouter> = {};
83
+ for (const [pluginId, contract] of contracts) {
84
+ aggregatedContract[pluginId] = contract;
85
+ }
86
+
87
+ // Build metadata lookup from contracts
88
+ const metadataLookup = buildMetadataLookup(contracts);
89
+
90
+ // Create OpenAPI generator with Zod v4 converter
91
+ const generator = new OpenAPIGenerator({
92
+ schemaConverters: [new ZodToJsonSchemaConverter()],
93
+ });
94
+
95
+ // Generate spec for all endpoints
96
+ const spec = (await generator.generate(aggregatedContract, {
97
+ info: {
98
+ title: "Checkstack API",
99
+ version: "1.0.0",
100
+ description: "API documentation for Checkstack platform endpoints.",
101
+ },
102
+ servers: [{ url: baseUrl }],
103
+ })) as {
104
+ paths?: Record<
105
+ string,
106
+ Record<string, { operationId?: string; "x-orpc-meta"?: unknown }>
107
+ >;
108
+ };
109
+
110
+ // Post-process: Add x-orpc-meta to each operation and prefix paths with /api
111
+ if (spec.paths) {
112
+ const prefixedPaths: typeof spec.paths = {};
113
+
114
+ for (const [path, methods] of Object.entries(spec.paths)) {
115
+ // Prefix path with /api
116
+ const prefixedPath = `/api${path.startsWith("/") ? path : `/${path}`}`;
117
+ prefixedPaths[prefixedPath] = methods;
118
+
119
+ // Add metadata to each operation
120
+ for (const operation of Object.values(methods)) {
121
+ if (operation.operationId) {
122
+ const meta = metadataLookup.get(operation.operationId);
123
+ if (meta) {
124
+ operation["x-orpc-meta"] = meta;
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ spec.paths = prefixedPaths;
131
+ }
132
+
133
+ return spec as Record<string, unknown>;
134
+ }
135
+
136
+ /**
137
+ * Create the OpenAPI endpoint handler for Hono.
138
+ * Returns a fetch handler that serves the OpenAPI spec.
139
+ */
140
+ export function createOpenApiHandler({
141
+ pluginManager,
142
+ authService,
143
+ baseUrl,
144
+ requiredPermission,
145
+ }: {
146
+ pluginManager: PluginManager;
147
+ authService: AuthService;
148
+ baseUrl: string;
149
+ requiredPermission: string;
150
+ }): (req: Request) => Promise<Response> {
151
+ return async (req: Request) => {
152
+ // Authenticate request
153
+ const user = await authService.authenticate(req);
154
+
155
+ if (!user) {
156
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
157
+ }
158
+
159
+ // Check permission (applications.manage from auth plugin)
160
+ // Services don't have permissions, so deny them access to docs
161
+ if (user.type === "service" || !hasPermission(user, requiredPermission)) {
162
+ return Response.json({ error: "Forbidden" }, { status: 403 });
163
+ }
164
+
165
+ try {
166
+ const spec = await generateOpenApiSpec({ pluginManager, baseUrl });
167
+
168
+ return Response.json(spec);
169
+ } catch (error) {
170
+ console.error("Failed to generate OpenAPI spec:", error);
171
+ return Response.json(
172
+ { error: "Failed to generate OpenAPI specification" },
173
+ { status: 500 }
174
+ );
175
+ }
176
+ };
177
+ }