@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +333 -0
  2. package/drizzle/0001_slim_mordo.sql +34 -0
  3. package/drizzle/meta/0001_snapshot.json +444 -0
  4. package/drizzle/meta/_journal.json +7 -0
  5. package/package.json +18 -13
  6. package/src/index.ts +276 -17
  7. package/src/plugin-deregistration.test.ts +137 -0
  8. package/src/plugin-manager/api-router.ts +35 -11
  9. package/src/plugin-manager/plugin-loader.ts +73 -0
  10. package/src/plugin-manager.ts +295 -105
  11. package/src/schema.ts +79 -1
  12. package/src/services/cache-manager.test.ts +172 -0
  13. package/src/services/cache-manager.ts +67 -14
  14. package/src/services/compatibility-checker.test.ts +146 -0
  15. package/src/services/compatibility-checker.ts +137 -0
  16. package/src/services/dev-auth.test.ts +87 -0
  17. package/src/services/dev-auth.ts +56 -0
  18. package/src/services/event-bus.test.ts +52 -0
  19. package/src/services/event-bus.ts +27 -1
  20. package/src/services/plugin-artifact-store.ts +131 -0
  21. package/src/services/plugin-bundle-resolver.ts +76 -0
  22. package/src/services/plugin-event-recorder.ts +87 -0
  23. package/src/services/plugin-installers/catalog-installer.ts +33 -0
  24. package/src/services/plugin-installers/github-installer.ts +207 -0
  25. package/src/services/plugin-installers/install-from-tarball.ts +69 -0
  26. package/src/services/plugin-installers/installer-registry.ts +51 -0
  27. package/src/services/plugin-installers/npm-installer.ts +156 -0
  28. package/src/services/plugin-installers/plugin-install-error.ts +37 -0
  29. package/src/services/plugin-installers/tarball-installer.ts +80 -0
  30. package/src/services/plugin-installers/tarball-utils.test.ts +200 -0
  31. package/src/services/plugin-installers/tarball-utils.ts +172 -0
  32. package/src/services/plugin-manager-orchestrator.ts +522 -0
  33. package/src/services/plugin-manager-router.ts +219 -0
  34. package/src/services/queue-manager.ts +77 -2
  35. package/src/services/queue-proxy.ts +7 -0
  36. package/src/utils/plugin-discovery.test.ts +6 -0
  37. package/src/utils/plugin-discovery.ts +6 -1
  38. package/tsconfig.json +3 -0
  39. package/src/plugin-lifecycle.test.ts +0 -276
  40. package/src/plugin-manager/plugin-admin-router.ts +0 -89
  41. package/src/services/plugin-installer.test.ts +0 -90
  42. 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 { WsConnectionHandlers } from "@checkstack/backend-api";
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
- app.use("*", logger());
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
- await pluginManager.loadPlugins(app);
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 admin endpoints)
459
- const pluginAdminRouter = createPluginAdminRouter({
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
- installer,
715
+ registry: pluginManager.getRegistry(),
716
+ eventRecorder,
717
+ workspaceRoot: path.resolve(import.meta.dir, "..", "..", ".."),
718
+ runtimeDir: runtimePluginsDir,
462
719
  });
463
- // Register as core router - available at /api/core/
464
- pluginManager.registerCoreRouter("core", pluginAdminRouter);
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
- if (logger) {
75
- logger.error(`RPC procedure error: ${String(error)}`);
76
- const stack =
77
- error !== null &&
78
- typeof error === "object" &&
79
- "stack" in error
80
- ? (error as { stack: string }).stack
81
- : undefined;
82
- if (stack) {
83
- logger.error(`Stack trace: ${stack}`);
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
+ }