@checkstack/backend 0.11.0 → 0.12.0

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 CHANGED
@@ -1,5 +1,39 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 270ef29: Fix automation provider actions and `secretEnv` script actions throwing in production.
8
+
9
+ The automation dispatch engine resolved provider-action dependencies (the integration connection store, the secret resolver) through a `getService` that was a throwing stub, so Jira / Teams / Webex actions and `secretEnv` script actions threw at execute time in production. The whole dispatch test suite stubbed `getService`, so the break was invisible.
10
+
11
+ Root cause: the plugin `env` exposed `registerService` but no resolver, so the dispatch path (the only context that resolves arbitrary cross-plugin refs outside an RPC handler) had nothing real to call.
12
+
13
+ Changes:
14
+
15
+ - `@checkstack/backend-api`: add `getService<S>(ref: ServiceRef<S>): Promise<S>` to the plugin `env` (`BackendPluginRegistry`). It resolves a service registered by any plugin through the real `ServiceRegistry` using the calling plugin's identity, and throws a clear error if the ref is not registered (never silently `undefined`). **NEW PLUGIN-AUTHOR CONTRACT**: `env.getService` is now available to resolve arbitrary cross-plugin service refs at init / afterPluginsReady time.
16
+ - `@checkstack/backend`: implement `env.getService` in both the plugin loader and the runtime single-plugin registration path, backed by `ServiceRegistry.get(ref, { pluginId })`.
17
+ - `@checkstack/automation-backend`: wire the dispatch `getService` to `env.getService` (was a throwing stub). This also activates run-wide provider-credential masking, because resolving the connection store / secret resolver now flows through the run's masking interceptor.
18
+
19
+ Also fixes a test-only seam where the `core/backend` test preload registered a no-op `registerRouter`, silently disabling oRPC router registration across the suite.
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [270ef29]
24
+ - Updated dependencies [270ef29]
25
+ - Updated dependencies [270ef29]
26
+ - Updated dependencies [b995afb]
27
+ - Updated dependencies [270ef29]
28
+ - Updated dependencies [270ef29]
29
+ - Updated dependencies [270ef29]
30
+ - Updated dependencies [270ef29]
31
+ - Updated dependencies [270ef29]
32
+ - @checkstack/backend-api@0.19.0
33
+ - @checkstack/cache-api@0.3.7
34
+ - @checkstack/queue-api@0.3.7
35
+ - @checkstack/signal-backend@0.2.11
36
+
3
37
  ## 0.11.0
4
38
 
5
39
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "backend"
@@ -14,16 +14,16 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/api-docs-common": "0.1.14",
18
- "@checkstack/auth-common": "0.7.1",
19
- "@checkstack/backend-api": "0.17.1",
20
- "@checkstack/common": "0.11.0",
17
+ "@checkstack/api-docs-common": "0.1.15",
18
+ "@checkstack/auth-common": "0.7.2",
19
+ "@checkstack/backend-api": "0.18.0",
20
+ "@checkstack/common": "0.12.0",
21
21
  "@checkstack/drizzle-helper": "0.0.5",
22
- "@checkstack/cache-api": "0.3.5",
23
- "@checkstack/queue-api": "0.3.5",
24
- "@checkstack/signal-backend": "0.2.9",
25
- "@checkstack/signal-common": "0.2.4",
26
- "@checkstack/pluginmanager-common": "0.2.3",
22
+ "@checkstack/cache-api": "0.3.6",
23
+ "@checkstack/queue-api": "0.3.6",
24
+ "@checkstack/signal-backend": "0.2.10",
25
+ "@checkstack/signal-common": "0.2.5",
26
+ "@checkstack/pluginmanager-common": "0.2.4",
27
27
  "@hono/zod-validator": "^0.7.6",
28
28
  "@orpc/client": "^1.13.14",
29
29
  "@orpc/contract": "^1.13.14",
@@ -45,8 +45,8 @@
45
45
  "@types/bun": "latest",
46
46
  "@types/semver": "^7.5.0",
47
47
  "@checkstack/tsconfig": "0.0.7",
48
- "@checkstack/scripts": "0.3.3",
49
- "@checkstack/test-utils-backend": "0.1.30",
48
+ "@checkstack/scripts": "0.3.4",
49
+ "@checkstack/test-utils-backend": "0.1.31",
50
50
  "drizzle-kit": "^0.31.10"
51
51
  }
52
52
  }
@@ -9,6 +9,7 @@ import {
9
9
  RpcClient,
10
10
  EventBus as IEventBus,
11
11
  AuthenticationStrategy,
12
+ createAdvisoryLockService,
12
13
  } from "@checkstack/backend-api";
13
14
  import { AuthApi } from "@checkstack/auth-common";
14
15
  import type { ServiceRegistry } from "../services/service-registry";
@@ -97,6 +98,16 @@ export function registerCoreServices({
97
98
  return createScopedDb(db, assignedSchema);
98
99
  });
99
100
 
101
+ // 1b. Advisory Lock Factory (server-global, backed by the shared admin
102
+ // pool). Session locks need connection affinity, so the service checks
103
+ // out a dedicated client per acquired lock and releases on the SAME
104
+ // client — the scoped per-query DB proxy can't provide that.
105
+ const advisoryLockService = createAdvisoryLockService(adminPool);
106
+ registry.registerFactory(
107
+ coreServices.advisoryLock,
108
+ () => advisoryLockService,
109
+ );
110
+
100
111
  // 2. Logger Factory
101
112
  registry.registerFactory(coreServices.logger, (metadata) => {
102
113
  return rootLogger.child({ plugin: metadata.pluginId });
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ createServiceRef,
4
+ createBackendPlugin,
5
+ type BackendPluginRegistry,
6
+ type ServiceRef,
7
+ } from "@checkstack/backend-api";
8
+ import type { AccessRule, PluginMetadata } from "@checkstack/common";
9
+ import { ServiceRegistry } from "../services/service-registry";
10
+ import { createExtensionPointManager } from "./extension-points";
11
+ import { registerPlugin, type PluginLoaderDeps } from "./plugin-loader";
12
+ import type { PendingInit } from "./types";
13
+ import type { AnyContractRouter } from "@orpc/contract";
14
+
15
+ /**
16
+ * Framework-level coverage for the plugin `env.getService`, the registry-backed
17
+ * resolver added so the automation dispatch path can resolve arbitrary
18
+ * cross-plugin refs (connection store, secret resolver) at execute time.
19
+ *
20
+ * Prior to this, `env` exposed `registerService` but NO resolver, so the
21
+ * dispatch engine stubbed `getService` with a throwing placeholder and every
22
+ * provider action threw in production. These tests assert `env.getService`
23
+ * resolves through the REAL `ServiceRegistry` and fails loudly on a missing
24
+ * ref (never silently `undefined`).
25
+ */
26
+
27
+ function makeDeps(registry: ServiceRegistry): PluginLoaderDeps {
28
+ return {
29
+ registry,
30
+ pluginRpcRouters: new Map<string, unknown>(),
31
+ pluginHttpHandlers: new Map<string, (req: Request) => Promise<Response>>(),
32
+ extensionPointManager: createExtensionPointManager(),
33
+ registeredAccessRules: [] as (AccessRule & { pluginId: string })[],
34
+ getAllAccessRules: () => [],
35
+ db: {} as PluginLoaderDeps["db"],
36
+ pluginMetadataRegistry: new Map<string, PluginMetadata>(),
37
+ cleanupHandlers: new Map<string, Array<() => Promise<void>>>(),
38
+ pluginContractRegistry: new Map<string, AnyContractRouter>(),
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Register a no-op plugin whose `register` callback captures the `env` so the
44
+ * test can call `env.getService` the same way a plugin would at init time.
45
+ */
46
+ function captureEnv(deps: PluginLoaderDeps): BackendPluginRegistry {
47
+ let captured: BackendPluginRegistry | undefined;
48
+ const plugin = createBackendPlugin({
49
+ metadata: { pluginId: "consumer-plugin" },
50
+ register: (env) => {
51
+ captured = env;
52
+ },
53
+ });
54
+ const pendingInits: PendingInit[] = [];
55
+ registerPlugin({
56
+ backendPlugin: plugin,
57
+ pluginPath: "/virtual/consumer-plugin",
58
+ pendingInits,
59
+ providedBy: new Map<string, string>(),
60
+ deps,
61
+ });
62
+ if (!captured) throw new Error("env was not captured");
63
+ return captured;
64
+ }
65
+
66
+ describe("plugin env.getService", () => {
67
+ it("resolves a service registered by another plugin through the real ServiceRegistry", async () => {
68
+ const registry = new ServiceRegistry();
69
+ const ref = createServiceRef<{ ping: () => string }>("other.service");
70
+ const impl = { ping: () => "pong" };
71
+ registry.register(ref, impl);
72
+
73
+ const env = captureEnv(makeDeps(registry));
74
+ const resolved = await env.getService(ref);
75
+
76
+ expect(resolved).toBe(impl);
77
+ expect(resolved.ping()).toBe("pong");
78
+ });
79
+
80
+ it("resolves a service the SAME plugin registered via env.registerService", async () => {
81
+ const registry = new ServiceRegistry();
82
+ const env = captureEnv(makeDeps(registry));
83
+ const ref = createServiceRef<{ n: number }>("self.service");
84
+
85
+ env.registerService(ref, { n: 42 });
86
+ const resolved = await env.getService(ref);
87
+
88
+ expect(resolved.n).toBe(42);
89
+ });
90
+
91
+ it("throws a clear error (never undefined) when the ref is not registered", async () => {
92
+ const registry = new ServiceRegistry();
93
+ const env = captureEnv(makeDeps(registry));
94
+ const missing: ServiceRef<{ x: number }> =
95
+ createServiceRef<{ x: number }>("missing.service");
96
+
97
+ let error: unknown;
98
+ try {
99
+ await env.getService(missing);
100
+ } catch (e) {
101
+ error = e;
102
+ }
103
+ expect(error).toBeInstanceOf(Error);
104
+ expect((error as Error).message).toContain("missing.service");
105
+ // Consumer identity is THIS plugin (audit / scoped factories).
106
+ expect((error as Error).message).toContain("consumer-plugin");
107
+ });
108
+ });
@@ -149,6 +149,14 @@ export function registerPlugin({
149
149
  providedBy.set(ref.id, pluginId);
150
150
  rootLogger.debug(` -> Registered service '${ref.id}'`);
151
151
  },
152
+ // Registry-backed resolver for arbitrary cross-plugin refs. The
153
+ // consumer identity is THIS plugin (`pluginId`), which is the correct
154
+ // audit identity for scoped factories. `registry.get` throws a clear
155
+ // error when the ref is unregistered, so a missing service (e.g. a
156
+ // connection store the automation dispatch path needs) fails loudly
157
+ // rather than silently resolving `undefined`.
158
+ getService: <T>(ref: ServiceRef<T>): Promise<T> =>
159
+ deps.registry.get(ref, { pluginId }),
152
160
  registerExtensionPoint: (ref, impl) => {
153
161
  deps.extensionPointManager.registerExtensionPoint(ref, impl);
154
162
  },
@@ -634,6 +634,12 @@ export class PluginManager {
634
634
  registerService: (ref, impl) => {
635
635
  this.registry.register(ref, impl);
636
636
  },
637
+ // Registry-backed resolver for arbitrary cross-plugin refs, using
638
+ // this plugin's identity as the consumer. Mirrors the plugin-loader
639
+ // `env.getService`; resolves through the real ServiceRegistry and
640
+ // throws clearly on a missing ref (never silently undefined).
641
+ getService: <T>(ref: ServiceRef<T>) =>
642
+ this.registry.get(ref, backendPlugin.metadata),
637
643
  registerExtensionPoint: (ref, impl) => {
638
644
  this.extensionPointManager.registerExtensionPoint(ref, impl);
639
645
  },
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, mock, beforeEach } from "bun:test";
2
2
  import { PluginManager } from "./plugin-manager";
3
- import { coreServices, os, RpcContext } from "@checkstack/backend-api";
3
+ import { coreServices, os, createBackendPlugin } from "@checkstack/backend-api";
4
4
  import { Hono } from "hono";
5
5
  import { createMockDbModule } from "@checkstack/test-utils-backend";
6
6
  import { createMockLoggerModule } from "@checkstack/test-utils-backend";
@@ -10,7 +10,27 @@ mock.module("./db", () => createMockDbModule());
10
10
 
11
11
  mock.module("./logger", () => createMockLoggerModule());
12
12
 
13
- describe("RPC REST Compatibility", () => {
13
+ /**
14
+ * Guards the oRPC router REGISTRATION + MOUNTING wiring contract.
15
+ *
16
+ * Background (silent-failure seam): the core/backend test preload used to
17
+ * register a mock `rpc` service whose `registerRouter` was a no-op, so across
18
+ * the ENTIRE suite a plugin's router never landed in the shared maps the
19
+ * `/api/:pluginId/*` dispatcher reads. The one "reachability" test that
20
+ * existed only asserted on a 200 inside an `if`, so it passed forever on a
21
+ * 404. These tests assert the concrete wiring that was silently broken:
22
+ *
23
+ * 1. Resolving `coreServices.rpc` for a plugin and calling `registerRouter`
24
+ * lands the router AND its contract in the shared maps, keyed by the
25
+ * resolving plugin id (consumed by `createApiRouteHandler`).
26
+ * 2. `loadPlugins` mounts the `/api/:pluginId/*` route on the Hono app, so
27
+ * requests to a registered plugin reach the handler (no Hono-level 404).
28
+ *
29
+ * Note: exercising oRPC's RPC wire protocol end-to-end requires the plugin's
30
+ * real contract + protocol encoding; that is out of scope here. These guards
31
+ * cover the registration/mounting seam — the exact thing the no-op mock hid.
32
+ */
33
+ describe("oRPC router registration + mounting wiring", () => {
14
34
  let pluginManager: PluginManager;
15
35
  let app: Hono;
16
36
 
@@ -19,69 +39,58 @@ describe("RPC REST Compatibility", () => {
19
39
  app = new Hono();
20
40
  });
21
41
 
22
- it("should handle GET /api/auth/accessRules via oRPC router", async () => {
23
- // 1. Setup a mock auth router
42
+ it("registers a plugin router + contract into the shared maps the API handler reads", async () => {
24
43
  const authRouter = os.router({
25
- accessRules: os.handler(async () => {
26
- return { accessRules: ["test-perm"] };
27
- }),
44
+ accessRules: os.handler(async () => ({ accessRules: ["test-perm"] })),
28
45
  });
46
+ const contract = { accessRules: {} };
29
47
 
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, { accessRules: {} });
48
+ // Resolve the rpc service scoped to the `auth` plugin and register the
49
+ // router exactly how a plugin does it during init().
50
+ const rpcService = await pluginManager
51
+ .getRegistry()
52
+ .get(coreServices.rpc, { pluginId: "auth" });
53
+ rpcService.registerRouter(authRouter, contract);
37
54
 
38
- // 3. Mock the auth service to skip real authentication
39
- const mockAuth: any = {
40
- authenticate: mock(async () => ({ id: "user-1", accessRules: ["*"] })),
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
- pluginManager.registerService(coreServices.cachePluginRegistry, {
62
- getPlugins: () => [],
63
- } as any);
64
- pluginManager.registerService(coreServices.cacheManager, {
65
- getActivePlugin: () => "memory",
66
- getProvider: () => ({}),
67
- } as any);
55
+ // The contract MUST land in the shared registry the API route handler
56
+ // (and OpenAPI generation) consumes, keyed by the plugin id —
57
+ // `registerRouter` sets the router map and the contract map together. A
58
+ // no-op `registerRouter` (the prior regression) would leave this empty.
59
+ expect(pluginManager.getAllContracts().get("auth")).toBe(contract);
60
+ });
68
61
 
69
- // 4. Mount the plugins
70
- await pluginManager.loadPlugins(app);
62
+ it("mounts the /api/:pluginId/* route so a registered plugin reaches the handler (not a Hono 404)", async () => {
63
+ const authRouter = os.router({
64
+ accessRules: os.handler(async () => ({ accessRules: ["test-perm"] })),
65
+ });
66
+ const rpcService = await pluginManager
67
+ .getRegistry()
68
+ .get(coreServices.rpc, { pluginId: "auth" });
69
+ rpcService.registerRouter(authRouter, { accessRules: {} });
70
+ pluginManager.registerCorePluginMetadata({ pluginId: "auth" });
71
71
 
72
- // 5. Simulate the request that frontend makes (now /api/auth instead of /api/auth-backend)
73
- const res = await app.request("/api/auth/accessRules", {
74
- method: "GET",
72
+ // At least one (manual) plugin is required for loadPlugins to proceed
73
+ // past its "no plugins" early-return and actually mount the
74
+ // `/api/:pluginId/*` route.
75
+ const noopPlugin = createBackendPlugin({
76
+ metadata: { pluginId: "noop" },
77
+ register: () => {},
75
78
  });
79
+ await pluginManager.loadPlugins(app, [noopPlugin], { skipDiscovery: true });
76
80
 
77
- // If it's a 404, my theory about dots vs slashes or GET vs POST is correct
78
- console.log("Response status:", res.status);
79
- if (res.status === 200) {
80
- const body = await res.json();
81
- console.log("Response body:", JSON.stringify(body));
82
- expect(body.accessRules).toContain("test-perm");
83
- } else {
84
- console.log("Response text:", await res.text());
85
- }
81
+ // A request to `/api/:pluginId/*` must REACH the assembled API handler
82
+ // (the dispatcher), not fall through to a Hono 404. The dispatcher
83
+ // resolves the RpcContext first and, in this minimal harness, returns a
84
+ // handler-level 500 ("Core services not initialized") — which is exactly
85
+ // the proof we want: the route is mounted and the request reached the
86
+ // handler. (A regression that fails to mount the route would surface as
87
+ // a Hono 404 here instead.)
88
+ const reached = await app.request("/api/auth/accessRules", {
89
+ method: "POST",
90
+ headers: { "content-type": "application/json" },
91
+ body: "{}",
92
+ });
93
+ expect(reached.status).not.toBe(404);
94
+ expect(reached.status).toBe(500);
86
95
  });
87
96
  });
@@ -43,11 +43,18 @@ mock.module(loggerPath, () => createMockLoggerModule());
43
43
  mock.module(coreServicesPath, () => ({
44
44
  registerCoreServices: ({
45
45
  registry,
46
+ pluginRpcRouters,
47
+ pluginContractRegistry,
46
48
  }: {
47
49
  registry: {
48
- registerFactory: (ref: { id: string }, factory: unknown) => void;
50
+ registerFactory: (
51
+ ref: { id: string },
52
+ factory: (metadata: { pluginId: string }) => unknown,
53
+ ) => void;
49
54
  register: (ref: { id: string }, impl: unknown) => void;
50
55
  };
56
+ pluginRpcRouters?: Map<string, unknown>;
57
+ pluginContractRegistry?: Map<string, unknown>;
51
58
  }) => {
52
59
  // Register mock database factory - includes dialect.migrate for migration tests
53
60
  registry.registerFactory(coreServices.database, () => ({
@@ -101,9 +108,20 @@ mock.module(coreServicesPath, () => ({
101
108
  getAllStrategies: () => [...strategies.values()],
102
109
  }));
103
110
 
104
- // Register mock RPC service factory
105
- registry.registerFactory(coreServices.rpc, () => ({
106
- registerRouter: () => {},
111
+ // Register mock RPC service factory.
112
+ //
113
+ // This MUST mirror the production factory's router wiring: it keys the
114
+ // router + contract by the resolving plugin's id into the SAME shared
115
+ // maps the API route handler reads. A previous version stubbed
116
+ // `registerRouter` as a no-op, which silently disabled router
117
+ // registration across the entire core/backend test suite — making oRPC
118
+ // router reachability impossible to verify (a 404 looked identical to a
119
+ // wired route to any test that didn't assert hard).
120
+ registry.registerFactory(coreServices.rpc, (metadata) => ({
121
+ registerRouter: (router: unknown, contract: unknown): void => {
122
+ pluginRpcRouters?.set(metadata.pluginId, router);
123
+ pluginContractRegistry?.set(metadata.pluginId, contract);
124
+ },
107
125
  registerHttpHandler: () => {},
108
126
  }));
109
127