@checkstack/backend 0.8.2 → 0.9.1
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 +333 -0
- package/drizzle/0001_slim_mordo.sql +34 -0
- package/drizzle/meta/0001_snapshot.json +444 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +18 -13
- package/src/index.ts +276 -17
- package/src/plugin-deregistration.test.ts +137 -0
- package/src/plugin-manager/api-router.ts +35 -11
- package/src/plugin-manager/plugin-loader.ts +73 -0
- package/src/plugin-manager.ts +295 -105
- package/src/schema.ts +79 -1
- package/src/services/cache-manager.test.ts +172 -0
- package/src/services/cache-manager.ts +67 -14
- package/src/services/compatibility-checker.test.ts +146 -0
- package/src/services/compatibility-checker.ts +137 -0
- package/src/services/dev-auth.test.ts +87 -0
- package/src/services/dev-auth.ts +56 -0
- package/src/services/event-bus.test.ts +52 -0
- package/src/services/event-bus.ts +27 -1
- package/src/services/plugin-artifact-store.ts +131 -0
- package/src/services/plugin-bundle-resolver.ts +76 -0
- package/src/services/plugin-event-recorder.ts +87 -0
- package/src/services/plugin-installers/catalog-installer.ts +33 -0
- package/src/services/plugin-installers/github-installer.ts +207 -0
- package/src/services/plugin-installers/install-from-tarball.ts +69 -0
- package/src/services/plugin-installers/installer-registry.ts +51 -0
- package/src/services/plugin-installers/npm-installer.ts +156 -0
- package/src/services/plugin-installers/plugin-install-error.ts +37 -0
- package/src/services/plugin-installers/tarball-installer.ts +80 -0
- package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
- package/src/services/plugin-installers/tarball-utils.ts +172 -0
- package/src/services/plugin-manager-orchestrator.ts +522 -0
- package/src/services/plugin-manager-router.ts +219 -0
- package/src/services/queue-manager.ts +77 -2
- package/src/services/queue-proxy.ts +7 -0
- package/src/utils/plugin-discovery.test.ts +6 -0
- package/src/utils/plugin-discovery.ts +6 -1
- package/tsconfig.json +3 -0
- package/src/plugin-lifecycle.test.ts +0 -276
- package/src/plugin-manager/plugin-admin-router.ts +0 -89
- package/src/services/plugin-installer.test.ts +0 -90
- package/src/services/plugin-installer.ts +0 -70
package/src/index.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { Server } from "bun";
|
|
|
2
2
|
import { type Context, Hono } from "hono";
|
|
3
3
|
import { TrieRouter } from "hono/router/trie-router";
|
|
4
4
|
import { PluginManager } from "./plugin-manager";
|
|
5
|
-
import { logger } from "hono/logger";
|
|
6
5
|
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
|
7
6
|
import { db } from "./db";
|
|
8
7
|
import path from "node:path";
|
|
@@ -12,17 +11,35 @@ import { coreServices, coreHooks } from "@checkstack/backend-api";
|
|
|
12
11
|
import { extractErrorMessage } from "@checkstack/common";
|
|
13
12
|
import { plugins } from "./schema";
|
|
14
13
|
import { eq, and } from "drizzle-orm";
|
|
15
|
-
import { PluginLocalInstaller } from "./services/plugin-installer";
|
|
16
14
|
import { QueuePluginRegistryImpl } from "./services/queue-plugin-registry";
|
|
17
15
|
import { QueueManagerImpl } from "./services/queue-manager";
|
|
18
16
|
import { CachePluginRegistryImpl } from "./services/cache-plugin-registry";
|
|
19
17
|
import { CacheManagerImpl } from "./services/cache-manager";
|
|
18
|
+
import { PostgresPluginArtifactStore } from "./services/plugin-artifact-store";
|
|
19
|
+
import { DefaultPluginInstallerRegistry } from "./services/plugin-installers/installer-registry";
|
|
20
|
+
import { PluginEventRecorder } from "./services/plugin-event-recorder";
|
|
21
|
+
import { createPluginManagerRouter } from "./services/plugin-manager-router";
|
|
22
|
+
import {
|
|
23
|
+
pluginManagerAccessRules,
|
|
24
|
+
pluginMetadata as pluginManagerMetadata,
|
|
25
|
+
pluginManagerAccess,
|
|
26
|
+
} from "@checkstack/pluginmanager-common";
|
|
27
|
+
import {
|
|
28
|
+
extractPackageJson,
|
|
29
|
+
tryExtractBundle,
|
|
30
|
+
MAX_TARBALL_SIZE_BYTES,
|
|
31
|
+
} from "./services/plugin-installers/tarball-utils";
|
|
20
32
|
import {
|
|
21
33
|
createWebSocketHandler,
|
|
22
34
|
SignalServiceImpl,
|
|
23
35
|
type WebSocketData,
|
|
24
36
|
} from "@checkstack/signal-backend";
|
|
25
|
-
import type {
|
|
37
|
+
import type {
|
|
38
|
+
AuthService,
|
|
39
|
+
BackendPlugin,
|
|
40
|
+
WsConnectionHandlers,
|
|
41
|
+
} from "@checkstack/backend-api";
|
|
42
|
+
import { createDevAuthService } from "./services/dev-auth";
|
|
26
43
|
|
|
27
44
|
// =============================================================================
|
|
28
45
|
// SERVER-LEVEL WEBSOCKET DATA
|
|
@@ -46,7 +63,6 @@ import {
|
|
|
46
63
|
PLUGIN_INSTALLED,
|
|
47
64
|
PLUGIN_DEREGISTERED,
|
|
48
65
|
} from "@checkstack/signal-common";
|
|
49
|
-
import { createPluginAdminRouter } from "./plugin-manager/plugin-admin-router";
|
|
50
66
|
import {
|
|
51
67
|
pluginMetadata as apiDocsMetadata,
|
|
52
68
|
apiDocsAccess,
|
|
@@ -113,7 +129,38 @@ app.use(
|
|
|
113
129
|
credentials: true,
|
|
114
130
|
})
|
|
115
131
|
);
|
|
116
|
-
|
|
132
|
+
// Request/response logging through our rootLogger (winston) instead of
|
|
133
|
+
// hono/logger which bypasses winston and writes to stdout directly. Goes
|
|
134
|
+
// at debug level for healthy responses; warn for 4xx and error for 5xx so
|
|
135
|
+
// failures surface even with low verbosity. The 5xx branch additionally
|
|
136
|
+
// peeks the response body so the underlying error message lands in the
|
|
137
|
+
// log — Hono returns errors as JSON via `c.json({error}, 500)` which the
|
|
138
|
+
// default access log strips down to just the status code.
|
|
139
|
+
app.use("*", async (c, next) => {
|
|
140
|
+
const start = performance.now();
|
|
141
|
+
const method = c.req.method;
|
|
142
|
+
const path = c.req.path;
|
|
143
|
+
rootLogger.debug(`<-- ${method} ${path}`);
|
|
144
|
+
await next();
|
|
145
|
+
const elapsedMs = (performance.now() - start).toFixed(1);
|
|
146
|
+
const status = c.res.status;
|
|
147
|
+
const line = `--> ${method} ${path} ${status} ${elapsedMs}ms`;
|
|
148
|
+
|
|
149
|
+
if (status >= 500) {
|
|
150
|
+
let body: string | undefined;
|
|
151
|
+
try {
|
|
152
|
+
// Clone so the response stream remains consumable downstream.
|
|
153
|
+
body = await c.res.clone().text();
|
|
154
|
+
} catch {
|
|
155
|
+
// ignore — best-effort body capture only
|
|
156
|
+
}
|
|
157
|
+
rootLogger.error(body ? `${line} — ${body}` : line);
|
|
158
|
+
} else if (status >= 400) {
|
|
159
|
+
rootLogger.warn(line);
|
|
160
|
+
} else {
|
|
161
|
+
rootLogger.debug(line);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
117
164
|
|
|
118
165
|
// =============================================================================
|
|
119
166
|
// PLATFORM ENDPOINTS — /.checkstack/*
|
|
@@ -316,12 +363,6 @@ if (frontendDistPath && fs.existsSync(frontendDistPath)) {
|
|
|
316
363
|
const init = async () => {
|
|
317
364
|
rootLogger.info("🚀 Starting Checkstack Core...");
|
|
318
365
|
|
|
319
|
-
// Register Plugin Installer Service
|
|
320
|
-
const installer = new PluginLocalInstaller(
|
|
321
|
-
path.join(process.cwd(), "runtime_plugins")
|
|
322
|
-
);
|
|
323
|
-
pluginManager.registerService(coreServices.pluginInstaller, installer);
|
|
324
|
-
|
|
325
366
|
// 1. Run Core Migrations
|
|
326
367
|
rootLogger.info("🔄 Running core migrations...");
|
|
327
368
|
try {
|
|
@@ -373,6 +414,128 @@ const init = async () => {
|
|
|
373
414
|
);
|
|
374
415
|
pluginManager.registerService(coreServices.cacheManager, cacheManager);
|
|
375
416
|
|
|
417
|
+
// 1.9. Register Plugin Install Services (artifact store + installer registry)
|
|
418
|
+
rootLogger.debug("Registering plugin install services...");
|
|
419
|
+
const runtimePluginsDir = path.join(process.cwd(), "runtime_plugins");
|
|
420
|
+
fs.mkdirSync(runtimePluginsDir, { recursive: true });
|
|
421
|
+
const pluginArtifactStore = new PostgresPluginArtifactStore(db);
|
|
422
|
+
const pluginInstallerRegistry = new DefaultPluginInstallerRegistry({
|
|
423
|
+
runtimeDir: runtimePluginsDir,
|
|
424
|
+
artifactStore: pluginArtifactStore,
|
|
425
|
+
});
|
|
426
|
+
pluginManager.registerService(
|
|
427
|
+
coreServices.pluginArtifactStore,
|
|
428
|
+
pluginArtifactStore,
|
|
429
|
+
);
|
|
430
|
+
pluginManager.registerService(
|
|
431
|
+
coreServices.pluginInstallerRegistry,
|
|
432
|
+
pluginInstallerRegistry,
|
|
433
|
+
);
|
|
434
|
+
// Per-instance event recorder (instanceId is the bun process pid for now;
|
|
435
|
+
// upgrade to a stable instance id when multi-region deploys land).
|
|
436
|
+
const eventRecorder = new PluginEventRecorder(db, `bun-${process.pid}`);
|
|
437
|
+
pluginManager.setEventRecorder(eventRecorder);
|
|
438
|
+
pluginManager.setRuntimeDir(runtimePluginsDir);
|
|
439
|
+
|
|
440
|
+
// Tarball-upload endpoint backing the install UI's "Tarball Upload" tab.
|
|
441
|
+
//
|
|
442
|
+
// The user uploads a `.tgz` produced by `bunx @checkstack/scripts plugin-pack`
|
|
443
|
+
// (single-package or `--bundle` mode). We peek the bytes to derive the
|
|
444
|
+
// primary `(name, version)`, persist the artifact to plugin_artifacts, and
|
|
445
|
+
// return the `artifactId`. The frontend then submits a `PluginSource` of
|
|
446
|
+
// type "tarball" with that id to `previewInstall` / `install`.
|
|
447
|
+
//
|
|
448
|
+
// We deliberately keep this as a plain Hono route (not an oRPC procedure)
|
|
449
|
+
// because oRPC contracts are JSON-only — multipart bodies can't be
|
|
450
|
+
// expressed there. Auth + access are enforced manually below using the
|
|
451
|
+
// same access service the rest of the platform uses.
|
|
452
|
+
app.post("/api/pluginmanager/upload-tarball", async (c) => {
|
|
453
|
+
const authService = await pluginManager.getService(coreServices.auth);
|
|
454
|
+
if (!authService) {
|
|
455
|
+
return c.json({ error: "Auth service not available" }, 503);
|
|
456
|
+
}
|
|
457
|
+
const user = await authService.authenticate(c.req.raw);
|
|
458
|
+
if (!user || user.type === "service") {
|
|
459
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
460
|
+
}
|
|
461
|
+
const requiredAccess = `${pluginManagerMetadata.pluginId}.${pluginManagerAccess.install.id}`;
|
|
462
|
+
const accessRules = (
|
|
463
|
+
"accessRules" in user ? user.accessRules : []
|
|
464
|
+
) as string[];
|
|
465
|
+
const anonymous = await authService.getAnonymousAccessRules();
|
|
466
|
+
if (!accessRules.includes(requiredAccess) && !anonymous.includes(requiredAccess)) {
|
|
467
|
+
return c.json({ error: "Access denied" }, 403);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const formData = await c.req.formData();
|
|
471
|
+
const file = formData.get("file");
|
|
472
|
+
if (!file || typeof file === "string") {
|
|
473
|
+
return c.json({ error: "Missing 'file' field in multipart body" }, 400);
|
|
474
|
+
}
|
|
475
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
476
|
+
if (bytes.byteLength === 0) {
|
|
477
|
+
return c.json({ error: "Uploaded file is empty" }, 400);
|
|
478
|
+
}
|
|
479
|
+
if (bytes.byteLength > MAX_TARBALL_SIZE_BYTES) {
|
|
480
|
+
return c.json(
|
|
481
|
+
{
|
|
482
|
+
error: `Tarball exceeds maximum size: ${bytes.byteLength} > ${MAX_TARBALL_SIZE_BYTES} bytes`,
|
|
483
|
+
},
|
|
484
|
+
413,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Derive (name, version) by peeking the tarball. For bundle tarballs,
|
|
489
|
+
// use the primary's manifest entry; for single packages, the embedded
|
|
490
|
+
// package.json. Validation happens here too — a malformed tarball is
|
|
491
|
+
// rejected before any DB write.
|
|
492
|
+
let pluginName: string;
|
|
493
|
+
let version: string;
|
|
494
|
+
try {
|
|
495
|
+
const bundle = await tryExtractBundle(bytes);
|
|
496
|
+
if (bundle) {
|
|
497
|
+
pluginName = bundle.manifest.primary;
|
|
498
|
+
const primaryEntry = bundle.manifest.packages.find(
|
|
499
|
+
(p) => p.name === bundle.manifest.primary,
|
|
500
|
+
);
|
|
501
|
+
if (!primaryEntry) {
|
|
502
|
+
return c.json(
|
|
503
|
+
{ error: `Bundle manifest missing primary entry '${pluginName}'` },
|
|
504
|
+
400,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
version = primaryEntry.version;
|
|
508
|
+
} else {
|
|
509
|
+
const meta = await extractPackageJson(bytes);
|
|
510
|
+
pluginName = meta.name;
|
|
511
|
+
version = meta.version;
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
return c.json(
|
|
515
|
+
{ error: `Failed to peek tarball: ${extractErrorMessage(error)}` },
|
|
516
|
+
400,
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const { artifactId, contentHash } = await pluginArtifactStore.store({
|
|
521
|
+
pluginName,
|
|
522
|
+
version,
|
|
523
|
+
tarball: bytes,
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
rootLogger.info(
|
|
527
|
+
`📦 Tarball uploaded: ${pluginName}@${version} (artifactId=${artifactId}, ${bytes.byteLength} bytes)`,
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
return c.json({
|
|
531
|
+
artifactId,
|
|
532
|
+
pluginName,
|
|
533
|
+
version,
|
|
534
|
+
contentHash,
|
|
535
|
+
sizeBytes: bytes.byteLength,
|
|
536
|
+
});
|
|
537
|
+
});
|
|
538
|
+
|
|
376
539
|
// Serve static assets for runtime frontend plugins only
|
|
377
540
|
// Backend plugins don't need public assets - only frontend plugins do
|
|
378
541
|
// e.g. /assets/plugins/my-plugin-frontend/index.js -> runtime_plugins/node_modules/my-plugin-frontend/dist/index.js
|
|
@@ -437,7 +600,88 @@ const init = async () => {
|
|
|
437
600
|
}
|
|
438
601
|
|
|
439
602
|
// 3. Load Plugins
|
|
440
|
-
|
|
603
|
+
//
|
|
604
|
+
// Dev-server mode (entered via `bunx @checkstack/scripts dev` from a
|
|
605
|
+
// plugin author's repo). Two env vars control it:
|
|
606
|
+
//
|
|
607
|
+
// - CHECKSTACK_DEV_PLUGIN_PATH: absolute path to a plugin module's
|
|
608
|
+
// directory whose `default` export is the BackendPlugin to load.
|
|
609
|
+
// When set, filesystem discovery is skipped — only this plugin and
|
|
610
|
+
// core services are loaded. Lets a plugin author iterate without
|
|
611
|
+
// a workspace checkout.
|
|
612
|
+
// - CHECKSTACK_DEV_AUTH=true: registers a synthetic auth service that
|
|
613
|
+
// auto-grants every access rule. Skips login flow entirely. Strictly
|
|
614
|
+
// refused on a known-prod NODE_ENV value to make accidental misuse
|
|
615
|
+
// loud.
|
|
616
|
+
const devPluginPath = process.env.CHECKSTACK_DEV_PLUGIN_PATH;
|
|
617
|
+
const devAuth = process.env.CHECKSTACK_DEV_AUTH === "true";
|
|
618
|
+
if (devAuth) {
|
|
619
|
+
if (process.env.NODE_ENV === "production") {
|
|
620
|
+
throw new Error(
|
|
621
|
+
"CHECKSTACK_DEV_AUTH=true is refused when NODE_ENV=production. " +
|
|
622
|
+
"Dev auth bypasses every access guard and must never run in prod.",
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
rootLogger.warn(
|
|
626
|
+
"🛠 Dev auth ENABLED — every access rule is auto-granted. Do NOT use in production.",
|
|
627
|
+
);
|
|
628
|
+
const devAuthService: AuthService = createDevAuthService({
|
|
629
|
+
getAllAccessRules: () => pluginManager.getAllAccessRules(),
|
|
630
|
+
});
|
|
631
|
+
pluginManager.registerService(coreServices.auth, devAuthService);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const manualPlugins: BackendPlugin[] = [];
|
|
635
|
+
if (devPluginPath) {
|
|
636
|
+
rootLogger.info(`🛠 Dev mode — loading plugin from ${devPluginPath}`);
|
|
637
|
+
|
|
638
|
+
// Co-load `@checkstack/*` backend deps the dev command resolved from
|
|
639
|
+
// the plugin's package.json. Without these, the plugin under dev's
|
|
640
|
+
// `init()` would hit unregistered services. The dev command always
|
|
641
|
+
// includes in-memory queue+cache providers when no other provider
|
|
642
|
+
// is in the dep graph, so coreServices.queueManager /
|
|
643
|
+
// coreServices.cacheManager have a registered strategy on boot.
|
|
644
|
+
const extraPathsRaw = process.env.CHECKSTACK_DEV_EXTRA_PLUGIN_PATHS;
|
|
645
|
+
const extraPaths: string[] = extraPathsRaw ? JSON.parse(extraPathsRaw) : [];
|
|
646
|
+
for (const extra of extraPaths) {
|
|
647
|
+
try {
|
|
648
|
+
const mod = await import(extra);
|
|
649
|
+
const exp = mod.default as BackendPlugin | undefined;
|
|
650
|
+
if (!exp || typeof exp.register !== "function") {
|
|
651
|
+
throw new Error(
|
|
652
|
+
`Module at ${extra} does not export a default BackendPlugin`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
manualPlugins.push(exp);
|
|
656
|
+
} catch (error) {
|
|
657
|
+
throw new Error(
|
|
658
|
+
`Failed to import co-loaded core plugin from ${extra}: ${extractErrorMessage(error)}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Plugin under dev loads last; the platform's pendingInits topo-sort
|
|
664
|
+
// takes care of actual init order, but importing it last makes the
|
|
665
|
+
// boot log easier to read.
|
|
666
|
+
try {
|
|
667
|
+
const pluginModule = await import(devPluginPath);
|
|
668
|
+
const pluginExport = pluginModule.default as BackendPlugin | undefined;
|
|
669
|
+
if (!pluginExport || typeof pluginExport.register !== "function") {
|
|
670
|
+
throw new Error(
|
|
671
|
+
`Module at ${devPluginPath} does not export a default BackendPlugin`,
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
manualPlugins.push(pluginExport);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`Failed to import dev plugin from ${devPluginPath}: ${extractErrorMessage(error)}`,
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
await pluginManager.loadPlugins(app, manualPlugins, {
|
|
683
|
+
skipDiscovery: !!devPluginPath,
|
|
684
|
+
});
|
|
441
685
|
|
|
442
686
|
// 4. Wire up auth client for access-based signal filtering
|
|
443
687
|
// This must happen AFTER plugins load so auth-backend is available
|
|
@@ -455,13 +699,28 @@ const init = async () => {
|
|
|
455
699
|
);
|
|
456
700
|
}
|
|
457
701
|
|
|
458
|
-
// 5. Register plugin admin router (core
|
|
459
|
-
|
|
702
|
+
// 4.5. Register the plugin-manager admin router (core router, not a regular
|
|
703
|
+
// plugin). Access rules from `@checkstack/pluginmanager-common` are also
|
|
704
|
+
// pushed into the access registry here so the autoAuthMiddleware can
|
|
705
|
+
// resolve them. We use the existing access-rule prefix scheme so the
|
|
706
|
+
// ids land as e.g. `pluginmanager.plugin.manage`.
|
|
707
|
+
pluginManager.registerCoreAccessRules(
|
|
708
|
+
pluginManagerMetadata.pluginId,
|
|
709
|
+
pluginManagerAccessRules,
|
|
710
|
+
);
|
|
711
|
+
pluginManager.registerCorePluginMetadata(pluginManagerMetadata);
|
|
712
|
+
const pluginManagerRouter = createPluginManagerRouter({
|
|
713
|
+
db,
|
|
460
714
|
pluginManager,
|
|
461
|
-
|
|
715
|
+
registry: pluginManager.getRegistry(),
|
|
716
|
+
eventRecorder,
|
|
717
|
+
workspaceRoot: path.resolve(import.meta.dir, "..", "..", ".."),
|
|
718
|
+
runtimeDir: runtimePluginsDir,
|
|
462
719
|
});
|
|
463
|
-
|
|
464
|
-
|
|
720
|
+
pluginManager.registerCoreRouter(
|
|
721
|
+
pluginManagerMetadata.pluginId,
|
|
722
|
+
pluginManagerRouter,
|
|
723
|
+
);
|
|
465
724
|
|
|
466
725
|
// 5. Setup lifecycle listeners for multi-instance coordination
|
|
467
726
|
await pluginManager.setupLifecycleListeners();
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { describe, it, expect, mock, beforeEach } from "bun:test";
|
|
2
|
+
import { PluginManager } from "./plugin-manager";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Integration test for the originator/in-process split introduced in the
|
|
6
|
+
* runtime plugin system.
|
|
7
|
+
*
|
|
8
|
+
* Key contract: `deregisterPluginInProcess` must NOT touch any persistent
|
|
9
|
+
* state. It runs on every instance via the broadcast hook; the destructive
|
|
10
|
+
* cleanup (drop schema, delete plugin_configs, delete plugin_artifacts,
|
|
11
|
+
* delete plugins rows) only runs on the originator via `deletePluginData`.
|
|
12
|
+
*
|
|
13
|
+
* If a future refactor accidentally pushes destructive ops back into the
|
|
14
|
+
* in-process path, these tests fire.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
describe("deregisterPluginInProcess", () => {
|
|
18
|
+
let pluginManager: PluginManager;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
pluginManager = new PluginManager();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("clears in-memory cleanup handlers and runs them in LIFO order", async () => {
|
|
25
|
+
const order: string[] = [];
|
|
26
|
+
const handlerA = mock(async () => {
|
|
27
|
+
order.push("a");
|
|
28
|
+
});
|
|
29
|
+
const handlerB = mock(async () => {
|
|
30
|
+
order.push("b");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Inject cleanup handlers using the same mechanism plugins use during
|
|
34
|
+
// registration. We poke the private map because the public path (full
|
|
35
|
+
// backendPlugin.register) needs a real plugin module.
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
37
|
+
const handlers = (pluginManager as any).cleanupHandlers as Map<
|
|
38
|
+
string,
|
|
39
|
+
Array<() => Promise<void>>
|
|
40
|
+
>;
|
|
41
|
+
handlers.set("test-plugin", [handlerA, handlerB]);
|
|
42
|
+
|
|
43
|
+
await pluginManager.deregisterPluginInProcess("test-plugin");
|
|
44
|
+
|
|
45
|
+
// LIFO: handlerB ran before handlerA
|
|
46
|
+
expect(order).toEqual(["b", "a"]);
|
|
47
|
+
expect(handlers.has("test-plugin")).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("removes router, contract, metadata and access-rule entries", async () => {
|
|
51
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
52
|
+
const pm = pluginManager as any;
|
|
53
|
+
pm.pluginRpcRouters.set("foo", { router: true });
|
|
54
|
+
pm.pluginContractRegistry.set("foo", { contract: true });
|
|
55
|
+
pm.pluginMetadataRegistry.set("foo", { pluginId: "foo" });
|
|
56
|
+
pm.registeredAccessRules.push({
|
|
57
|
+
id: "foo.something",
|
|
58
|
+
pluginId: "foo",
|
|
59
|
+
action: "read",
|
|
60
|
+
resourceType: "thing",
|
|
61
|
+
description: "test",
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await pluginManager.deregisterPluginInProcess("foo");
|
|
65
|
+
|
|
66
|
+
expect(pm.pluginRpcRouters.has("foo")).toBe(false);
|
|
67
|
+
expect(pm.pluginContractRegistry.has("foo")).toBe(false);
|
|
68
|
+
expect(pm.pluginMetadataRegistry.has("foo")).toBe(false);
|
|
69
|
+
expect(
|
|
70
|
+
pm.registeredAccessRules.some(
|
|
71
|
+
(r: { pluginId: string }) => r.pluginId === "foo",
|
|
72
|
+
),
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not invoke any artifact-store or destructive ops", async () => {
|
|
77
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
78
|
+
const pm = pluginManager as any;
|
|
79
|
+
|
|
80
|
+
const artifactStoreCalls: string[] = [];
|
|
81
|
+
const fakeArtifactStore = {
|
|
82
|
+
maxArtifactSize: 1,
|
|
83
|
+
store: () => {
|
|
84
|
+
artifactStoreCalls.push("store");
|
|
85
|
+
return Promise.resolve({ artifactId: "x", contentHash: "x" });
|
|
86
|
+
},
|
|
87
|
+
fetch: () => {
|
|
88
|
+
artifactStoreCalls.push("fetch");
|
|
89
|
+
return Promise.resolve(undefined);
|
|
90
|
+
},
|
|
91
|
+
fetchById: () => {
|
|
92
|
+
artifactStoreCalls.push("fetchById");
|
|
93
|
+
return Promise.resolve(undefined);
|
|
94
|
+
},
|
|
95
|
+
delete: () => {
|
|
96
|
+
artifactStoreCalls.push("delete");
|
|
97
|
+
return Promise.resolve();
|
|
98
|
+
},
|
|
99
|
+
deleteByBundle: () => {
|
|
100
|
+
artifactStoreCalls.push("deleteByBundle");
|
|
101
|
+
return Promise.resolve();
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
// The artifact store is registered via coreServices.pluginArtifactStore.
|
|
105
|
+
// Even when present, deregisterPluginInProcess should not consult it.
|
|
106
|
+
pm.registry.register(
|
|
107
|
+
{ id: "core.pluginArtifactStore" },
|
|
108
|
+
fakeArtifactStore,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
pm.cleanupHandlers.set("foo", []);
|
|
112
|
+
await pluginManager.deregisterPluginInProcess("foo");
|
|
113
|
+
|
|
114
|
+
expect(artifactStoreCalls).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("handler errors do not block subsequent cleanup or remove-from-registry", async () => {
|
|
118
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
119
|
+
const pm = pluginManager as any;
|
|
120
|
+
|
|
121
|
+
const goodHandler = mock(async () => {});
|
|
122
|
+
const badHandler = mock(async () => {
|
|
123
|
+
throw new Error("intentional");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
pm.cleanupHandlers.set("plugin-a", [goodHandler, badHandler]);
|
|
127
|
+
pm.pluginRpcRouters.set("plugin-a", {});
|
|
128
|
+
|
|
129
|
+
await pluginManager.deregisterPluginInProcess("plugin-a");
|
|
130
|
+
|
|
131
|
+
expect(badHandler).toHaveBeenCalled();
|
|
132
|
+
expect(goodHandler).toHaveBeenCalled();
|
|
133
|
+
// Even though one handler threw, the in-memory state was cleared:
|
|
134
|
+
expect(pm.pluginRpcRouters.has("plugin-a")).toBe(false);
|
|
135
|
+
expect(pm.cleanupHandlers.has("plugin-a")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
@@ -20,6 +20,8 @@ import type {
|
|
|
20
20
|
import type { ServiceRegistry } from "../services/service-registry";
|
|
21
21
|
import type { EventBus } from "@checkstack/backend-api";
|
|
22
22
|
import type { PluginMetadata } from "@checkstack/common";
|
|
23
|
+
import { rootLogger } from "../logger";
|
|
24
|
+
import { extractErrorMessage } from "@checkstack/common";
|
|
23
25
|
|
|
24
26
|
/**
|
|
25
27
|
* Creates the API route handler for Hono.
|
|
@@ -71,17 +73,18 @@ export function createApiRouteHandler({
|
|
|
71
73
|
try {
|
|
72
74
|
return await next(rest);
|
|
73
75
|
} catch (error) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
const target = (logger ?? rootLogger) as Logger;
|
|
77
|
+
target.error(
|
|
78
|
+
`RPC ${pathname} failed: ${extractErrorMessage(error)}`,
|
|
79
|
+
);
|
|
80
|
+
const stack =
|
|
81
|
+
error !== null &&
|
|
82
|
+
typeof error === "object" &&
|
|
83
|
+
"stack" in error
|
|
84
|
+
? (error as { stack: string }).stack
|
|
85
|
+
: undefined;
|
|
86
|
+
if (stack) {
|
|
87
|
+
target.error(`Stack trace: ${stack}`);
|
|
85
88
|
}
|
|
86
89
|
throw error;
|
|
87
90
|
}
|
|
@@ -121,6 +124,22 @@ export function createApiRouteHandler({
|
|
|
121
124
|
!cacheManager ||
|
|
122
125
|
!eventBus
|
|
123
126
|
) {
|
|
127
|
+
const missing = [
|
|
128
|
+
!auth && "auth",
|
|
129
|
+
!logger && "logger",
|
|
130
|
+
!db && "db",
|
|
131
|
+
!fetch && "fetch",
|
|
132
|
+
!healthCheckRegistry && "healthCheckRegistry",
|
|
133
|
+
!collectorRegistry && "collectorRegistry",
|
|
134
|
+
!queuePluginRegistry && "queuePluginRegistry",
|
|
135
|
+
!queueManager && "queueManager",
|
|
136
|
+
!cachePluginRegistry && "cachePluginRegistry",
|
|
137
|
+
!cacheManager && "cacheManager",
|
|
138
|
+
!eventBus && "eventBus",
|
|
139
|
+
].filter(Boolean).join(", ");
|
|
140
|
+
(logger ?? rootLogger).error(
|
|
141
|
+
`${pathname}: core services not initialized — missing: ${missing}`,
|
|
142
|
+
);
|
|
124
143
|
return c.json({ error: "Core services not initialized" }, 500);
|
|
125
144
|
}
|
|
126
145
|
|
|
@@ -136,6 +155,11 @@ export function createApiRouteHandler({
|
|
|
136
155
|
pluginMetadataRegistry.get(pluginId);
|
|
137
156
|
|
|
138
157
|
if (!pluginMetadata) {
|
|
158
|
+
(logger as Logger).error(
|
|
159
|
+
`${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
|
|
160
|
+
`Regular plugins populate this during register(); core routers must call ` +
|
|
161
|
+
`pluginManager.registerCorePluginMetadata().`,
|
|
162
|
+
);
|
|
139
163
|
return c.json({ error: "Plugin metadata not found in registry" }, 500);
|
|
140
164
|
}
|
|
141
165
|
|
|
@@ -224,6 +224,14 @@ export async function loadPlugins({
|
|
|
224
224
|
// 2. Sync local plugins to database
|
|
225
225
|
await syncPluginsToDatabase({ localPlugins, db: deps.db });
|
|
226
226
|
|
|
227
|
+
// 2.5 Bootstrap runtime-installed plugins missing from node_modules.
|
|
228
|
+
// For every `is_uninstallable=true` row in `plugins`, ensure the
|
|
229
|
+
// package is installed in the runtime dir. If not, fetch its tarball
|
|
230
|
+
// from `plugin_artifacts` and run `bun install` so the import below
|
|
231
|
+
// resolves. This is what lets a freshly spun replica recover the
|
|
232
|
+
// full plugin set from Postgres alone.
|
|
233
|
+
await bootstrapRuntimePlugins({ db: deps.db, registry: deps.registry });
|
|
234
|
+
|
|
227
235
|
// 3. Load all enabled BACKEND plugins from database
|
|
228
236
|
allPlugins = await deps.db
|
|
229
237
|
.select()
|
|
@@ -641,3 +649,68 @@ function validateContractAccessRules({
|
|
|
641
649
|
}
|
|
642
650
|
}
|
|
643
651
|
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Fresh-instance bootstrap.
|
|
655
|
+
*
|
|
656
|
+
* Walks every `is_uninstallable=true` row in the `plugins` table and ensures
|
|
657
|
+
* the corresponding package is installed under the runtime dir. Missing
|
|
658
|
+
* packages are recovered from `plugin_artifacts` (bytea) and installed with
|
|
659
|
+
* `bun install --no-save --ignore-scripts`. Once this returns, the normal
|
|
660
|
+
* `pluginModule = await import(...)` in Phase 1 below resolves.
|
|
661
|
+
*/
|
|
662
|
+
async function bootstrapRuntimePlugins({
|
|
663
|
+
db,
|
|
664
|
+
registry,
|
|
665
|
+
}: {
|
|
666
|
+
db: SafeDatabase<Record<string, unknown>>;
|
|
667
|
+
registry: ServiceRegistry;
|
|
668
|
+
}): Promise<void> {
|
|
669
|
+
const installerRegistry = await registry
|
|
670
|
+
.get(coreServices.pluginInstallerRegistry, { pluginId: "core" })
|
|
671
|
+
.catch(() => {});
|
|
672
|
+
const artifactStore = await registry
|
|
673
|
+
.get(coreServices.pluginArtifactStore, { pluginId: "core" })
|
|
674
|
+
.catch(() => {});
|
|
675
|
+
if (!installerRegistry || !artifactStore) {
|
|
676
|
+
rootLogger.debug(
|
|
677
|
+
" -> Skipping runtime plugin bootstrap (services not registered)",
|
|
678
|
+
);
|
|
679
|
+
return;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
const remoteRows = await db
|
|
683
|
+
.select()
|
|
684
|
+
.from(plugins)
|
|
685
|
+
.where(eq(plugins.isUninstallable, true));
|
|
686
|
+
|
|
687
|
+
for (const row of remoteRows) {
|
|
688
|
+
if (!row.path) continue;
|
|
689
|
+
const pkgJsonPath = path.join(row.path, "package.json");
|
|
690
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
691
|
+
rootLogger.debug(` -> ${row.name} already present, skipping bootstrap`);
|
|
692
|
+
continue;
|
|
693
|
+
}
|
|
694
|
+
rootLogger.info(
|
|
695
|
+
`🔌 Bootstrapping runtime plugin from artifact store: ${row.name}@${row.version}`,
|
|
696
|
+
);
|
|
697
|
+
const artifact = await artifactStore.fetch({
|
|
698
|
+
pluginName: row.name,
|
|
699
|
+
version: row.version,
|
|
700
|
+
});
|
|
701
|
+
if (!artifact) {
|
|
702
|
+
rootLogger.warn(
|
|
703
|
+
` -> No artifact for ${row.name}@${row.version} — skipping. Re-install from the original source.`,
|
|
704
|
+
);
|
|
705
|
+
continue;
|
|
706
|
+
}
|
|
707
|
+
const allowInstallScripts =
|
|
708
|
+
(row.metadata as { checkstack?: { allowInstallScripts?: boolean } })
|
|
709
|
+
?.checkstack?.allowInstallScripts === true;
|
|
710
|
+
await installerRegistry.forSource("npm").installFromArtifact({
|
|
711
|
+
tarball: artifact.tarball,
|
|
712
|
+
pluginName: row.name,
|
|
713
|
+
allowInstallScripts,
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|