@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,276 @@
1
+ import { describe, it, expect, mock, beforeEach, spyOn } from "bun:test";
2
+ import {
3
+ createMockEventBus,
4
+ createMockPluginInstaller,
5
+ createMockQueueManager,
6
+ type MockEventBus,
7
+ type MockPluginInstaller,
8
+ } from "@checkstack/test-utils-backend";
9
+ import { coreServices, coreHooks } from "@checkstack/backend-api";
10
+
11
+ // Note: ./db and ./logger are mocked via test-preload.ts (bunfig.toml preload)
12
+ // This ensures mocks are in place BEFORE any module imports them
13
+
14
+ import { PluginManager } from "./plugin-manager";
15
+
16
+ // ─────────────────────────────────────────────────────────────────────────────
17
+ // Tests
18
+ // ─────────────────────────────────────────────────────────────────────────────
19
+
20
+ describe("Plugin Lifecycle", () => {
21
+ let pluginManager: PluginManager;
22
+ let mockEventBus: MockEventBus;
23
+ let mockInstaller: MockPluginInstaller;
24
+
25
+ beforeEach(() => {
26
+ pluginManager = new PluginManager();
27
+ mockEventBus = createMockEventBus();
28
+ mockInstaller = createMockPluginInstaller();
29
+
30
+ // Access internal registry to override factory (factories take precedence)
31
+ const registry = (pluginManager as never)["registry"] as {
32
+ registerFactory: <T>(ref: { id: string }, factory: () => T) => void;
33
+ register: <T>(ref: { id: string }, impl: T) => void;
34
+ };
35
+
36
+ // Override EventBus factory with our mock
37
+ registry.registerFactory(
38
+ coreServices.eventBus,
39
+ () => mockEventBus as never
40
+ );
41
+
42
+ // Register services
43
+ registry.register(coreServices.pluginInstaller, mockInstaller as never);
44
+ registry.register(
45
+ coreServices.queueManager,
46
+ createMockQueueManager() as never
47
+ );
48
+ });
49
+
50
+ describe("requestInstallation", () => {
51
+ it("should emit pluginInstallationRequested broadcast", async () => {
52
+ await pluginManager.requestInstallation("test-plugin", "/path/to/plugin");
53
+
54
+ expect(mockEventBus._emittedEvents).toContainEqual({
55
+ hook: coreHooks.pluginInstallationRequested.id,
56
+ payload: { pluginId: "test-plugin", pluginPath: "/path/to/plugin" },
57
+ });
58
+ });
59
+ });
60
+
61
+ describe("requestDeregistration", () => {
62
+ it("should emit pluginDeregistrationRequested broadcast", async () => {
63
+ await pluginManager.requestDeregistration("test-plugin", {
64
+ deleteSchema: false,
65
+ });
66
+
67
+ expect(mockEventBus._emittedEvents).toContainEqual({
68
+ hook: coreHooks.pluginDeregistrationRequested.id,
69
+ payload: { pluginId: "test-plugin", deleteSchema: false },
70
+ });
71
+ });
72
+
73
+ it("should include deleteSchema flag in broadcast", async () => {
74
+ await pluginManager.requestDeregistration("test-plugin", {
75
+ deleteSchema: true,
76
+ });
77
+
78
+ expect(mockEventBus._emittedEvents).toContainEqual({
79
+ hook: coreHooks.pluginDeregistrationRequested.id,
80
+ payload: { pluginId: "test-plugin", deleteSchema: true },
81
+ });
82
+ });
83
+ });
84
+
85
+ describe("setupLifecycleListeners", () => {
86
+ it("should register listeners for installation broadcasts", async () => {
87
+ await pluginManager.setupLifecycleListeners();
88
+
89
+ // Spy on loadSinglePlugin and make it a no-op to avoid actual import
90
+ const loadSpy = spyOn(
91
+ pluginManager,
92
+ "loadSinglePlugin"
93
+ ).mockImplementation(async () => {});
94
+ await mockEventBus._triggerBroadcast(
95
+ coreHooks.pluginInstallationRequested,
96
+ {
97
+ pluginId: "broadcast-plugin",
98
+ pluginPath: "/broadcast/path",
99
+ }
100
+ );
101
+
102
+ expect(loadSpy).toHaveBeenCalledWith(
103
+ "broadcast-plugin",
104
+ "/broadcast/path"
105
+ );
106
+ });
107
+
108
+ it("should register listeners for deregistration broadcasts", async () => {
109
+ await pluginManager.setupLifecycleListeners();
110
+
111
+ // Trigger a broadcast and verify the listener responds
112
+ const deregSpy = spyOn(pluginManager, "deregisterPlugin");
113
+ await mockEventBus._triggerBroadcast(
114
+ coreHooks.pluginDeregistrationRequested,
115
+ {
116
+ pluginId: "broadcast-plugin",
117
+ deleteSchema: true,
118
+ }
119
+ );
120
+
121
+ expect(deregSpy).toHaveBeenCalledWith("broadcast-plugin", {
122
+ deleteSchema: true,
123
+ });
124
+ });
125
+ });
126
+
127
+ describe("loadSinglePlugin", () => {
128
+ it("should emit pluginInstalling local hook", async () => {
129
+ // Try loading - will fail import but should still emit installing hook
130
+ try {
131
+ await pluginManager.loadSinglePlugin(
132
+ "nonexistent-plugin",
133
+ "/nonexistent/path"
134
+ );
135
+ } catch {
136
+ // Expected to fail since plugin doesn't exist
137
+ }
138
+
139
+ // Should have emitted pluginInstalling locally
140
+ expect(mockEventBus._localEmittedEvents).toContainEqual({
141
+ hook: coreHooks.pluginInstalling.id,
142
+ payload: { pluginId: "nonexistent-plugin" },
143
+ });
144
+ });
145
+
146
+ it("should call installer if import fails", async () => {
147
+ try {
148
+ await pluginManager.loadSinglePlugin(
149
+ "test-remote-plugin",
150
+ "/nonexistent/path"
151
+ );
152
+ } catch {
153
+ // Expected to fail at the final import, but installer should be called
154
+ }
155
+
156
+ // Installer should have been called when imports failed
157
+ expect(mockInstaller._installCalls).toContain("test-remote-plugin");
158
+ });
159
+ });
160
+
161
+ describe("deregisterPlugin", () => {
162
+ beforeEach(() => {
163
+ // Setup cleanup handlers for a plugin
164
+ const cleanupHandlers = (pluginManager as never)[
165
+ "cleanupHandlers"
166
+ ] as Map<string, (() => Promise<void>)[]>;
167
+ cleanupHandlers.set("test-plugin", [async () => {}]);
168
+ });
169
+
170
+ it("should emit pluginDeregistering local hook", async () => {
171
+ await pluginManager.deregisterPlugin("test-plugin", {
172
+ deleteSchema: false,
173
+ });
174
+
175
+ const deregisteringEvent = mockEventBus._localEmittedEvents.find(
176
+ (e) => e.hook === coreHooks.pluginDeregistering.id
177
+ );
178
+ expect(deregisteringEvent).toBeDefined();
179
+ expect(deregisteringEvent?.payload).toMatchObject({
180
+ pluginId: "test-plugin",
181
+ });
182
+ });
183
+
184
+ it("should emit pluginDeregistered after cleanup", async () => {
185
+ await pluginManager.deregisterPlugin("test-plugin", {
186
+ deleteSchema: false,
187
+ });
188
+
189
+ const deregisteredEvent = mockEventBus._emittedEvents.find(
190
+ (e) => e.hook === coreHooks.pluginDeregistered.id
191
+ );
192
+ expect(deregisteredEvent).toBeDefined();
193
+ expect(deregisteredEvent?.payload).toMatchObject({
194
+ pluginId: "test-plugin",
195
+ });
196
+ });
197
+
198
+ it("should run cleanup handlers in reverse order", async () => {
199
+ const callOrder: number[] = [];
200
+ const cleanupHandlers = (pluginManager as never)[
201
+ "cleanupHandlers"
202
+ ] as Map<string, (() => Promise<void>)[]>;
203
+
204
+ cleanupHandlers.set("test-plugin", [
205
+ async () => {
206
+ callOrder.push(1);
207
+ },
208
+ async () => {
209
+ callOrder.push(2);
210
+ },
211
+ async () => {
212
+ callOrder.push(3);
213
+ },
214
+ ]);
215
+
216
+ await pluginManager.deregisterPlugin("test-plugin", {
217
+ deleteSchema: false,
218
+ });
219
+
220
+ expect(callOrder).toEqual([3, 2, 1]);
221
+ });
222
+
223
+ it("should remove plugin router", async () => {
224
+ const pluginRpcRouters = (pluginManager as never)[
225
+ "pluginRpcRouters"
226
+ ] as Map<string, unknown>;
227
+ pluginRpcRouters.set("test-plugin", { mockRouter: true });
228
+
229
+ await pluginManager.deregisterPlugin("test-plugin", {
230
+ deleteSchema: false,
231
+ });
232
+
233
+ expect(pluginRpcRouters.has("test-plugin")).toBe(false);
234
+ });
235
+
236
+ it("should clear permissions for plugin", async () => {
237
+ const registeredPermissions = (pluginManager as never)[
238
+ "registeredPermissions"
239
+ ] as { pluginId: string; id: string }[];
240
+
241
+ // Clear existing permissions first
242
+ while (registeredPermissions.length > 0) {
243
+ registeredPermissions.pop();
244
+ }
245
+
246
+ // Add test permissions
247
+ registeredPermissions.push(
248
+ { pluginId: "test-plugin", id: "test-plugin.perm1" },
249
+ { pluginId: "test-plugin", id: "test-plugin.perm2" },
250
+ { pluginId: "other-plugin", id: "other-plugin.perm1" }
251
+ );
252
+
253
+ await pluginManager.deregisterPlugin("test-plugin", {
254
+ deleteSchema: false,
255
+ });
256
+
257
+ // Use getAllPermissions() which returns the current array
258
+ const remaining = pluginManager.getAllPermissions();
259
+ expect(remaining).toHaveLength(1);
260
+ expect(remaining[0].id).toBe("other-plugin.perm1");
261
+ });
262
+ });
263
+
264
+ describe("registerCoreRouter", () => {
265
+ it("should add router to pluginRpcRouters", () => {
266
+ const mockRouter = { test: true };
267
+
268
+ pluginManager.registerCoreRouter("admin", mockRouter);
269
+
270
+ const pluginRpcRouters = (pluginManager as never)[
271
+ "pluginRpcRouters"
272
+ ] as Map<string, unknown>;
273
+ expect(pluginRpcRouters.get("admin")).toBe(mockRouter);
274
+ });
275
+ });
276
+ });
@@ -0,0 +1,163 @@
1
+ import type { Hono, Context } from "hono";
2
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import { RPCHandler } from "@orpc/server/fetch";
4
+ import {
5
+ coreServices,
6
+ AuthService,
7
+ RpcContext,
8
+ Logger,
9
+ Fetch,
10
+ HealthCheckRegistry,
11
+ type EmitHookFn,
12
+ type Hook,
13
+ } from "@checkstack/backend-api";
14
+ import type {
15
+ QueuePluginRegistry,
16
+ QueueManager,
17
+ } from "@checkstack/queue-api";
18
+ import type { ServiceRegistry } from "../services/service-registry";
19
+ import type { EventBus } from "@checkstack/backend-api";
20
+ import type { PluginMetadata } from "@checkstack/common";
21
+
22
+ /**
23
+ * Creates the API route handler for Hono.
24
+ * Extracted from PluginManager for better organization.
25
+ */
26
+ export function createApiRouteHandler({
27
+ registry,
28
+ pluginRpcRouters,
29
+ pluginHttpHandlers,
30
+ pluginMetadataRegistry,
31
+ }: {
32
+ registry: ServiceRegistry;
33
+ pluginRpcRouters: Map<string, unknown>;
34
+ pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
35
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
36
+ }) {
37
+ // Helper to get service from registry
38
+ async function getService<T>(ref: {
39
+ id: string;
40
+ T: T;
41
+ }): Promise<T | undefined> {
42
+ try {
43
+ return await registry.get(ref, { pluginId: "core" });
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ return async function handleApiRequest(c: Context) {
50
+ // Extract pluginId from Hono path parameter (/api/:pluginId/*)
51
+ const pluginId = c.req.param("pluginId") || "";
52
+ const pathname = new URL(c.req.raw.url).pathname;
53
+
54
+ // Build RPC handler lazily at request time
55
+ // This ensures all plugins registered during init are included
56
+ const rootRpcRouter: Record<string, unknown> = {};
57
+ for (const [pluginId, router] of pluginRpcRouters.entries()) {
58
+ rootRpcRouter[pluginId] = router;
59
+ }
60
+
61
+ const rpcHandler = new RPCHandler(
62
+ rootRpcRouter as ConstructorParameters<typeof RPCHandler>[0]
63
+ );
64
+
65
+ // Resolve core services for RPC context
66
+ const auth = await getService(coreServices.auth);
67
+ const logger = await getService(coreServices.logger);
68
+ const db = await getService(coreServices.database);
69
+ const fetch = await getService(coreServices.fetch);
70
+ const healthCheckRegistry = await getService(
71
+ coreServices.healthCheckRegistry
72
+ );
73
+ const queuePluginRegistry = await getService(
74
+ coreServices.queuePluginRegistry
75
+ );
76
+ const queueManager = await getService(coreServices.queueManager);
77
+ const eventBus = await getService(coreServices.eventBus);
78
+
79
+ if (
80
+ !auth ||
81
+ !logger ||
82
+ !db ||
83
+ !fetch ||
84
+ !healthCheckRegistry ||
85
+ !queuePluginRegistry ||
86
+ !queueManager ||
87
+ !eventBus
88
+ ) {
89
+ return c.json({ error: "Core services not initialized" }, 500);
90
+ }
91
+
92
+ const user = await (auth as AuthService).authenticate(c.req.raw);
93
+
94
+ // Create emitHook function using eventBus
95
+ const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
96
+ await (eventBus as EventBus).emit(hook, payload);
97
+ };
98
+
99
+ // Lookup plugin metadata from registry
100
+ const pluginMetadata: PluginMetadata | undefined =
101
+ pluginMetadataRegistry.get(pluginId);
102
+
103
+ if (!pluginMetadata) {
104
+ return c.json({ error: "Plugin metadata not found in registry" }, 500);
105
+ }
106
+
107
+ const context: RpcContext = {
108
+ pluginMetadata,
109
+ auth: auth as AuthService,
110
+ logger: logger as Logger,
111
+ db: db as NodePgDatabase<Record<string, unknown>>,
112
+ fetch: fetch as Fetch,
113
+ healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
114
+ queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
115
+ queueManager: queueManager as QueueManager,
116
+ user,
117
+ emitHook,
118
+ };
119
+
120
+ // 1. Try oRPC first
121
+ try {
122
+ const { matched, response } = await rpcHandler.handle(c.req.raw, {
123
+ prefix: "/api",
124
+ context,
125
+ });
126
+
127
+ if (matched) {
128
+ return c.newResponse(response.body, response);
129
+ }
130
+
131
+ logger.debug(`RPC mismatch for: ${c.req.method} ${pathname}`);
132
+ } catch (error) {
133
+ logger.error(`RPC Handler error: ${String(error)}`);
134
+ }
135
+
136
+ // 2. Try native handlers
137
+ // Sort by path length (descending) to ensure more specific paths are tried first
138
+ const sortedHandlers = [...pluginHttpHandlers.entries()].toSorted(function (
139
+ a,
140
+ b
141
+ ) {
142
+ return b[0].length - a[0].length;
143
+ });
144
+
145
+ for (const [path, handler] of sortedHandlers) {
146
+ if (pathname.startsWith(path)) {
147
+ return handler(c.req.raw);
148
+ }
149
+ }
150
+
151
+ return c.json({ error: "Not Found" }, 404);
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Registers the /api/:pluginId/* route with Hono.
157
+ */
158
+ export function registerApiRoute(
159
+ rootRouter: Hono,
160
+ handler: ReturnType<typeof createApiRouteHandler>
161
+ ) {
162
+ rootRouter.all("/api/:pluginId/*", handler);
163
+ }