@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,464 @@
1
+ import { drizzle } from "drizzle-orm/node-postgres";
2
+ import { migrate } from "drizzle-orm/node-postgres/migrator";
3
+ import { Pool } from "pg";
4
+ import path from "node:path";
5
+ import fs from "node:fs";
6
+ import type { Hono } from "hono";
7
+ import { eq, and } from "drizzle-orm";
8
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
9
+ import {
10
+ coreServices,
11
+ BackendPlugin,
12
+ AfterPluginsReadyContext,
13
+ DatabaseDeps,
14
+ ServiceRef,
15
+ Deps,
16
+ ResolvedDeps,
17
+ coreHooks,
18
+ HookSubscribeOptions,
19
+ RpcContext,
20
+ } from "@checkstack/backend-api";
21
+ import type { Permission } from "@checkstack/common";
22
+ import { getPluginSchemaName } from "@checkstack/drizzle-helper";
23
+ import { rootLogger } from "../logger";
24
+ import type { ServiceRegistry } from "../services/service-registry";
25
+ import { plugins } from "../schema";
26
+ import { stripPublicSchemaFromMigrations } from "../utils/strip-public-schema";
27
+ import {
28
+ discoverLocalPlugins,
29
+ syncPluginsToDatabase,
30
+ } from "../utils/plugin-discovery";
31
+ import type { InitCallback, PendingInit } from "./types";
32
+ import { sortPlugins } from "./dependency-sorter";
33
+ import { createApiRouteHandler, registerApiRoute } from "./api-router";
34
+ import type { ExtensionPointManager } from "./extension-points";
35
+ import { Router } from "@orpc/server";
36
+ import { AnyContractRouter } from "@orpc/contract";
37
+ import type { PluginMetadata } from "@checkstack/common";
38
+
39
+ export interface PluginLoaderDeps {
40
+ registry: ServiceRegistry;
41
+ pluginRpcRouters: Map<string, unknown>;
42
+ pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
43
+ extensionPointManager: ExtensionPointManager;
44
+ registeredPermissions: (Permission & { pluginId: string })[];
45
+ getAllPermissions: () => Permission[];
46
+ db: NodePgDatabase<Record<string, unknown>>;
47
+ /**
48
+ * Map of pluginId -> PluginMetadata for request-time context injection.
49
+ */
50
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
51
+ /**
52
+ * Map of pluginId -> cleanup handlers (stored in registration order, executed LIFO)
53
+ */
54
+ cleanupHandlers: Map<string, Array<() => Promise<void>>>;
55
+ /**
56
+ * Map of pluginId -> contract for OpenAPI generation.
57
+ */
58
+ pluginContractRegistry: Map<string, AnyContractRouter>;
59
+ }
60
+
61
+ /**
62
+ * Registers a single plugin - called during Phase 1.
63
+ */
64
+ export function registerPlugin({
65
+ backendPlugin,
66
+ pluginPath,
67
+ pendingInits,
68
+ providedBy,
69
+ deps,
70
+ }: {
71
+ backendPlugin: BackendPlugin;
72
+ pluginPath: string;
73
+ pendingInits: PendingInit[];
74
+ providedBy: Map<string, string>;
75
+ deps: PluginLoaderDeps;
76
+ }) {
77
+ if (!backendPlugin || typeof backendPlugin.register !== "function") {
78
+ rootLogger.warn(
79
+ `Plugin ${
80
+ backendPlugin?.metadata?.pluginId || "unknown"
81
+ } is not using new API. Skipping.`
82
+ );
83
+ return;
84
+ }
85
+
86
+ const pluginId = backendPlugin.metadata.pluginId;
87
+
88
+ // Store metadata for request-time context injection
89
+ deps.pluginMetadataRegistry.set(pluginId, backendPlugin.metadata);
90
+
91
+ // Execute Register
92
+ backendPlugin.register({
93
+ registerInit: <
94
+ D extends Deps,
95
+ S extends Record<string, unknown> | undefined = undefined
96
+ >(args: {
97
+ deps: D;
98
+ schema?: S;
99
+ init: (deps: ResolvedDeps<D> & DatabaseDeps<S>) => Promise<void>;
100
+ afterPluginsReady?: (
101
+ deps: ResolvedDeps<D> & DatabaseDeps<S> & AfterPluginsReadyContext
102
+ ) => Promise<void>;
103
+ }) => {
104
+ pendingInits.push({
105
+ metadata: backendPlugin.metadata,
106
+ pluginPath: pluginPath,
107
+ deps: args.deps,
108
+ init: args.init as InitCallback,
109
+ afterPluginsReady: args.afterPluginsReady as InitCallback | undefined,
110
+ schema: args.schema,
111
+ });
112
+ },
113
+ registerService: (ref: ServiceRef<unknown>, impl: unknown) => {
114
+ deps.registry.register(ref, impl);
115
+ providedBy.set(ref.id, pluginId);
116
+ rootLogger.debug(` -> Registered service '${ref.id}'`);
117
+ },
118
+ registerExtensionPoint: (ref, impl) => {
119
+ deps.extensionPointManager.registerExtensionPoint(ref, impl);
120
+ },
121
+ getExtensionPoint: (ref) => {
122
+ return deps.extensionPointManager.getExtensionPoint(ref);
123
+ },
124
+ registerPermissions: (permissions: Permission[]) => {
125
+ // Store permissions with pluginId prefix to namespace them
126
+ const prefixed = permissions.map((p) => ({
127
+ pluginId: pluginId,
128
+ id: `${pluginId}.${p.id}`,
129
+ description: p.description,
130
+ isAuthenticatedDefault: p.isAuthenticatedDefault,
131
+ isPublicDefault: p.isPublicDefault,
132
+ }));
133
+ deps.registeredPermissions.push(...prefixed);
134
+ rootLogger.debug(
135
+ ` -> Registered ${prefixed.length} permissions for ${pluginId}`
136
+ );
137
+ },
138
+ registerRouter: (
139
+ router: Router<AnyContractRouter, RpcContext>,
140
+ contract: AnyContractRouter
141
+ ) => {
142
+ deps.pluginRpcRouters.set(pluginId, router);
143
+ deps.pluginContractRegistry.set(pluginId, contract);
144
+ rootLogger.debug(` -> Registered router and contract for ${pluginId}`);
145
+ },
146
+ registerCleanup: (cleanup: () => Promise<void>) => {
147
+ const existing = deps.cleanupHandlers.get(pluginId) || [];
148
+ existing.push(cleanup);
149
+ deps.cleanupHandlers.set(pluginId, existing);
150
+ rootLogger.debug(` -> Registered cleanup handler for ${pluginId}`);
151
+ },
152
+ pluginManager: {
153
+ getAllPermissions: () => deps.getAllPermissions(),
154
+ },
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Loads all plugins - main orchestration function.
160
+ */
161
+ export async function loadPlugins({
162
+ rootRouter,
163
+ manualPlugins = [],
164
+ skipDiscovery = false,
165
+ deps,
166
+ }: {
167
+ rootRouter: Hono;
168
+ manualPlugins?: BackendPlugin[];
169
+ /** When true, skip filesystem plugin discovery (for testing) */
170
+ skipDiscovery?: boolean;
171
+ deps: PluginLoaderDeps;
172
+ }) {
173
+ if (skipDiscovery) {
174
+ rootLogger.debug("⏭️ Plugin discovery skipped (test mode)");
175
+ } else {
176
+ rootLogger.info("🔍 Discovering plugins...");
177
+ }
178
+
179
+ // 1. Discover local BACKEND plugins from monorepo using package.json metadata
180
+ let allPlugins: Array<{ name: string; path: string }> = [];
181
+
182
+ if (!skipDiscovery) {
183
+ const workspaceRoot = path.join(__dirname, "..", "..", "..", "..");
184
+ const localPlugins = discoverLocalPlugins({
185
+ workspaceRoot,
186
+ type: "backend",
187
+ });
188
+
189
+ rootLogger.debug(
190
+ ` -> Found ${localPlugins.length} local backend plugin(s) in workspace`
191
+ );
192
+ rootLogger.debug(" -> Discovered plugins:");
193
+ for (const p of localPlugins) {
194
+ rootLogger.debug(` • ${p.packageName}`);
195
+ }
196
+
197
+ // 2. Sync local plugins to database
198
+ await syncPluginsToDatabase({ localPlugins, db: deps.db });
199
+
200
+ // 3. Load all enabled BACKEND plugins from database
201
+ allPlugins = await deps.db
202
+ .select()
203
+ .from(plugins)
204
+ .where(and(eq(plugins.enabled, true), eq(plugins.type, "backend")));
205
+
206
+ rootLogger.debug(
207
+ ` -> ${allPlugins.length} enabled backend plugins in database:`
208
+ );
209
+ for (const p of allPlugins) {
210
+ rootLogger.debug(` • ${p.name}`);
211
+ }
212
+ }
213
+
214
+ if (allPlugins.length === 0 && manualPlugins.length === 0) {
215
+ rootLogger.info("ℹ️ No enabled plugins found.");
216
+ return;
217
+ }
218
+
219
+ // Phase 1: Load Modules & Register Services
220
+ const pendingInits: PendingInit[] = [];
221
+ const providedBy = new Map<string, string>();
222
+
223
+ for (const plugin of allPlugins) {
224
+ rootLogger.debug(`🔌 Loading module ${plugin.name}...`);
225
+
226
+ try {
227
+ let pluginModule;
228
+ try {
229
+ pluginModule = await import(plugin.name);
230
+ } catch {
231
+ rootLogger.debug(
232
+ ` -> Package name import failed, trying path: ${plugin.path}`
233
+ );
234
+ pluginModule = await import(plugin.path);
235
+ }
236
+
237
+ const backendPlugin: BackendPlugin = pluginModule.default;
238
+ registerPlugin({
239
+ backendPlugin,
240
+ pluginPath: plugin.path,
241
+ pendingInits,
242
+ providedBy,
243
+ deps,
244
+ });
245
+ } catch (error) {
246
+ rootLogger.error(`❌ Failed to load module for ${plugin.name}:`, error);
247
+ rootLogger.error(` Expected path: ${plugin.path}`);
248
+ throw new Error(`Failed to load plugin ${plugin.name}`, { cause: error });
249
+ }
250
+ }
251
+
252
+ // Phase 1.5: Register manual plugins
253
+ for (const backendPlugin of manualPlugins) {
254
+ registerPlugin({
255
+ backendPlugin,
256
+ pluginPath: "",
257
+ pendingInits,
258
+ providedBy,
259
+ deps,
260
+ });
261
+ }
262
+
263
+ // Phase 2: Initialize Plugins (Topological Sort)
264
+ const logger = await deps.registry.get(coreServices.logger, {
265
+ pluginId: "core",
266
+ });
267
+ const sortedIds = sortPlugins({ pendingInits, providedBy, logger });
268
+ rootLogger.debug(`✅ Initialization Order: ${sortedIds.join(" -> ")}`);
269
+
270
+ // Register /api/* route BEFORE plugin initialization
271
+ const apiHandler = createApiRouteHandler({
272
+ registry: deps.registry,
273
+ pluginRpcRouters: deps.pluginRpcRouters,
274
+ pluginHttpHandlers: deps.pluginHttpHandlers,
275
+ pluginMetadataRegistry: deps.pluginMetadataRegistry,
276
+ });
277
+ registerApiRoute(rootRouter, apiHandler);
278
+
279
+ for (const id of sortedIds) {
280
+ const p = pendingInits.find((x) => x.metadata.pluginId === id)!;
281
+ rootLogger.info(`🚀 Initializing ${p.metadata.pluginId}...`);
282
+
283
+ try {
284
+ const pluginDb = await deps.registry.get(
285
+ coreServices.database,
286
+ p.metadata
287
+ );
288
+
289
+ // Run Migrations
290
+ const migrationsFolder = path.join(p.pluginPath, "drizzle");
291
+ const migrationsSchema = getPluginSchemaName(p.metadata.pluginId);
292
+ if (fs.existsSync(migrationsFolder)) {
293
+ try {
294
+ // Strip "public". schema references from migration SQL at runtime
295
+ stripPublicSchemaFromMigrations(migrationsFolder);
296
+ rootLogger.debug(
297
+ ` -> Running migrations for ${p.metadata.pluginId} from ${migrationsFolder}`
298
+ );
299
+ await migrate(pluginDb, { migrationsFolder, migrationsSchema });
300
+ } catch (error) {
301
+ rootLogger.error(
302
+ `❌ Failed migration of plugin ${p.metadata.pluginId}:`,
303
+ error
304
+ );
305
+ throw new Error(`Failed to migrate plugin ${p.metadata.pluginId}`, {
306
+ cause: error,
307
+ });
308
+ }
309
+ } else {
310
+ rootLogger.debug(
311
+ ` -> No migrations found for ${p.metadata.pluginId} (skipping)`
312
+ );
313
+ }
314
+
315
+ // Resolve Dependencies
316
+ const resolvedDeps: Record<string, unknown> = {};
317
+ for (const [key, ref] of Object.entries(p.deps)) {
318
+ resolvedDeps[key] = await deps.registry.get(
319
+ ref as ServiceRef<unknown>,
320
+ p.metadata
321
+ );
322
+ }
323
+
324
+ // Inject Schema-aware Database if schema is provided
325
+ if (p.schema) {
326
+ const baseUrl = process.env.DATABASE_URL;
327
+ const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
328
+ const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
329
+ const pluginPool = new Pool({ connectionString: scopedUrl });
330
+ resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
331
+ }
332
+
333
+ try {
334
+ await p.init(resolvedDeps);
335
+ rootLogger.debug(` -> Initialized ${p.metadata.pluginId}`);
336
+ } catch (error) {
337
+ rootLogger.error(
338
+ `❌ Failed to initialize ${p.metadata.pluginId}:`,
339
+ error
340
+ );
341
+ throw new Error(`Failed to initialize plugin ${p.metadata.pluginId}`, {
342
+ cause: error,
343
+ });
344
+ }
345
+ } catch (error) {
346
+ rootLogger.error(
347
+ `❌ Critical error loading plugin ${p.metadata.pluginId}:`,
348
+ error
349
+ );
350
+ throw new Error(`Critical error loading plugin ${p.metadata.pluginId}`, {
351
+ cause: error,
352
+ });
353
+ }
354
+ }
355
+
356
+ // Emit pluginInitialized hooks for all plugins after Phase 2 completes
357
+ // (EventBus is now available)
358
+ const eventBus = await deps.registry.get(coreServices.eventBus, {
359
+ pluginId: "core",
360
+ });
361
+ for (const p of pendingInits) {
362
+ try {
363
+ await eventBus.emit(coreHooks.pluginInitialized, {
364
+ pluginId: p.metadata.pluginId,
365
+ });
366
+ } catch (error) {
367
+ rootLogger.error(
368
+ `Failed to emit pluginInitialized hook for ${p.metadata.pluginId}:`,
369
+ error
370
+ );
371
+ }
372
+ }
373
+
374
+ // Phase 3: Run afterPluginsReady callbacks
375
+ rootLogger.debug("🔄 Running afterPluginsReady callbacks...");
376
+
377
+ // Emit permission registration hooks at start of Phase 3
378
+ // (EventBus already retrieved above, all plugins can receive notifications)
379
+ const permissionsByPlugin = new Map<string, Permission[]>();
380
+ for (const perm of deps.registeredPermissions) {
381
+ if (!permissionsByPlugin.has(perm.pluginId)) {
382
+ permissionsByPlugin.set(perm.pluginId, []);
383
+ }
384
+ permissionsByPlugin.get(perm.pluginId)!.push({
385
+ id: perm.id,
386
+ description: perm.description,
387
+ isAuthenticatedDefault: perm.isAuthenticatedDefault,
388
+ isPublicDefault: perm.isPublicDefault,
389
+ });
390
+ }
391
+ for (const [pluginId, permissions] of permissionsByPlugin) {
392
+ try {
393
+ await eventBus.emit(coreHooks.permissionsRegistered, {
394
+ pluginId,
395
+ permissions,
396
+ });
397
+ } catch (error) {
398
+ rootLogger.error(
399
+ `Failed to emit permissionsRegistered hook for ${pluginId}:`,
400
+ error
401
+ );
402
+ }
403
+ }
404
+ for (const p of pendingInits) {
405
+ if (p.afterPluginsReady) {
406
+ try {
407
+ const resolvedDeps: Record<string, unknown> = {};
408
+ for (const [key, ref] of Object.entries(p.deps)) {
409
+ resolvedDeps[key] = await deps.registry.get(
410
+ ref as ServiceRef<unknown>,
411
+ p.metadata
412
+ );
413
+ }
414
+
415
+ if (p.schema) {
416
+ const baseUrl = process.env.DATABASE_URL;
417
+ const assignedSchema = getPluginSchemaName(p.metadata.pluginId);
418
+ const scopedUrl = `${baseUrl}?options=-c%20search_path%3D${assignedSchema}`;
419
+ const pluginPool = new Pool({ connectionString: scopedUrl });
420
+ resolvedDeps["database"] = drizzle(pluginPool, { schema: p.schema });
421
+ }
422
+
423
+ const eventBus = await deps.registry.get(coreServices.eventBus, {
424
+ pluginId: "core",
425
+ });
426
+ resolvedDeps["onHook"] = <T>(
427
+ hook: { id: string },
428
+ listener: (payload: T) => Promise<void>,
429
+ options?: HookSubscribeOptions
430
+ ) => {
431
+ return eventBus.subscribe(
432
+ p.metadata.pluginId,
433
+ hook,
434
+ listener,
435
+ options
436
+ );
437
+ };
438
+ resolvedDeps["emitHook"] = async <T>(
439
+ hook: { id: string },
440
+ payload: T
441
+ ) => {
442
+ await eventBus.emit(hook, payload);
443
+ };
444
+
445
+ await p.afterPluginsReady(resolvedDeps);
446
+ rootLogger.debug(
447
+ ` -> ${p.metadata.pluginId} afterPluginsReady complete`
448
+ );
449
+ } catch (error) {
450
+ rootLogger.error(
451
+ `❌ Failed afterPluginsReady for ${p.metadata.pluginId}:`,
452
+ error
453
+ );
454
+ throw new Error(
455
+ `Failed afterPluginsReady for plugin ${p.metadata.pluginId}`,
456
+ {
457
+ cause: error,
458
+ }
459
+ );
460
+ }
461
+ }
462
+ }
463
+ rootLogger.debug("✅ All afterPluginsReady callbacks complete");
464
+ }
@@ -0,0 +1,14 @@
1
+ import type { ServiceRef } from "@checkstack/backend-api";
2
+ import type { PluginMetadata } from "@checkstack/common";
3
+
4
+ /** Erased callback type used in PendingInit storage */
5
+ export type InitCallback = (deps: Record<string, unknown>) => Promise<void>;
6
+
7
+ export interface PendingInit {
8
+ metadata: PluginMetadata;
9
+ pluginPath: string;
10
+ deps: Record<string, ServiceRef<unknown>>;
11
+ init: InitCallback;
12
+ afterPluginsReady?: InitCallback;
13
+ schema?: Record<string, unknown>;
14
+ }