@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
package/src/index.ts ADDED
@@ -0,0 +1,419 @@
1
+ import type { Server } from "bun";
2
+ import { Hono } from "hono";
3
+ import { PluginManager } from "./plugin-manager";
4
+ import { logger } from "hono/logger";
5
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
6
+ import { db } from "./db";
7
+ import path from "node:path";
8
+ import fs from "node:fs";
9
+ import { rootLogger } from "./logger";
10
+ import { coreServices, coreHooks } from "@checkstack/backend-api";
11
+ import { plugins } from "./schema";
12
+ import { eq, and } from "drizzle-orm";
13
+ import { PluginLocalInstaller } from "./services/plugin-installer";
14
+ import { QueuePluginRegistryImpl } from "./services/queue-plugin-registry";
15
+ import { QueueManagerImpl } from "./services/queue-manager";
16
+ import {
17
+ createWebSocketHandler,
18
+ SignalServiceImpl,
19
+ type WebSocketData,
20
+ } from "@checkstack/signal-backend";
21
+ import {
22
+ PLUGIN_INSTALLED,
23
+ PLUGIN_DEREGISTERED,
24
+ } from "@checkstack/signal-common";
25
+ import { createPluginAdminRouter } from "./plugin-manager/plugin-admin-router";
26
+ import {
27
+ pluginMetadata as apiDocsMetadata,
28
+ permissions as apiDocsPermissions,
29
+ } from "@checkstack/api-docs-common";
30
+ import { qualifyPermissionId } from "@checkstack/common";
31
+
32
+ import { cors } from "hono/cors";
33
+
34
+ const app = new Hono();
35
+ const pluginManager = new PluginManager();
36
+
37
+ // WebSocket handler instance (initialized during init)
38
+ let wsHandler: ReturnType<typeof createWebSocketHandler> | undefined;
39
+
40
+ // CORS configuration
41
+ // - In production: uses BASE_URL
42
+ // - In development: allows both backend origin and Vite dev server
43
+ const corsOrigin = process.env.BASE_URL || "http://localhost:3000";
44
+ const corsOrigins = [corsOrigin];
45
+
46
+ // Allow Vite dev server in development
47
+ if (!process.env.BASE_URL || corsOrigin.includes("localhost")) {
48
+ corsOrigins.push("http://localhost:5173");
49
+ }
50
+
51
+ app.use(
52
+ "*",
53
+ cors({
54
+ origin: corsOrigins,
55
+ allowHeaders: ["Content-Type", "Authorization"],
56
+ allowMethods: ["POST", "GET", "PUT", "PATCH", "DELETE", "OPTIONS"],
57
+ exposeHeaders: ["Content-Length"],
58
+ maxAge: 600,
59
+ credentials: true,
60
+ })
61
+ );
62
+ app.use("*", logger());
63
+
64
+ // Runtime config endpoint - returns BASE_URL for frontend
65
+ app.get("/api/config", (c) => {
66
+ const baseUrl = process.env.BASE_URL || "http://localhost:3000";
67
+ return c.json({ baseUrl });
68
+ });
69
+
70
+ app.get("/api/plugins", async (c) => {
71
+ // Only return remote plugins that need to be loaded via HTTP
72
+ // Local plugins are bundled and loaded via Vite's glob import
73
+ const enabledPlugins = await db
74
+ .select({
75
+ name: plugins.name,
76
+ path: plugins.path,
77
+ })
78
+ .from(plugins)
79
+ .where(
80
+ and(
81
+ eq(plugins.enabled, true),
82
+ eq(plugins.type, "frontend"),
83
+ eq(plugins.isUninstallable, true) // Only remote plugins
84
+ )
85
+ );
86
+
87
+ return c.json(enabledPlugins);
88
+ });
89
+
90
+ app.get("/.well-known/jwks.json", async (c) => {
91
+ const { keyStore } = await import("./services/keystore");
92
+ const jwks = await keyStore.getPublicJWKS();
93
+ return c.json(jwks);
94
+ });
95
+
96
+ // Production: Serve frontend static files when CHECKSTACK_FRONTEND_DIST is set
97
+ // Must be registered at module load time before Hono's router is built
98
+ const frontendDistPath = process.env.CHECKSTACK_FRONTEND_DIST;
99
+ if (frontendDistPath && fs.existsSync(frontendDistPath)) {
100
+ rootLogger.info(`📦 Serving frontend from: ${frontendDistPath}`);
101
+
102
+ // Serve static assets (JS, CSS, images, etc.)
103
+ app.get("/assets/*", async (c) => {
104
+ const assetPath = c.req.path.replace("/assets/", "");
105
+ const filePath = path.join(frontendDistPath, "assets", assetPath);
106
+
107
+ if (fs.existsSync(filePath)) {
108
+ const file = Bun.file(filePath);
109
+ return new Response(file, {
110
+ headers: { "Content-Type": file.type },
111
+ });
112
+ }
113
+ return c.notFound();
114
+ });
115
+
116
+ // Serve vendor scripts (externalized React, react-router-dom, etc.)
117
+ app.get("/vendor/*", async (c) => {
118
+ const vendorPath = c.req.path.replace("/vendor/", "");
119
+ const filePath = path.join(frontendDistPath, "vendor", vendorPath);
120
+
121
+ if (fs.existsSync(filePath)) {
122
+ const file = Bun.file(filePath);
123
+ return new Response(file, {
124
+ headers: { "Content-Type": file.type },
125
+ });
126
+ }
127
+ return c.notFound();
128
+ });
129
+
130
+ // Serve index.html for all non-API routes (SPA fallback)
131
+ app.get("*", async (c, next) => {
132
+ // Skip API and WebSocket routes - let them pass through to actual handlers
133
+ if (c.req.path.startsWith("/api")) {
134
+ return next();
135
+ }
136
+
137
+ const indexPath = path.join(frontendDistPath, "index.html");
138
+ if (fs.existsSync(indexPath)) {
139
+ const file = Bun.file(indexPath);
140
+ return new Response(file, {
141
+ headers: { "Content-Type": "text/html" },
142
+ });
143
+ }
144
+ return c.notFound();
145
+ });
146
+ }
147
+
148
+ const init = async () => {
149
+ rootLogger.info("🚀 Starting Checkstack Core...");
150
+
151
+ // Register Plugin Installer Service
152
+ const installer = new PluginLocalInstaller(
153
+ path.join(process.cwd(), "runtime_plugins")
154
+ );
155
+ pluginManager.registerService(coreServices.pluginInstaller, installer);
156
+
157
+ // 1. Run Core Migrations
158
+ rootLogger.info("🔄 Running core migrations...");
159
+ try {
160
+ await migrate(db, {
161
+ // Use import.meta.dir to find migrations relative to this file (works in Docker)
162
+ migrationsFolder: path.join(import.meta.dir, "..", "drizzle"),
163
+ });
164
+ rootLogger.info("✅ Core migrations applied.");
165
+ } catch (error) {
166
+ throw new Error("❌ Failed to apply core migrations", {
167
+ cause: error,
168
+ });
169
+ }
170
+
171
+ // 1.5. Ensure JWKS signing keys exist
172
+ rootLogger.info("🔑 Ensuring JWKS signing keys...");
173
+ const { keyStore } = await import("./services/keystore");
174
+ await keyStore.getSigningKey(); // This triggers generation if missing
175
+
176
+ // 1.6. Create backend-scoped ConfigService for core services
177
+ const { ConfigServiceImpl } = await import("./services/config-service");
178
+ const configService = new ConfigServiceImpl("backend", db);
179
+
180
+ // 1.7. Register Queue Services
181
+ rootLogger.debug("Registering queue services...");
182
+ const queueRegistry = new QueuePluginRegistryImpl();
183
+ const queueManager = new QueueManagerImpl(
184
+ queueRegistry,
185
+ configService,
186
+ rootLogger
187
+ );
188
+ pluginManager.registerService(
189
+ coreServices.queuePluginRegistry,
190
+ queueRegistry
191
+ );
192
+ pluginManager.registerService(coreServices.queueManager, queueManager);
193
+
194
+ // Serve static assets for runtime frontend plugins only
195
+ // Backend plugins don't need public assets - only frontend plugins do
196
+ // e.g. /assets/plugins/my-plugin-frontend/index.js -> runtime_plugins/node_modules/my-plugin-frontend/dist/index.js
197
+ app.use("/assets/plugins/:pluginName/*", async (c, next) => {
198
+ const pluginName = c.req.param("pluginName");
199
+ // Find plugin in DB to get path
200
+ const results = await db
201
+ .select()
202
+ .from(plugins)
203
+ .where(eq(plugins.name, pluginName));
204
+ const plugin = results[0];
205
+
206
+ // Only serve assets for frontend plugins
207
+ if (!plugin || plugin.type !== "frontend") {
208
+ return next();
209
+ }
210
+
211
+ // We assume plugins are built into 'dist' folder
212
+ const assetPath = c.req.path.split(`/assets/plugins/${pluginName}/`)[1];
213
+ const filePath = path.join(plugin.path, "dist", assetPath);
214
+
215
+ if (fs.existsSync(filePath)) {
216
+ return c.body(fs.readFileSync(filePath));
217
+ }
218
+ return next();
219
+ });
220
+
221
+ // 2. Initialize Signal Service (before plugins so they can use it)
222
+ // SignalService requires EventBus which is a lazy factory depending on QueueManager
223
+ rootLogger.debug("Initializing signal service...");
224
+ const eventBus = await pluginManager.getService(coreServices.eventBus);
225
+ if (!eventBus) {
226
+ throw new Error("EventBus not available - required for SignalService");
227
+ }
228
+ const signalService = new SignalServiceImpl(
229
+ eventBus,
230
+ rootLogger.child({ service: "SignalService" })
231
+ );
232
+ pluginManager.registerService(coreServices.signalService, signalService);
233
+
234
+ // 2.5. Register OpenAPI endpoint BEFORE plugins load
235
+ // Must be registered before /api/:pluginId/* catch-all route
236
+ const authService = await pluginManager.getService(coreServices.auth);
237
+ if (authService) {
238
+ const { createOpenApiHandler } = await import("./openapi-router");
239
+ const baseUrl = process.env.BASE_URL || "http://localhost:3000";
240
+ const openApiHandler = createOpenApiHandler({
241
+ pluginManager,
242
+ authService,
243
+ baseUrl,
244
+ requiredPermission: qualifyPermissionId(
245
+ apiDocsMetadata,
246
+ apiDocsPermissions.apiDocsView
247
+ ),
248
+ });
249
+ app.get("/api/openapi.json", async (c) => {
250
+ const response = await openApiHandler(c.req.raw);
251
+ return c.newResponse(response.body, response);
252
+ });
253
+ rootLogger.debug("OpenAPI endpoint registered at /api/openapi.json");
254
+ } else {
255
+ rootLogger.warn(
256
+ "AuthService not available, OpenAPI endpoint will not be registered"
257
+ );
258
+ }
259
+
260
+ // 3. Load Plugins
261
+ await pluginManager.loadPlugins(app);
262
+
263
+ // 4. Wire up auth client for permission-based signal filtering
264
+ // This must happen AFTER plugins load so auth-backend is available
265
+ const rpcClient = await pluginManager.getService(coreServices.rpcClient);
266
+ if (rpcClient) {
267
+ const { AuthApi } = await import("@checkstack/auth-common");
268
+ const authClient = rpcClient.forPlugin(AuthApi);
269
+ signalService.setAuthClient(authClient);
270
+ rootLogger.debug(
271
+ "SignalService: Auth client configured for permission filtering"
272
+ );
273
+ } else {
274
+ rootLogger.warn(
275
+ "SignalService: RpcClient not available, sendToAuthorizedUsers will be disabled"
276
+ );
277
+ }
278
+
279
+ // 5. Register plugin admin router (core admin endpoints)
280
+ const pluginAdminRouter = createPluginAdminRouter({
281
+ pluginManager,
282
+ installer,
283
+ });
284
+ // Register as core router - available at /api/core/
285
+ pluginManager.registerCoreRouter("core", pluginAdminRouter);
286
+
287
+ // 5. Setup lifecycle listeners for multi-instance coordination
288
+ await pluginManager.setupLifecycleListeners();
289
+
290
+ // 6. Load Queue Configuration AFTER plugins (queue plugins register first)
291
+ rootLogger.info("📋 Loading queue configuration...");
292
+ await queueManager.loadConfiguration();
293
+
294
+ // 7. Start config polling for multi-instance coordination
295
+ queueManager.startPolling(5000);
296
+
297
+ // 9. Setup plugin lifecycle signal broadcasting to frontend
298
+ // Only broadcast for frontend plugins (plugins ending with -frontend)
299
+ await eventBus.subscribe(
300
+ "core",
301
+ coreHooks.pluginInstalled,
302
+ async ({ pluginId }) => {
303
+ // Only signal frontend plugin installations to the frontend
304
+ if (!pluginId.endsWith("-frontend")) {
305
+ rootLogger.debug(
306
+ `Skipping PLUGIN_INSTALLED signal for non-frontend plugin: ${pluginId}`
307
+ );
308
+ return;
309
+ }
310
+ rootLogger.debug(`Broadcasting PLUGIN_INSTALLED signal for: ${pluginId}`);
311
+ await signalService.broadcast(PLUGIN_INSTALLED, { pluginId });
312
+ },
313
+ { mode: "work-queue", workerGroup: "frontend-signal-installed" }
314
+ );
315
+ await eventBus.subscribe(
316
+ "core",
317
+ coreHooks.pluginDeregistered,
318
+ async ({ pluginId }) => {
319
+ // Only signal frontend plugin deregistrations to the frontend
320
+ if (!pluginId.endsWith("-frontend")) {
321
+ rootLogger.debug(
322
+ `Skipping PLUGIN_DEREGISTERED signal for non-frontend plugin: ${pluginId}`
323
+ );
324
+ return;
325
+ }
326
+ rootLogger.debug(
327
+ `Broadcasting PLUGIN_DEREGISTERED signal for: ${pluginId}`
328
+ );
329
+ await signalService.broadcast(PLUGIN_DEREGISTERED, { pluginId });
330
+ },
331
+ { mode: "work-queue", workerGroup: "frontend-signal-deregistered" }
332
+ );
333
+
334
+ // 11. Create WebSocket handler for realtime signals
335
+ wsHandler = createWebSocketHandler({
336
+ eventBus,
337
+ logger: rootLogger.child({ service: "WebSocket" }),
338
+ });
339
+
340
+ rootLogger.info("✅ Checkstack Core initialized.");
341
+ };
342
+
343
+ void init();
344
+
345
+ // Custom fetch handler that handles WebSocket upgrades
346
+ const fetch = async (
347
+ req: Request,
348
+ server: Server<WebSocketData>
349
+ ): Promise<Response | undefined> => {
350
+ // Set the server reference for WebSocket pub/sub after startup
351
+ if (wsHandler && !server.upgrade) {
352
+ // Server doesn't support WebSocket upgrade (shouldn't happen with Bun)
353
+ return app.fetch(req, server);
354
+ }
355
+
356
+ // Give the WebSocket handler the server reference if needed
357
+ wsHandler?.setServer(server);
358
+
359
+ const url = new URL(req.url);
360
+
361
+ // Handle WebSocket upgrade for signals
362
+ if (url.pathname === "/api/signals/ws") {
363
+ // Try to authenticate, but allow anonymous connections for broadcast signals
364
+ const authService = await pluginManager.getService(coreServices.auth);
365
+ let userId: string | undefined;
366
+
367
+ if (authService) {
368
+ const user = await authService.authenticate(req);
369
+ // Only RealUser (type: 'user') can have a private channel
370
+ if (user?.type === "user") {
371
+ userId = user.id;
372
+ }
373
+ }
374
+
375
+ const success = server.upgrade(req, {
376
+ data: {
377
+ userId, // undefined for anonymous, set for authenticated users
378
+ createdAt: Date.now(),
379
+ },
380
+ });
381
+
382
+ return success
383
+ ? undefined
384
+ : new Response("WebSocket upgrade failed", { status: 500 });
385
+ }
386
+
387
+ // Handle regular HTTP requests with Hono
388
+ return app.fetch(req, server);
389
+ };
390
+
391
+ export default {
392
+ port: 3000,
393
+ fetch,
394
+ websocket: {
395
+ // Type template for ws.data
396
+ data: {} as WebSocketData,
397
+
398
+ open(ws: import("bun").ServerWebSocket<WebSocketData>) {
399
+ wsHandler?.websocket.open(ws);
400
+ },
401
+
402
+ message(
403
+ ws: import("bun").ServerWebSocket<WebSocketData>,
404
+ message: string | Buffer
405
+ ) {
406
+ wsHandler?.websocket.message(ws, message);
407
+ },
408
+
409
+ close(
410
+ ws: import("bun").ServerWebSocket<WebSocketData>,
411
+ code: number,
412
+ reason: string
413
+ ) {
414
+ wsHandler?.websocket.close(ws, code, reason);
415
+ },
416
+ },
417
+ };
418
+
419
+ export { jwtService } from "./services/jwt";