@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.
- package/CHANGELOG.md +225 -0
- package/drizzle/0000_loose_yellow_claw.sql +28 -0
- package/drizzle/meta/0000_snapshot.json +187 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +10 -0
- package/package.json +42 -0
- package/src/db.ts +20 -0
- package/src/health-check-plugin-integration.test.ts +93 -0
- package/src/index.ts +419 -0
- package/src/integration/event-bus.integration.test.ts +313 -0
- package/src/logger.ts +65 -0
- package/src/openapi-router.ts +177 -0
- package/src/plugin-lifecycle.test.ts +276 -0
- package/src/plugin-manager/api-router.ts +163 -0
- package/src/plugin-manager/core-services.ts +312 -0
- package/src/plugin-manager/dependency-sorter.ts +103 -0
- package/src/plugin-manager/deregistration-guard.ts +41 -0
- package/src/plugin-manager/extension-points.ts +85 -0
- package/src/plugin-manager/index.ts +13 -0
- package/src/plugin-manager/plugin-admin-router.ts +89 -0
- package/src/plugin-manager/plugin-loader.ts +464 -0
- package/src/plugin-manager/types.ts +14 -0
- package/src/plugin-manager.test.ts +464 -0
- package/src/plugin-manager.ts +431 -0
- package/src/rpc-rest-compat.test.ts +80 -0
- package/src/schema.ts +46 -0
- package/src/services/config-service.test.ts +66 -0
- package/src/services/config-service.ts +322 -0
- package/src/services/event-bus.test.ts +469 -0
- package/src/services/event-bus.ts +317 -0
- package/src/services/health-check-registry.test.ts +101 -0
- package/src/services/health-check-registry.ts +27 -0
- package/src/services/jwt.ts +45 -0
- package/src/services/keystore.test.ts +198 -0
- package/src/services/keystore.ts +136 -0
- package/src/services/plugin-installer.test.ts +90 -0
- package/src/services/plugin-installer.ts +70 -0
- package/src/services/queue-manager.ts +382 -0
- package/src/services/queue-plugin-registry.ts +17 -0
- package/src/services/queue-proxy.ts +182 -0
- package/src/services/service-registry.ts +35 -0
- package/src/test-preload.ts +114 -0
- package/src/utils/plugin-discovery.test.ts +383 -0
- package/src/utils/plugin-discovery.ts +157 -0
- package/src/utils/strip-public-schema.ts +40 -0
- package/tsconfig.json +6 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import type { Hono } from "hono";
|
|
2
|
+
import { adminPool, db } from "./db";
|
|
3
|
+
import { ServiceRegistry } from "./services/service-registry";
|
|
4
|
+
import {
|
|
5
|
+
BackendPlugin,
|
|
6
|
+
ServiceRef,
|
|
7
|
+
ExtensionPoint,
|
|
8
|
+
coreServices,
|
|
9
|
+
coreHooks,
|
|
10
|
+
HookUnsubscribe,
|
|
11
|
+
} from "@checkstack/backend-api";
|
|
12
|
+
import type { AnyContractRouter } from "@orpc/contract";
|
|
13
|
+
import type { Permission, PluginMetadata } from "@checkstack/common";
|
|
14
|
+
|
|
15
|
+
// Extracted modules
|
|
16
|
+
import { registerCoreServices } from "./plugin-manager/core-services";
|
|
17
|
+
import { createExtensionPointManager } from "./plugin-manager/extension-points";
|
|
18
|
+
import { loadPlugins as loadPluginsImpl } from "./plugin-manager/plugin-loader";
|
|
19
|
+
import { rootLogger } from "./logger";
|
|
20
|
+
|
|
21
|
+
export interface DeregisterOptions {
|
|
22
|
+
deleteSchema: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class PluginManager {
|
|
26
|
+
private registry = new ServiceRegistry();
|
|
27
|
+
private pluginRpcRouters = new Map<string, unknown>();
|
|
28
|
+
private pluginHttpHandlers = new Map<
|
|
29
|
+
string,
|
|
30
|
+
(req: Request) => Promise<Response>
|
|
31
|
+
>();
|
|
32
|
+
private extensionPointManager = createExtensionPointManager();
|
|
33
|
+
|
|
34
|
+
// Permission registry - stores all registered permissions with pluginId for hook emission
|
|
35
|
+
private registeredPermissions: (Permission & { pluginId: string })[] = [];
|
|
36
|
+
|
|
37
|
+
// Plugin metadata registry - stores PluginMetadata for request-time context injection
|
|
38
|
+
private pluginMetadataRegistry = new Map<string, PluginMetadata>();
|
|
39
|
+
|
|
40
|
+
// Cleanup handlers registered by plugins (LIFO execution)
|
|
41
|
+
private cleanupHandlers = new Map<string, Array<() => Promise<void>>>();
|
|
42
|
+
|
|
43
|
+
// Contract registry - stores plugin contracts for OpenAPI generation
|
|
44
|
+
private pluginContractRegistry = new Map<string, AnyContractRouter>();
|
|
45
|
+
|
|
46
|
+
// Hook subscriptions per plugin (for bulk unsubscribe)
|
|
47
|
+
private hookSubscriptions = new Map<string, HookUnsubscribe[]>();
|
|
48
|
+
|
|
49
|
+
constructor() {
|
|
50
|
+
registerCoreServices({
|
|
51
|
+
registry: this.registry,
|
|
52
|
+
adminPool,
|
|
53
|
+
pluginRpcRouters: this.pluginRpcRouters,
|
|
54
|
+
pluginHttpHandlers: this.pluginHttpHandlers,
|
|
55
|
+
pluginContractRegistry: this.pluginContractRegistry,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
|
|
60
|
+
this.extensionPointManager.registerExtensionPoint(ref, impl);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
getExtensionPoint<T>(ref: ExtensionPoint<T>): T {
|
|
64
|
+
return this.extensionPointManager.getExtensionPoint(ref);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Register a core router (not from a plugin, but from core backend).
|
|
69
|
+
* Used for admin endpoints like plugin installation/deregistration.
|
|
70
|
+
*/
|
|
71
|
+
registerCoreRouter(routerId: string, router: unknown): void {
|
|
72
|
+
this.pluginRpcRouters.set(routerId, router);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
getAllPermissions(): Permission[] {
|
|
76
|
+
return this.registeredPermissions.map(
|
|
77
|
+
({ id, description, isAuthenticatedDefault, isPublicDefault }) => ({
|
|
78
|
+
id,
|
|
79
|
+
description,
|
|
80
|
+
isAuthenticatedDefault,
|
|
81
|
+
isPublicDefault,
|
|
82
|
+
})
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Get all registered contracts for OpenAPI generation.
|
|
88
|
+
* Returns a map of pluginId -> contract.
|
|
89
|
+
*/
|
|
90
|
+
getAllContracts(): Map<string, AnyContractRouter> {
|
|
91
|
+
return new Map(this.pluginContractRegistry);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async loadPlugins(
|
|
95
|
+
rootRouter: Hono,
|
|
96
|
+
manualPlugins: BackendPlugin[] = [],
|
|
97
|
+
options: { skipDiscovery?: boolean } = {}
|
|
98
|
+
) {
|
|
99
|
+
await loadPluginsImpl({
|
|
100
|
+
rootRouter,
|
|
101
|
+
manualPlugins,
|
|
102
|
+
skipDiscovery: options.skipDiscovery,
|
|
103
|
+
deps: {
|
|
104
|
+
registry: this.registry,
|
|
105
|
+
pluginRpcRouters: this.pluginRpcRouters,
|
|
106
|
+
pluginHttpHandlers: this.pluginHttpHandlers,
|
|
107
|
+
extensionPointManager: this.extensionPointManager,
|
|
108
|
+
registeredPermissions: this.registeredPermissions,
|
|
109
|
+
getAllPermissions: () => this.getAllPermissions(),
|
|
110
|
+
db,
|
|
111
|
+
pluginMetadataRegistry: this.pluginMetadataRegistry,
|
|
112
|
+
cleanupHandlers: this.cleanupHandlers,
|
|
113
|
+
pluginContractRegistry: this.pluginContractRegistry,
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Deregister a plugin at runtime.
|
|
120
|
+
* Only works for plugins with isUninstallable: true.
|
|
121
|
+
*/
|
|
122
|
+
async deregisterPlugin(
|
|
123
|
+
pluginId: string,
|
|
124
|
+
options: DeregisterOptions
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
rootLogger.info(`🔄 Deregistering plugin: ${pluginId}...`);
|
|
127
|
+
|
|
128
|
+
// 1. Emit pluginDeregistering hook locally (instance-local, not distributed)
|
|
129
|
+
// This lets other plugins on THIS instance cleanup dependencies
|
|
130
|
+
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
131
|
+
pluginId: "core",
|
|
132
|
+
});
|
|
133
|
+
await eventBus.emitLocal(coreHooks.pluginDeregistering, {
|
|
134
|
+
pluginId,
|
|
135
|
+
reason: "uninstall" as const,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// 2. Run cleanup handlers (LIFO order)
|
|
139
|
+
const handlers = this.cleanupHandlers.get(pluginId) || [];
|
|
140
|
+
for (const handler of handlers.toReversed()) {
|
|
141
|
+
try {
|
|
142
|
+
await handler();
|
|
143
|
+
} catch (error) {
|
|
144
|
+
rootLogger.error(`Cleanup handler failed for ${pluginId}:`, error);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
this.cleanupHandlers.delete(pluginId);
|
|
148
|
+
|
|
149
|
+
// 3. Unsubscribe from all EventBus hooks
|
|
150
|
+
const subscriptions = this.hookSubscriptions.get(pluginId) || [];
|
|
151
|
+
for (const unsubscribe of subscriptions) {
|
|
152
|
+
try {
|
|
153
|
+
await unsubscribe();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
rootLogger.error(`Failed to unsubscribe hook for ${pluginId}:`, error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
this.hookSubscriptions.delete(pluginId);
|
|
159
|
+
|
|
160
|
+
// 4. Remove from router maps and contract registry
|
|
161
|
+
this.pluginRpcRouters.delete(pluginId);
|
|
162
|
+
this.pluginHttpHandlers.delete(pluginId);
|
|
163
|
+
this.pluginContractRegistry.delete(pluginId);
|
|
164
|
+
rootLogger.debug(` -> Removed routers and contracts for ${pluginId}`);
|
|
165
|
+
|
|
166
|
+
// 5. Remove permissions from registry
|
|
167
|
+
const beforeCount = this.registeredPermissions.length;
|
|
168
|
+
this.registeredPermissions = this.registeredPermissions.filter(
|
|
169
|
+
(p) => p.pluginId !== pluginId
|
|
170
|
+
);
|
|
171
|
+
rootLogger.debug(
|
|
172
|
+
` -> Removed ${
|
|
173
|
+
beforeCount - this.registeredPermissions.length
|
|
174
|
+
} permissions`
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// 6. Drop schema if requested
|
|
178
|
+
if (options.deleteSchema) {
|
|
179
|
+
try {
|
|
180
|
+
const schemaName = `plugin_${pluginId}`;
|
|
181
|
+
await db.execute(`DROP SCHEMA IF EXISTS "${schemaName}" CASCADE`);
|
|
182
|
+
rootLogger.info(` -> Dropped schema: ${schemaName}`);
|
|
183
|
+
} catch (error) {
|
|
184
|
+
rootLogger.error(`Failed to drop schema for ${pluginId}:`, error);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 7. Emit pluginDeregistered hook (for permission cleanup in auth-backend)
|
|
189
|
+
await eventBus.emit(coreHooks.pluginDeregistered, { pluginId });
|
|
190
|
+
|
|
191
|
+
rootLogger.info(`✅ Plugin deregistered: ${pluginId}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Track a hook subscription for a plugin (for bulk unsubscribe during deregistration)
|
|
196
|
+
*/
|
|
197
|
+
trackHookSubscription(pluginId: string, unsubscribe: HookUnsubscribe): void {
|
|
198
|
+
const existing = this.hookSubscriptions.get(pluginId) || [];
|
|
199
|
+
existing.push(unsubscribe);
|
|
200
|
+
this.hookSubscriptions.set(pluginId, existing);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Request deregistration of a plugin across all instances.
|
|
205
|
+
* This emits a broadcast hook that all instances (including this one) will receive.
|
|
206
|
+
* Each instance then performs local cleanup via deregisterPlugin().
|
|
207
|
+
*/
|
|
208
|
+
async requestDeregistration(
|
|
209
|
+
pluginId: string,
|
|
210
|
+
options: DeregisterOptions
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
rootLogger.info(`📢 Broadcasting deregistration request for: ${pluginId}`);
|
|
213
|
+
|
|
214
|
+
// Emit broadcast hook - all instances receive and perform local cleanup
|
|
215
|
+
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
216
|
+
pluginId: "core",
|
|
217
|
+
});
|
|
218
|
+
await eventBus.emit(coreHooks.pluginDeregistrationRequested, {
|
|
219
|
+
pluginId,
|
|
220
|
+
deleteSchema: options.deleteSchema,
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
rootLogger.info(`✅ Deregistration request broadcast for: ${pluginId}`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Request installation/loading of a plugin across all instances.
|
|
228
|
+
* This emits a broadcast hook that all instances (including this one) will receive.
|
|
229
|
+
* Each instance then loads the plugin into memory.
|
|
230
|
+
*/
|
|
231
|
+
async requestInstallation(
|
|
232
|
+
pluginId: string,
|
|
233
|
+
pluginPath: string
|
|
234
|
+
): Promise<void> {
|
|
235
|
+
rootLogger.info(`📢 Broadcasting installation request for: ${pluginId}`);
|
|
236
|
+
|
|
237
|
+
// Emit broadcast hook - all instances receive and load the plugin
|
|
238
|
+
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
239
|
+
pluginId: "core",
|
|
240
|
+
});
|
|
241
|
+
await eventBus.emit(coreHooks.pluginInstallationRequested, {
|
|
242
|
+
pluginId,
|
|
243
|
+
pluginPath,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
rootLogger.info(`✅ Installation request broadcast for: ${pluginId}`);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Setup lifecycle listeners for multi-instance coordination.
|
|
251
|
+
* Must be called after EventBus is available (after loadPlugins).
|
|
252
|
+
*/
|
|
253
|
+
async setupLifecycleListeners(): Promise<void> {
|
|
254
|
+
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
255
|
+
pluginId: "core",
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Listen for deregistration broadcasts (from any instance)
|
|
259
|
+
await eventBus.subscribe(
|
|
260
|
+
"core",
|
|
261
|
+
coreHooks.pluginDeregistrationRequested,
|
|
262
|
+
async ({ pluginId, deleteSchema }) => {
|
|
263
|
+
rootLogger.info(`📥 Received deregistration request for: ${pluginId}`);
|
|
264
|
+
await this.deregisterPlugin(pluginId, { deleteSchema });
|
|
265
|
+
}
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// Listen for installation broadcasts (from any instance)
|
|
269
|
+
await eventBus.subscribe(
|
|
270
|
+
"core",
|
|
271
|
+
coreHooks.pluginInstallationRequested,
|
|
272
|
+
async ({ pluginId, pluginPath }) => {
|
|
273
|
+
rootLogger.info(
|
|
274
|
+
`📥 Received installation request for: ${pluginId} at ${pluginPath}`
|
|
275
|
+
);
|
|
276
|
+
await this.loadSinglePlugin(pluginId, pluginPath);
|
|
277
|
+
}
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
rootLogger.debug("🔗 Lifecycle listeners registered");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async getService<T>(ref: ServiceRef<T>): Promise<T | undefined> {
|
|
284
|
+
try {
|
|
285
|
+
return await this.registry.get(ref, { pluginId: "core" });
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
registerService<T>(ref: ServiceRef<T>, impl: T) {
|
|
292
|
+
this.registry.register(ref, impl);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Load a single plugin at runtime (for dynamic installation).
|
|
297
|
+
* If the plugin isn't available locally, it will be installed via npm first.
|
|
298
|
+
* This imports the plugin module, registers it, and initializes it.
|
|
299
|
+
*
|
|
300
|
+
* @param pluginId - The plugin ID (package name)
|
|
301
|
+
* @param pluginPath - The expected path (may not exist yet on this instance)
|
|
302
|
+
*/
|
|
303
|
+
async loadSinglePlugin(pluginId: string, pluginPath: string): Promise<void> {
|
|
304
|
+
rootLogger.info(`🔌 Loading plugin at runtime: ${pluginId}`);
|
|
305
|
+
|
|
306
|
+
// Emit instance-local installing hook
|
|
307
|
+
const eventBus = await this.registry.get(coreServices.eventBus, {
|
|
308
|
+
pluginId: "core",
|
|
309
|
+
});
|
|
310
|
+
await eventBus.emitLocal(coreHooks.pluginInstalling, { pluginId });
|
|
311
|
+
|
|
312
|
+
try {
|
|
313
|
+
// 1. Try to import the plugin - if it fails, install it first
|
|
314
|
+
let pluginModule;
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
// Try importing by package name first
|
|
318
|
+
pluginModule = await import(pluginId);
|
|
319
|
+
} catch {
|
|
320
|
+
try {
|
|
321
|
+
// Try importing by path
|
|
322
|
+
pluginModule = await import(pluginPath);
|
|
323
|
+
} catch {
|
|
324
|
+
// Plugin not available locally - need to install it
|
|
325
|
+
rootLogger.info(
|
|
326
|
+
` -> Plugin ${pluginId} not found locally, installing via npm...`
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const installer = await this.registry.get(
|
|
330
|
+
coreServices.pluginInstaller,
|
|
331
|
+
{ pluginId: "core" }
|
|
332
|
+
);
|
|
333
|
+
const result = await installer.install(pluginId);
|
|
334
|
+
|
|
335
|
+
// Now try importing again
|
|
336
|
+
try {
|
|
337
|
+
pluginModule = await import(result.name);
|
|
338
|
+
} catch {
|
|
339
|
+
pluginModule = await import(result.path);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const backendPlugin: BackendPlugin = pluginModule.default;
|
|
345
|
+
|
|
346
|
+
if (!backendPlugin || typeof backendPlugin.register !== "function") {
|
|
347
|
+
throw new Error(
|
|
348
|
+
`Plugin ${pluginId} does not export a valid BackendPlugin`
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const metaPluginId = backendPlugin.metadata.pluginId;
|
|
353
|
+
|
|
354
|
+
// Store metadata for request-time context injection
|
|
355
|
+
this.pluginMetadataRegistry.set(metaPluginId, backendPlugin.metadata);
|
|
356
|
+
|
|
357
|
+
// 2. Register plugin (Phase 1)
|
|
358
|
+
const pendingInits: { pluginId: string; init: () => Promise<void> }[] =
|
|
359
|
+
[];
|
|
360
|
+
|
|
361
|
+
backendPlugin.register({
|
|
362
|
+
registerInit: (args) => {
|
|
363
|
+
pendingInits.push({
|
|
364
|
+
pluginId: metaPluginId,
|
|
365
|
+
init: async () => {
|
|
366
|
+
// Resolve dependencies
|
|
367
|
+
const resolvedDeps: Record<string, unknown> = {};
|
|
368
|
+
for (const [key, ref] of Object.entries(args.deps)) {
|
|
369
|
+
resolvedDeps[key] = await this.registry.get(
|
|
370
|
+
ref as ServiceRef<unknown>,
|
|
371
|
+
backendPlugin.metadata
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
await args.init(resolvedDeps as never);
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
},
|
|
378
|
+
registerPermissions: (permissions) => {
|
|
379
|
+
const prefixed = permissions.map((p) => ({
|
|
380
|
+
...p,
|
|
381
|
+
id: `${metaPluginId}.${p.id}`,
|
|
382
|
+
pluginId: metaPluginId,
|
|
383
|
+
}));
|
|
384
|
+
this.registeredPermissions.push(...prefixed);
|
|
385
|
+
|
|
386
|
+
// Emit permission hook
|
|
387
|
+
eventBus.emit(coreHooks.permissionsRegistered, {
|
|
388
|
+
pluginId: metaPluginId,
|
|
389
|
+
permissions: prefixed,
|
|
390
|
+
});
|
|
391
|
+
},
|
|
392
|
+
registerService: (ref, impl) => {
|
|
393
|
+
this.registry.register(ref, impl);
|
|
394
|
+
},
|
|
395
|
+
registerExtensionPoint: (ref, impl) => {
|
|
396
|
+
this.extensionPointManager.registerExtensionPoint(ref, impl);
|
|
397
|
+
},
|
|
398
|
+
registerCleanup: (cleanup) => {
|
|
399
|
+
const handlers = this.cleanupHandlers.get(metaPluginId) || [];
|
|
400
|
+
handlers.push(cleanup);
|
|
401
|
+
this.cleanupHandlers.set(metaPluginId, handlers);
|
|
402
|
+
},
|
|
403
|
+
getExtensionPoint: <T>(ref: ExtensionPoint<T>) =>
|
|
404
|
+
this.extensionPointManager.getExtensionPoint(ref),
|
|
405
|
+
registerRouter: (router: unknown, contract: AnyContractRouter) => {
|
|
406
|
+
this.pluginRpcRouters.set(metaPluginId, router);
|
|
407
|
+
this.pluginContractRegistry.set(metaPluginId, contract);
|
|
408
|
+
},
|
|
409
|
+
pluginManager: {
|
|
410
|
+
getAllPermissions: () => this.getAllPermissions(),
|
|
411
|
+
},
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
// 3. Initialize plugin (Phase 2)
|
|
415
|
+
for (const pending of pendingInits) {
|
|
416
|
+
await pending.init();
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 4. Emit pluginInitialized
|
|
420
|
+
await eventBus.emit(coreHooks.pluginInitialized, { pluginId });
|
|
421
|
+
|
|
422
|
+
// 5. Emit pluginInstalled
|
|
423
|
+
await eventBus.emit(coreHooks.pluginInstalled, { pluginId });
|
|
424
|
+
|
|
425
|
+
rootLogger.info(`✅ Plugin loaded at runtime: ${pluginId}`);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
rootLogger.error(`❌ Failed to load plugin ${pluginId}:`, error);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { PluginManager } from "./plugin-manager";
|
|
3
|
+
import { coreServices, os, RpcContext } from "@checkstack/backend-api";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { createMockDbModule } from "@checkstack/test-utils-backend";
|
|
6
|
+
import { createMockLoggerModule } from "@checkstack/test-utils-backend";
|
|
7
|
+
|
|
8
|
+
// Mock DB and other globals
|
|
9
|
+
mock.module("./db", () => createMockDbModule());
|
|
10
|
+
|
|
11
|
+
mock.module("./logger", () => createMockLoggerModule());
|
|
12
|
+
|
|
13
|
+
describe("RPC REST Compatibility", () => {
|
|
14
|
+
let pluginManager: PluginManager;
|
|
15
|
+
let app: Hono;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
pluginManager = new PluginManager();
|
|
19
|
+
app = new Hono();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should handle GET /api/auth/permissions via oRPC router", async () => {
|
|
23
|
+
// 1. Setup a mock auth router
|
|
24
|
+
const authRouter = os.router({
|
|
25
|
+
permissions: os.handler(async () => {
|
|
26
|
+
return { permissions: ["test-perm"] };
|
|
27
|
+
}),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
// 2. Register it in plugin manager (now using new pattern - router AND contract required)
|
|
31
|
+
// Note: In real usage, the path is derived from pluginId. For test, we manually set it.
|
|
32
|
+
const rpcService = await pluginManager.getService(coreServices.rpc);
|
|
33
|
+
// The new API auto-prefixes based on pluginId, but for test we need to manually set the map key
|
|
34
|
+
// Since we're testing the router handler directly, we use the derived name "auth"
|
|
35
|
+
// Second argument is the contract (for OpenAPI generation) - using a mock object for test
|
|
36
|
+
rpcService?.registerRouter(authRouter, { permissions: {} });
|
|
37
|
+
|
|
38
|
+
// 3. Mock the auth service to skip real authentication
|
|
39
|
+
const mockAuth: any = {
|
|
40
|
+
authenticate: mock(async () => ({ id: "user-1", permissions: ["*"] })),
|
|
41
|
+
};
|
|
42
|
+
pluginManager.registerService(coreServices.auth, mockAuth);
|
|
43
|
+
|
|
44
|
+
// Register other dummy services needed for context
|
|
45
|
+
pluginManager.registerService(coreServices.logger, {
|
|
46
|
+
info: mock(),
|
|
47
|
+
debug: mock(),
|
|
48
|
+
error: mock(),
|
|
49
|
+
warn: mock(),
|
|
50
|
+
} as any);
|
|
51
|
+
pluginManager.registerService(coreServices.database, {} as any);
|
|
52
|
+
pluginManager.registerService(coreServices.fetch, {} as any);
|
|
53
|
+
pluginManager.registerService(coreServices.healthCheckRegistry, {} as any);
|
|
54
|
+
pluginManager.registerService(coreServices.queuePluginRegistry, {
|
|
55
|
+
getPlugins: () => [],
|
|
56
|
+
} as any);
|
|
57
|
+
pluginManager.registerService(coreServices.queueManager, {
|
|
58
|
+
getActivePlugin: () => "none",
|
|
59
|
+
getQueue: () => ({}),
|
|
60
|
+
} as any);
|
|
61
|
+
|
|
62
|
+
// 4. Mount the plugins
|
|
63
|
+
await pluginManager.loadPlugins(app);
|
|
64
|
+
|
|
65
|
+
// 5. Simulate the request that frontend makes (now /api/auth instead of /api/auth-backend)
|
|
66
|
+
const res = await app.request("/api/auth/permissions", {
|
|
67
|
+
method: "GET",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// If it's a 404, my theory about dots vs slashes or GET vs POST is correct
|
|
71
|
+
console.log("Response status:", res.status);
|
|
72
|
+
if (res.status === 200) {
|
|
73
|
+
const body = await res.json();
|
|
74
|
+
console.log("Response body:", JSON.stringify(body));
|
|
75
|
+
expect(body.permissions).toContain("test-perm");
|
|
76
|
+
} else {
|
|
77
|
+
console.log("Response text:", await res.text());
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
text,
|
|
4
|
+
boolean,
|
|
5
|
+
json,
|
|
6
|
+
serial,
|
|
7
|
+
timestamp,
|
|
8
|
+
jsonb,
|
|
9
|
+
primaryKey,
|
|
10
|
+
} from "drizzle-orm/pg-core";
|
|
11
|
+
|
|
12
|
+
// --- Plugin System Schema ---
|
|
13
|
+
export const plugins = pgTable("plugins", {
|
|
14
|
+
id: serial("id").primaryKey(),
|
|
15
|
+
name: text("name").notNull().unique(),
|
|
16
|
+
path: text("path").notNull(),
|
|
17
|
+
isUninstallable: boolean("is_uninstallable").default(false).notNull(),
|
|
18
|
+
config: json("config").default({}),
|
|
19
|
+
enabled: boolean("enabled").default(true).notNull(),
|
|
20
|
+
type: text("type").default("backend").notNull(),
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// --- JWT Key Store Schema ---
|
|
24
|
+
export const jwtKeys = pgTable("jwt_keys", {
|
|
25
|
+
id: text("id").primaryKey(), // The "kid"
|
|
26
|
+
publicKey: text("public_key").notNull(), // JWK JSON string
|
|
27
|
+
privateKey: text("private_key").notNull(), // Encrypted JWK JSON string (or plain if env is secure)
|
|
28
|
+
algorithm: text("algorithm").notNull(), // e.g. RS256
|
|
29
|
+
createdAt: text("created_at").notNull(), // ISO string
|
|
30
|
+
expiresAt: text("expires_at"), // ISO string, null if indefinite
|
|
31
|
+
revokedAt: text("revoked_at"), // ISO string, null if valid
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// --- Plugin Configs Schema ---
|
|
35
|
+
export const pluginConfigs = pgTable(
|
|
36
|
+
"plugin_configs",
|
|
37
|
+
{
|
|
38
|
+
pluginId: text("plugin_id").notNull(),
|
|
39
|
+
configId: text("config_id").notNull(),
|
|
40
|
+
data: jsonb("data").notNull(), // Stores VersionedConfig with encrypted secrets
|
|
41
|
+
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
|
42
|
+
},
|
|
43
|
+
(table) => ({
|
|
44
|
+
pk: primaryKey({ columns: [table.pluginId, table.configId] }),
|
|
45
|
+
})
|
|
46
|
+
);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { configString, isSecretSchema } from "@checkstack/backend-api";
|
|
4
|
+
import { encrypt, decrypt, isEncrypted } from "@checkstack/backend-api";
|
|
5
|
+
|
|
6
|
+
describe("Secret Detection", () => {
|
|
7
|
+
it("should detect direct secret fields", () => {
|
|
8
|
+
const schema = configString({ "x-secret": true });
|
|
9
|
+
expect(isSecretSchema(schema)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("should detect optional secret fields", () => {
|
|
13
|
+
const schema = configString({ "x-secret": true }).optional();
|
|
14
|
+
expect(isSecretSchema(schema)).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("should not detect regular string fields", () => {
|
|
18
|
+
const schema = z.string();
|
|
19
|
+
expect(isSecretSchema(schema)).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should not detect optional regular string fields", () => {
|
|
23
|
+
const schema = z.string().optional();
|
|
24
|
+
expect(isSecretSchema(schema)).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("Encryption and Decryption", () => {
|
|
29
|
+
beforeAll(() => {
|
|
30
|
+
// Set a test encryption key (32 bytes = 64 hex chars)
|
|
31
|
+
process.env.ENCRYPTION_MASTER_KEY =
|
|
32
|
+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should encrypt and decrypt a secret value", () => {
|
|
36
|
+
const plaintext = "my-secret-value";
|
|
37
|
+
const encrypted = encrypt(plaintext);
|
|
38
|
+
|
|
39
|
+
expect(encrypted).not.toBe(plaintext);
|
|
40
|
+
expect(isEncrypted(encrypted)).toBe(true);
|
|
41
|
+
|
|
42
|
+
const decrypted = decrypt(encrypted);
|
|
43
|
+
expect(decrypted).toBe(plaintext);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("should produce different ciphertexts for the same plaintext", () => {
|
|
47
|
+
const plaintext = "same-secret";
|
|
48
|
+
const encrypted1 = encrypt(plaintext);
|
|
49
|
+
const encrypted2 = encrypt(plaintext);
|
|
50
|
+
|
|
51
|
+
// Different IVs should produce different ciphertexts
|
|
52
|
+
expect(encrypted1).not.toBe(encrypted2);
|
|
53
|
+
|
|
54
|
+
// But both should decrypt to the same value
|
|
55
|
+
expect(decrypt(encrypted1)).toBe(plaintext);
|
|
56
|
+
expect(decrypt(encrypted2)).toBe(plaintext);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("should correctly identify encrypted vs plaintext values", () => {
|
|
60
|
+
const plaintext = "not-encrypted";
|
|
61
|
+
const encrypted = encrypt(plaintext);
|
|
62
|
+
|
|
63
|
+
expect(isEncrypted(plaintext)).toBe(false);
|
|
64
|
+
expect(isEncrypted(encrypted)).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
});
|