@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,312 @@
|
|
|
1
|
+
import { Pool } from "pg";
|
|
2
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { createORPCClient } from "@orpc/client";
|
|
4
|
+
import { RPCLink } from "@orpc/client/fetch";
|
|
5
|
+
import {
|
|
6
|
+
coreServices,
|
|
7
|
+
AuthService,
|
|
8
|
+
authenticationStrategyServiceRef,
|
|
9
|
+
RpcService,
|
|
10
|
+
RpcClient,
|
|
11
|
+
EventBus as IEventBus,
|
|
12
|
+
AuthenticationStrategy,
|
|
13
|
+
} from "@checkstack/backend-api";
|
|
14
|
+
import { AuthApi } from "@checkstack/auth-common";
|
|
15
|
+
import type { ServiceRegistry } from "../services/service-registry";
|
|
16
|
+
import { rootLogger } from "../logger";
|
|
17
|
+
import { db } from "../db";
|
|
18
|
+
import { jwtService } from "../services/jwt";
|
|
19
|
+
import { CoreHealthCheckRegistry } from "../services/health-check-registry";
|
|
20
|
+
import { EventBus } from "../services/event-bus.js";
|
|
21
|
+
import { getPluginSchemaName } from "@checkstack/drizzle-helper";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check if a PostgreSQL schema exists.
|
|
25
|
+
*/
|
|
26
|
+
async function schemaExists(pool: Pool, schemaName: string): Promise<boolean> {
|
|
27
|
+
const result = await pool.query(
|
|
28
|
+
"SELECT 1 FROM information_schema.schemata WHERE schema_name = $1",
|
|
29
|
+
[schemaName]
|
|
30
|
+
);
|
|
31
|
+
return result.rows.length > 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Registers all core services with the service registry.
|
|
36
|
+
* Extracted from PluginManager for better organization.
|
|
37
|
+
*/
|
|
38
|
+
export function registerCoreServices({
|
|
39
|
+
registry,
|
|
40
|
+
adminPool,
|
|
41
|
+
pluginRpcRouters,
|
|
42
|
+
pluginHttpHandlers,
|
|
43
|
+
pluginContractRegistry,
|
|
44
|
+
}: {
|
|
45
|
+
registry: ServiceRegistry;
|
|
46
|
+
adminPool: Pool;
|
|
47
|
+
pluginRpcRouters: Map<string, unknown>;
|
|
48
|
+
pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
|
|
49
|
+
pluginContractRegistry: Map<string, unknown>;
|
|
50
|
+
}) {
|
|
51
|
+
// 1. Database Factory (Scoped)
|
|
52
|
+
registry.registerFactory(coreServices.database, async (metadata) => {
|
|
53
|
+
const { pluginId, previousPluginIds } = metadata;
|
|
54
|
+
const assignedSchema = getPluginSchemaName(pluginId);
|
|
55
|
+
|
|
56
|
+
// Pre-flight: Check if this is a schema rename scenario
|
|
57
|
+
if (previousPluginIds && previousPluginIds.length > 0) {
|
|
58
|
+
for (const oldId of previousPluginIds) {
|
|
59
|
+
const oldSchema = getPluginSchemaName(oldId);
|
|
60
|
+
const oldExists = await schemaExists(adminPool, oldSchema);
|
|
61
|
+
const newExists = await schemaExists(adminPool, assignedSchema);
|
|
62
|
+
|
|
63
|
+
if (oldExists && !newExists) {
|
|
64
|
+
rootLogger.info(
|
|
65
|
+
`🔄 Renaming schema ${oldSchema} → ${assignedSchema} for plugin ${pluginId}`
|
|
66
|
+
);
|
|
67
|
+
await adminPool.query(
|
|
68
|
+
`ALTER SCHEMA "${oldSchema}" RENAME TO "${assignedSchema}"`
|
|
69
|
+
);
|
|
70
|
+
break; // Only one rename needed
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Ensure Schema Exists (creates if not already renamed/created)
|
|
76
|
+
await adminPool.query(`CREATE SCHEMA IF NOT EXISTS "${assignedSchema}"`);
|
|
77
|
+
|
|
78
|
+
// Create Scoped Connection
|
|
79
|
+
const baseUrl = process.env.DATABASE_URL;
|
|
80
|
+
if (!baseUrl) throw new Error("DATABASE_URL is not defined");
|
|
81
|
+
|
|
82
|
+
const connector = baseUrl.includes("?") ? "&" : "?";
|
|
83
|
+
const scopedUrl = `${baseUrl}${connector}options=-c%20search_path%3D${assignedSchema}`;
|
|
84
|
+
|
|
85
|
+
const pluginPool = new Pool({ connectionString: scopedUrl });
|
|
86
|
+
return drizzle(pluginPool);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// 2. Logger Factory
|
|
90
|
+
registry.registerFactory(coreServices.logger, (metadata) => {
|
|
91
|
+
return rootLogger.child({ plugin: metadata.pluginId });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// 3. Auth Factory (Scoped)
|
|
95
|
+
// Cache for anonymous permissions to avoid repeated DB queries
|
|
96
|
+
let anonymousPermissionsCache: string[] | undefined;
|
|
97
|
+
let anonymousCacheTime = 0;
|
|
98
|
+
const CACHE_TTL_MS = 60_000; // 1 minute cache
|
|
99
|
+
|
|
100
|
+
registry.registerFactory(coreServices.auth, (metadata) => {
|
|
101
|
+
const { pluginId } = metadata;
|
|
102
|
+
const authService: AuthService = {
|
|
103
|
+
authenticate: async (request: Request) => {
|
|
104
|
+
const authHeader = request.headers.get("Authorization");
|
|
105
|
+
const token = authHeader?.replace("Bearer ", "");
|
|
106
|
+
|
|
107
|
+
// Strategy A: Service Token (backend-to-backend)
|
|
108
|
+
if (token) {
|
|
109
|
+
const payload = await jwtService.verify(token);
|
|
110
|
+
if (payload && payload.service) {
|
|
111
|
+
// Service tokens return ServiceUser type
|
|
112
|
+
return {
|
|
113
|
+
type: "service" as const,
|
|
114
|
+
pluginId: payload.service as string,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Strategy B: User Token (via registered strategy)
|
|
120
|
+
try {
|
|
121
|
+
const authStrategy = await registry.get(
|
|
122
|
+
authenticationStrategyServiceRef,
|
|
123
|
+
metadata
|
|
124
|
+
);
|
|
125
|
+
if (authStrategy) {
|
|
126
|
+
// AuthenticationStrategy.validate() returns RealUser | undefined
|
|
127
|
+
return await (authStrategy as AuthenticationStrategy).validate(
|
|
128
|
+
request
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
// No strategy registered yet
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
getCredentials: async () => {
|
|
137
|
+
const token = await jwtService.sign({ service: pluginId }, "5m");
|
|
138
|
+
return { headers: { Authorization: `Bearer ${token}` } };
|
|
139
|
+
},
|
|
140
|
+
|
|
141
|
+
getAnonymousPermissions: async (): Promise<string[]> => {
|
|
142
|
+
const now = Date.now();
|
|
143
|
+
// Return cached value if still valid
|
|
144
|
+
if (
|
|
145
|
+
anonymousPermissionsCache !== undefined &&
|
|
146
|
+
now - anonymousCacheTime < CACHE_TTL_MS
|
|
147
|
+
) {
|
|
148
|
+
return anonymousPermissionsCache;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Use RPC client to call auth-backend's getAnonymousPermissions endpoint
|
|
152
|
+
try {
|
|
153
|
+
const rpcClient = await registry.get(coreServices.rpcClient, {
|
|
154
|
+
pluginId: "core",
|
|
155
|
+
});
|
|
156
|
+
const authClient = rpcClient.forPlugin(AuthApi);
|
|
157
|
+
const permissions = await authClient.getAnonymousPermissions();
|
|
158
|
+
|
|
159
|
+
// Update cache
|
|
160
|
+
anonymousPermissionsCache = permissions;
|
|
161
|
+
anonymousCacheTime = now;
|
|
162
|
+
|
|
163
|
+
return permissions;
|
|
164
|
+
} catch (error) {
|
|
165
|
+
// RPC client not available yet (during startup), return empty
|
|
166
|
+
rootLogger.warn(
|
|
167
|
+
`[auth] getAnonymousPermissions: RPC failed, returning empty array. Error: ${error}`
|
|
168
|
+
);
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
return authService;
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// 4. Fetch Factory (Scoped)
|
|
177
|
+
registry.registerFactory(coreServices.fetch, async (metadata) => {
|
|
178
|
+
const auth = await registry.get(coreServices.auth, metadata);
|
|
179
|
+
const apiBaseUrl = process.env.INTERNAL_URL || "http://localhost:3000";
|
|
180
|
+
|
|
181
|
+
const fetchWithAuth = async (
|
|
182
|
+
input: RequestInfo | URL,
|
|
183
|
+
init?: RequestInit
|
|
184
|
+
) => {
|
|
185
|
+
const { headers: authHeaders } = await auth.getCredentials();
|
|
186
|
+
const mergedHeaders = new Headers(init?.headers);
|
|
187
|
+
for (const [k, v] of Object.entries(authHeaders)) {
|
|
188
|
+
mergedHeaders.set(k, v);
|
|
189
|
+
}
|
|
190
|
+
return fetch(input, { ...init, headers: mergedHeaders });
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const forPlugin = (targetPluginId: string) => {
|
|
194
|
+
const pluginBaseUrl = `${apiBaseUrl}/api/${targetPluginId}`;
|
|
195
|
+
|
|
196
|
+
const pluginFetch = async (path: string, init?: RequestInit) => {
|
|
197
|
+
const url = `${pluginBaseUrl}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
198
|
+
return fetchWithAuth(url, init);
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
return {
|
|
202
|
+
fetch: pluginFetch,
|
|
203
|
+
get: (path: string, init?: RequestInit) =>
|
|
204
|
+
pluginFetch(path, { ...init, method: "GET" }),
|
|
205
|
+
post: (path: string, body?: unknown, init?: RequestInit) =>
|
|
206
|
+
pluginFetch(path, {
|
|
207
|
+
...init,
|
|
208
|
+
method: "POST",
|
|
209
|
+
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
210
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
211
|
+
}),
|
|
212
|
+
put: (path: string, body?: unknown, init?: RequestInit) =>
|
|
213
|
+
pluginFetch(path, {
|
|
214
|
+
...init,
|
|
215
|
+
method: "PUT",
|
|
216
|
+
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
217
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
218
|
+
}),
|
|
219
|
+
patch: (path: string, body?: unknown, init?: RequestInit) =>
|
|
220
|
+
pluginFetch(path, {
|
|
221
|
+
...init,
|
|
222
|
+
method: "PATCH",
|
|
223
|
+
headers: { "Content-Type": "application/json", ...init?.headers },
|
|
224
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
225
|
+
}),
|
|
226
|
+
delete: (path: string, init?: RequestInit) =>
|
|
227
|
+
pluginFetch(path, { ...init, method: "DELETE" }),
|
|
228
|
+
};
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
fetch: fetchWithAuth,
|
|
233
|
+
forPlugin,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// 5. RPC Client Factory (Scoped, Typed)
|
|
238
|
+
registry.registerFactory(coreServices.rpcClient, async (metadata) => {
|
|
239
|
+
const fetchService = await registry.get(coreServices.fetch, metadata);
|
|
240
|
+
const apiBaseUrl = process.env.INTERNAL_URL || "http://localhost:3000";
|
|
241
|
+
|
|
242
|
+
// Create RPC Link using the fetch service (already has auth)
|
|
243
|
+
const link = new RPCLink({
|
|
244
|
+
url: `${apiBaseUrl}/api`,
|
|
245
|
+
fetch: fetchService.fetch,
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const client = createORPCClient(link);
|
|
249
|
+
|
|
250
|
+
const rpcClient: RpcClient = {
|
|
251
|
+
forPlugin(def) {
|
|
252
|
+
// Type safety is provided by the RpcClient interface - InferClient<T>
|
|
253
|
+
// extracts the typed client from the ClientDefinition passed in
|
|
254
|
+
return (client as Record<string, unknown>)[def.pluginId] as never;
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return rpcClient;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// 6. Health Check Registry (Global Singleton)
|
|
262
|
+
const healthCheckRegistry = new CoreHealthCheckRegistry();
|
|
263
|
+
registry.registerFactory(
|
|
264
|
+
coreServices.healthCheckRegistry,
|
|
265
|
+
() => healthCheckRegistry
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// 7. RPC Service (Scoped Factory - uses pluginId for path derivation)
|
|
269
|
+
registry.registerFactory(coreServices.rpc, (metadata) => {
|
|
270
|
+
const { pluginId } = metadata;
|
|
271
|
+
return {
|
|
272
|
+
registerRouter: (router: unknown, contract: unknown): void => {
|
|
273
|
+
pluginRpcRouters.set(pluginId, router);
|
|
274
|
+
pluginContractRegistry.set(pluginId, contract);
|
|
275
|
+
rootLogger.debug(
|
|
276
|
+
` -> Registered oRPC router and contract for '${pluginId}' at '/api/${pluginId}'`
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
registerHttpHandler: (
|
|
280
|
+
handler: (req: Request) => Promise<Response>,
|
|
281
|
+
path = "/"
|
|
282
|
+
): void => {
|
|
283
|
+
const fullPath = `/api/${pluginId}${path === "/" ? "" : path}`;
|
|
284
|
+
pluginHttpHandlers.set(fullPath, handler);
|
|
285
|
+
rootLogger.debug(
|
|
286
|
+
` -> Registered HTTP handler for '${pluginId}' at '${fullPath}'`
|
|
287
|
+
);
|
|
288
|
+
},
|
|
289
|
+
} satisfies RpcService;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// 8. Config Service (Scoped Factory)
|
|
293
|
+
registry.registerFactory(coreServices.config, async (metadata) => {
|
|
294
|
+
const { ConfigServiceImpl } = await import("../services/config-service.js");
|
|
295
|
+
return new ConfigServiceImpl(metadata.pluginId, db);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// 9. EventBus (Global Singleton)
|
|
299
|
+
let eventBusInstance: IEventBus | undefined;
|
|
300
|
+
registry.registerFactory(coreServices.eventBus, async () => {
|
|
301
|
+
if (!eventBusInstance) {
|
|
302
|
+
const queueManager = await registry.get(coreServices.queueManager, {
|
|
303
|
+
pluginId: "core",
|
|
304
|
+
});
|
|
305
|
+
const logger = await registry.get(coreServices.logger, {
|
|
306
|
+
pluginId: "core",
|
|
307
|
+
});
|
|
308
|
+
eventBusInstance = new EventBus(queueManager, logger);
|
|
309
|
+
}
|
|
310
|
+
return eventBusInstance;
|
|
311
|
+
});
|
|
312
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { ServiceRef, Logger } from "@checkstack/backend-api";
|
|
2
|
+
import type { PluginMetadata } from "@checkstack/common";
|
|
3
|
+
import { coreServices } from "@checkstack/backend-api";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Topologically sorts plugins based on their dependencies.
|
|
7
|
+
* Pure function - no class dependencies.
|
|
8
|
+
*/
|
|
9
|
+
export function sortPlugins({
|
|
10
|
+
pendingInits,
|
|
11
|
+
providedBy,
|
|
12
|
+
logger,
|
|
13
|
+
}: {
|
|
14
|
+
pendingInits: {
|
|
15
|
+
metadata: PluginMetadata;
|
|
16
|
+
deps: Record<string, ServiceRef<unknown>>;
|
|
17
|
+
}[];
|
|
18
|
+
providedBy: Map<string, string>;
|
|
19
|
+
logger: Logger;
|
|
20
|
+
}): string[] {
|
|
21
|
+
logger.debug("🔄 Calculating initialization order...");
|
|
22
|
+
|
|
23
|
+
const inDegree = new Map<string, number>();
|
|
24
|
+
const graph = new Map<string, string[]>();
|
|
25
|
+
|
|
26
|
+
for (const p of pendingInits) {
|
|
27
|
+
inDegree.set(p.metadata.pluginId, 0);
|
|
28
|
+
graph.set(p.metadata.pluginId, []);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Track queue plugin providers (plugins that depend on queuePluginRegistry)
|
|
32
|
+
const queuePluginProviders = new Set<string>();
|
|
33
|
+
for (const p of pendingInits) {
|
|
34
|
+
for (const [, ref] of Object.entries(p.deps)) {
|
|
35
|
+
if (ref.id === coreServices.queuePluginRegistry.id) {
|
|
36
|
+
queuePluginProviders.add(p.metadata.pluginId);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Build dependency graph
|
|
42
|
+
for (const p of pendingInits) {
|
|
43
|
+
const consumerId = p.metadata.pluginId;
|
|
44
|
+
for (const [, ref] of Object.entries(p.deps)) {
|
|
45
|
+
const serviceId = ref.id;
|
|
46
|
+
const providerId = providedBy.get(serviceId);
|
|
47
|
+
|
|
48
|
+
if (providerId && providerId !== consumerId) {
|
|
49
|
+
if (!graph.has(providerId)) {
|
|
50
|
+
graph.set(providerId, []);
|
|
51
|
+
}
|
|
52
|
+
graph.get(providerId)!.push(consumerId);
|
|
53
|
+
inDegree.set(consumerId, (inDegree.get(consumerId) || 0) + 1);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Special handling: if this plugin uses queueManager, it must wait for all queue plugin providers
|
|
58
|
+
const usesQueueManager = Object.values(p.deps).some(
|
|
59
|
+
(ref) => ref.id === coreServices.queueManager.id
|
|
60
|
+
);
|
|
61
|
+
if (usesQueueManager) {
|
|
62
|
+
for (const qpp of queuePluginProviders) {
|
|
63
|
+
if (qpp !== consumerId) {
|
|
64
|
+
if (!graph.has(qpp)) {
|
|
65
|
+
graph.set(qpp, []);
|
|
66
|
+
}
|
|
67
|
+
// Add edge: queue plugin provider -> queue consumer
|
|
68
|
+
if (!graph.get(qpp)!.includes(consumerId)) {
|
|
69
|
+
graph.get(qpp)!.push(consumerId);
|
|
70
|
+
inDegree.set(consumerId, (inDegree.get(consumerId) || 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const queue: string[] = [];
|
|
78
|
+
for (const [id, count] of inDegree.entries()) {
|
|
79
|
+
if (count === 0) {
|
|
80
|
+
queue.push(id);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sortedIds: string[] = [];
|
|
85
|
+
while (queue.length > 0) {
|
|
86
|
+
const u = queue.shift()!;
|
|
87
|
+
sortedIds.push(u);
|
|
88
|
+
|
|
89
|
+
const dependents = graph.get(u) || [];
|
|
90
|
+
for (const v of dependents) {
|
|
91
|
+
inDegree.set(v, inDegree.get(v)! - 1);
|
|
92
|
+
if (inDegree.get(v) === 0) {
|
|
93
|
+
queue.push(v);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (sortedIds.length !== pendingInits.length) {
|
|
99
|
+
throw new Error("Circular dependency detected");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return sortedIds;
|
|
103
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { ORPCError } from "@orpc/server";
|
|
4
|
+
import { plugins } from "../schema";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Validates that a plugin can be deregistered.
|
|
8
|
+
* throws ORPCError if the plugin is not uninstallable or has dependents.
|
|
9
|
+
*/
|
|
10
|
+
export async function assertCanDeregister({
|
|
11
|
+
pluginId,
|
|
12
|
+
db,
|
|
13
|
+
}: {
|
|
14
|
+
pluginId: string;
|
|
15
|
+
db: NodePgDatabase<Record<string, unknown>>;
|
|
16
|
+
}): Promise<void> {
|
|
17
|
+
// 1. Check if plugin exists
|
|
18
|
+
const pluginRows = await db
|
|
19
|
+
.select()
|
|
20
|
+
.from(plugins)
|
|
21
|
+
.where(eq(plugins.name, pluginId));
|
|
22
|
+
|
|
23
|
+
if (pluginRows.length === 0) {
|
|
24
|
+
throw new ORPCError("NOT_FOUND", {
|
|
25
|
+
message: `Plugin "${pluginId}" not found`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const plugin = pluginRows[0];
|
|
30
|
+
|
|
31
|
+
// 2. Check isUninstallable flag
|
|
32
|
+
if (!plugin.isUninstallable) {
|
|
33
|
+
throw new ORPCError("FORBIDDEN", {
|
|
34
|
+
message: `Plugin "${pluginId}" is a core platform component and cannot be uninstalled`,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 3. TODO: Check for dependent plugins (consumers of this plugin's services)
|
|
39
|
+
// This would require tracking service dependencies at runtime
|
|
40
|
+
// For now, we skip this check and let the deregistration proceed
|
|
41
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { ExtensionPoint } from "@checkstack/backend-api";
|
|
2
|
+
import { rootLogger } from "../logger";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Creates an extension point manager that handles proxy creation and buffering.
|
|
6
|
+
*/
|
|
7
|
+
export function createExtensionPointManager() {
|
|
8
|
+
const extensionPointProxies = new Map<string, unknown>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get or create a proxy for an extension point.
|
|
12
|
+
* Buffers calls until implementation is set.
|
|
13
|
+
*/
|
|
14
|
+
function getExtensionPointProxy<T>(ref: ExtensionPoint<T>): T {
|
|
15
|
+
let proxy = extensionPointProxies.get(ref.id) as T | undefined;
|
|
16
|
+
if (!proxy) {
|
|
17
|
+
const buffer: { method: string | symbol; args: unknown[] }[] = [];
|
|
18
|
+
let implementation: T | undefined;
|
|
19
|
+
|
|
20
|
+
proxy = new Proxy(
|
|
21
|
+
{},
|
|
22
|
+
{
|
|
23
|
+
get: (target, prop) => {
|
|
24
|
+
if (prop === "$$setImplementation") {
|
|
25
|
+
return (impl: T) => {
|
|
26
|
+
implementation = impl;
|
|
27
|
+
for (const call of buffer) {
|
|
28
|
+
(
|
|
29
|
+
implementation as Record<
|
|
30
|
+
string | symbol,
|
|
31
|
+
(...args: unknown[]) => unknown
|
|
32
|
+
>
|
|
33
|
+
)[call.method](...call.args);
|
|
34
|
+
}
|
|
35
|
+
buffer.length = 0;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
return (...args: unknown[]) => {
|
|
39
|
+
if (implementation) {
|
|
40
|
+
return (
|
|
41
|
+
implementation as Record<
|
|
42
|
+
string | symbol,
|
|
43
|
+
(...args: unknown[]) => unknown
|
|
44
|
+
>
|
|
45
|
+
)[prop](...args);
|
|
46
|
+
} else {
|
|
47
|
+
buffer.push({ method: prop, args });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
},
|
|
51
|
+
}
|
|
52
|
+
) as T;
|
|
53
|
+
extensionPointProxies.set(ref.id, proxy);
|
|
54
|
+
}
|
|
55
|
+
return proxy;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Register an extension point implementation.
|
|
60
|
+
*/
|
|
61
|
+
function registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T) {
|
|
62
|
+
const proxy = getExtensionPointProxy(ref);
|
|
63
|
+
(proxy as Record<string, (...args: unknown[]) => unknown>)[
|
|
64
|
+
"$$setImplementation"
|
|
65
|
+
](impl);
|
|
66
|
+
rootLogger.debug(` -> Registered extension point '${ref.id}'`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get an extension point (returns the proxy).
|
|
71
|
+
*/
|
|
72
|
+
function getExtensionPoint<T>(ref: ExtensionPoint<T>): T {
|
|
73
|
+
return getExtensionPointProxy(ref);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
getExtensionPointProxy,
|
|
78
|
+
registerExtensionPoint,
|
|
79
|
+
getExtensionPoint,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type ExtensionPointManager = ReturnType<
|
|
84
|
+
typeof createExtensionPointManager
|
|
85
|
+
>;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { type InitCallback, type PendingInit } from "./types";
|
|
2
|
+
export { registerCoreServices } from "./core-services";
|
|
3
|
+
export { createApiRouteHandler, registerApiRoute } from "./api-router";
|
|
4
|
+
export { sortPlugins } from "./dependency-sorter";
|
|
5
|
+
export {
|
|
6
|
+
createExtensionPointManager,
|
|
7
|
+
type ExtensionPointManager,
|
|
8
|
+
} from "./extension-points";
|
|
9
|
+
export {
|
|
10
|
+
loadPlugins,
|
|
11
|
+
registerPlugin,
|
|
12
|
+
type PluginLoaderDeps,
|
|
13
|
+
} from "./plugin-loader";
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { implement } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
autoAuthMiddleware,
|
|
4
|
+
type RpcContext,
|
|
5
|
+
} from "@checkstack/backend-api";
|
|
6
|
+
import { pluginAdminContract } from "@checkstack/backend-api";
|
|
7
|
+
import type { PluginManager } from "../plugin-manager";
|
|
8
|
+
import type { PluginInstaller } from "@checkstack/backend-api";
|
|
9
|
+
import { db } from "../db";
|
|
10
|
+
import { plugins } from "../schema";
|
|
11
|
+
import { eq } from "drizzle-orm";
|
|
12
|
+
import { rootLogger } from "../logger";
|
|
13
|
+
|
|
14
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
// Router Factory
|
|
16
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export function createPluginAdminRouter({
|
|
19
|
+
pluginManager,
|
|
20
|
+
installer,
|
|
21
|
+
}: {
|
|
22
|
+
pluginManager: PluginManager;
|
|
23
|
+
installer: PluginInstaller;
|
|
24
|
+
}) {
|
|
25
|
+
const impl = implement(pluginAdminContract)
|
|
26
|
+
.$context<RpcContext>()
|
|
27
|
+
.use(autoAuthMiddleware);
|
|
28
|
+
|
|
29
|
+
return impl.router({
|
|
30
|
+
install: impl.install.handler(async ({ input }) => {
|
|
31
|
+
const { packageName } = input;
|
|
32
|
+
rootLogger.info(`📦 Installing plugin: ${packageName}`);
|
|
33
|
+
|
|
34
|
+
// 1. npm install to filesystem
|
|
35
|
+
const result = await installer.install(packageName);
|
|
36
|
+
|
|
37
|
+
// 2. Insert/update in DB with isUninstallable=true (remote plugin)
|
|
38
|
+
await db
|
|
39
|
+
.insert(plugins)
|
|
40
|
+
.values({
|
|
41
|
+
name: result.name,
|
|
42
|
+
path: result.path,
|
|
43
|
+
enabled: true,
|
|
44
|
+
isUninstallable: true, // Remote plugins can be uninstalled
|
|
45
|
+
type: "backend",
|
|
46
|
+
})
|
|
47
|
+
.onConflictDoUpdate({
|
|
48
|
+
target: [plugins.name],
|
|
49
|
+
set: { path: result.path, enabled: true },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// 3. Broadcast to all instances to load the plugin
|
|
53
|
+
await pluginManager.requestInstallation(result.name, result.path);
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
pluginId: result.name,
|
|
58
|
+
path: result.path,
|
|
59
|
+
};
|
|
60
|
+
}),
|
|
61
|
+
|
|
62
|
+
deregister: impl.deregister.handler(async ({ input }) => {
|
|
63
|
+
const { pluginId, deleteSchema } = input;
|
|
64
|
+
rootLogger.info(`🗑️ Deregistering plugin: ${pluginId}`);
|
|
65
|
+
|
|
66
|
+
// Check if plugin exists and is uninstallable
|
|
67
|
+
const existing = await db
|
|
68
|
+
.select()
|
|
69
|
+
.from(plugins)
|
|
70
|
+
.where(eq(plugins.name, pluginId))
|
|
71
|
+
.limit(1);
|
|
72
|
+
|
|
73
|
+
if (existing.length === 0) {
|
|
74
|
+
throw new Error(`Plugin ${pluginId} not found`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!existing[0].isUninstallable) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Plugin ${pluginId} is a core plugin and cannot be deregistered`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Broadcast to all instances to deregister
|
|
84
|
+
await pluginManager.requestDeregistration(pluginId, { deleteSchema });
|
|
85
|
+
|
|
86
|
+
return { success: true };
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
}
|