@checkstack/backend 0.10.4 → 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,98 @@
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
+
37
+ ## 0.11.0
38
+
39
+ ### Minor Changes
40
+
41
+ - 6d52276: feat(automation): expose `trigger.actor` so automations can filter on who/what caused an event
42
+
43
+ Every platform event now carries an **actor** - the user, application (API
44
+ client), service (backend-to-backend), or `system` (background /
45
+ unauthenticated) that caused it - and the automation engine surfaces it to
46
+ automations as `trigger.actor`. This lets a trigger filter gate on the
47
+ origin of the event it reacts to:
48
+
49
+ ```text
50
+ {{ trigger.actor.type == "system" }} # auto-created by the platform
51
+ {{ trigger.actor.type == "user" }} # a human
52
+ {{ trigger.actor.id == "app-deploybot" }} # a specific application
53
+ ```
54
+
55
+ `trigger.actor` is available on **every** trigger - it is injected by the
56
+ platform, not declared per trigger - and editor autocomplete + Run Script
57
+ context types include `trigger.actor.{type,id,name}`.
58
+
59
+ How it works:
60
+
61
+ - **`@checkstack/common`** adds the canonical `Actor` type / `ActorSchema`
62
+ and `SYSTEM_ACTOR`.
63
+ - **`@checkstack/backend-api`** adds `resolveActor(user)` and a
64
+ `HookEventMeta` envelope. The hook listener / `onHook` signature gains an
65
+ optional second `meta` argument (additive, backward compatible).
66
+ - **`@checkstack/backend`** wraps emitted hooks in an envelope so the actor
67
+ travels with the payload through the distributed queue, unwrapping it
68
+ before delivery. The RPC emit path captures the authenticated caller;
69
+ background emits default to the system actor. Raw/legacy queue data is
70
+ treated as a system-actor payload, so delivery stays backward compatible.
71
+ - **`@checkstack/automation-backend`** threads the actor into the dispatch
72
+ scope (`trigger.actor`), available to trigger filters, top-level
73
+ conditions, and all run templates, and persisted in the run's scope
74
+ snapshot. Manual runs are attributed to the invoking user.
75
+ - **`@checkstack/automation-common`** / **`@checkstack/automation-frontend`**
76
+ expose `trigger.actor` in the editor variable scope and the generated
77
+ Run Script `context.trigger.actor` types.
78
+
79
+ No database migration and no per-trigger schema changes: the actor rides as
80
+ event-envelope metadata and in the run scope snapshot.
81
+
82
+ ### Patch Changes
83
+
84
+ - Updated dependencies [6d52276]
85
+ - Updated dependencies [35bc682]
86
+ - @checkstack/common@0.12.0
87
+ - @checkstack/backend-api@0.18.0
88
+ - @checkstack/api-docs-common@0.1.15
89
+ - @checkstack/auth-common@0.7.2
90
+ - @checkstack/pluginmanager-common@0.2.4
91
+ - @checkstack/signal-backend@0.2.10
92
+ - @checkstack/signal-common@0.2.5
93
+ - @checkstack/cache-api@0.3.6
94
+ - @checkstack/queue-api@0.3.6
95
+
3
96
  ## 0.10.4
4
97
 
5
98
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.10.4",
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.0",
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.4",
23
- "@checkstack/queue-api": "0.3.4",
24
- "@checkstack/signal-backend": "0.2.8",
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.29",
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
  }
@@ -10,6 +10,7 @@ import {
10
10
  Fetch,
11
11
  HealthCheckRegistry,
12
12
  CollectorRegistry,
13
+ resolveActor,
13
14
  type EmitHookFn,
14
15
  type Hook,
15
16
  } from "@checkstack/backend-api";
@@ -126,7 +127,12 @@ async function resolveRequestContext({
126
127
  const user = await (auth as AuthService).authenticate(c.req.raw);
127
128
 
128
129
  const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
129
- await (eventBus as EventBus).emit(hook, payload);
130
+ // Capture the authenticated caller as the event actor so automations can
131
+ // filter on who/what caused the event (falls back to the system actor for
132
+ // unauthenticated callers).
133
+ await (eventBus as EventBus).emit(hook, payload, {
134
+ actor: resolveActor(user),
135
+ });
130
136
  };
131
137
 
132
138
  const pluginMetadata: PluginMetadata | undefined =
@@ -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
  });
@@ -3,6 +3,7 @@ import { EventBus } from "./event-bus";
3
3
  import type { QueueManager } from "@checkstack/queue-api";
4
4
  import type { Logger, Hook } from "@checkstack/backend-api";
5
5
  import { createHook } from "@checkstack/backend-api";
6
+ import { SYSTEM_ACTOR } from "@checkstack/common";
6
7
  import {
7
8
  createMockLogger,
8
9
  createMockQueueManager,
@@ -272,6 +273,63 @@ describe("EventBus", () => {
272
273
  });
273
274
  });
274
275
 
276
+ describe("Actor metadata", () => {
277
+ it("delivers the system actor by default when no meta is provided", async () => {
278
+ const testHook = createHook<{ value: number }>("test.actor.hook");
279
+ let receivedActor: unknown;
280
+
281
+ await eventBus.subscribe("plugin-1", testHook, async (_payload, meta) => {
282
+ receivedActor = meta?.actor;
283
+ });
284
+
285
+ await eventBus.emit(testHook, { value: 1 });
286
+ await new Promise((resolve) => setTimeout(resolve, 50));
287
+
288
+ expect(receivedActor).toEqual(SYSTEM_ACTOR);
289
+ });
290
+
291
+ it("delivers the provided actor alongside the payload", async () => {
292
+ const testHook = createHook<{ value: number }>("test.actor.hook");
293
+ let received: { value: number } | undefined;
294
+ let receivedActor: unknown;
295
+
296
+ await eventBus.subscribe("plugin-1", testHook, async (payload, meta) => {
297
+ received = payload;
298
+ receivedActor = meta?.actor;
299
+ });
300
+
301
+ const actor = { type: "user", id: "user-1", name: "Nico" } as const;
302
+ await eventBus.emit(testHook, { value: 7 }, { actor });
303
+ await new Promise((resolve) => setTimeout(resolve, 50));
304
+
305
+ expect(received).toEqual({ value: 7 });
306
+ expect(receivedActor).toEqual(actor);
307
+ });
308
+
309
+ it("delivers actor meta on instance-local emit", async () => {
310
+ const testHook = createHook<{ value: number }>("test.actor.local");
311
+ let receivedActor: unknown;
312
+
313
+ await eventBus.subscribe(
314
+ "plugin-1",
315
+ testHook,
316
+ async (_payload, meta) => {
317
+ receivedActor = meta?.actor;
318
+ },
319
+ { mode: "instance-local" },
320
+ );
321
+
322
+ const actor = {
323
+ type: "application",
324
+ id: "app-deploybot",
325
+ name: "Deploy Bot",
326
+ } as const;
327
+ await eventBus.emitLocal(testHook, { value: 1 }, { actor });
328
+
329
+ expect(receivedActor).toEqual(actor);
330
+ });
331
+ });
332
+
275
333
  describe("Shutdown", () => {
276
334
  it("should stop all queue channels", async () => {
277
335
  const hook1 = createHook<{ test: string }>("hook1");
@@ -1,13 +1,55 @@
1
1
  import type { Queue, QueueManager } from "@checkstack/queue-api";
2
2
  import type {
3
3
  Hook,
4
+ HookEventMeta,
4
5
  HookSubscribeOptions,
5
6
  HookUnsubscribe,
6
7
  Logger,
7
8
  } from "@checkstack/backend-api";
8
9
  import type { EventBus as IEventBus } from "@checkstack/backend-api";
10
+ import { SYSTEM_ACTOR } from "@checkstack/common";
9
11
 
10
- export type HookListener<T> = (payload: T) => Promise<void>;
12
+ export type HookListener<T> = (
13
+ payload: T,
14
+ meta?: HookEventMeta,
15
+ ) => Promise<void>;
16
+
17
+ /**
18
+ * Internal queue envelope. Hooks are enqueued wrapped so event metadata (the
19
+ * acting `actor`) rides alongside the typed payload through the distributed
20
+ * queue. `invokeListener` unwraps it before calling listeners, so subscribers
21
+ * still receive the payload as their first argument (plus optional `meta`).
22
+ */
23
+ const HOOK_ENVELOPE_MARKER = "__checkstackHookEnvelope" as const;
24
+
25
+ interface HookEnvelope<T> {
26
+ [HOOK_ENVELOPE_MARKER]: 1;
27
+ payload: T;
28
+ meta: HookEventMeta;
29
+ }
30
+
31
+ function isHookEnvelope(data: unknown): data is HookEnvelope<unknown> {
32
+ return (
33
+ typeof data === "object" &&
34
+ data !== null &&
35
+ (data as Record<string, unknown>)[HOOK_ENVELOPE_MARKER] === 1
36
+ );
37
+ }
38
+
39
+ /**
40
+ * Unwrap queued hook data into `{ payload, meta }`. Data emitted before the
41
+ * envelope existed (or enqueued by other producers) is treated as a raw
42
+ * payload with the system actor, so delivery stays backward compatible.
43
+ */
44
+ function unwrapHookData(data: unknown): {
45
+ payload: unknown;
46
+ meta: HookEventMeta;
47
+ } {
48
+ if (isHookEnvelope(data)) {
49
+ return { payload: data.payload, meta: data.meta };
50
+ }
51
+ return { payload: data, meta: { actor: SYSTEM_ACTOR } };
52
+ }
11
53
 
12
54
  interface ListenerRegistration {
13
55
  id: string;
@@ -201,7 +243,11 @@ export class EventBus implements IEventBus {
201
243
  * need cross-process delivery must therefore ensure at least one
202
244
  * listener registers on every replica that should receive the hook.
203
245
  */
204
- async emit<T>(hook: Hook<T>, payload: T): Promise<void> {
246
+ async emit<T>(
247
+ hook: Hook<T>,
248
+ payload: T,
249
+ meta?: HookEventMeta,
250
+ ): Promise<void> {
205
251
  const hasDistributedListeners =
206
252
  (this.listeners.get(hook.id)?.length ?? 0) > 0;
207
253
  const hasLocalListeners =
@@ -218,11 +264,18 @@ export class EventBus implements IEventBus {
218
264
 
219
265
  // Create channel lazily if not exists
220
266
  if (!channel) {
221
- channel = this.queueManager.getQueue<T>(hook.id);
267
+ channel = this.queueManager.getQueue<unknown>(hook.id);
222
268
  this.queueChannels.set(hook.id, channel);
223
269
  }
224
270
 
225
- await channel.enqueue(payload);
271
+ // Enqueue the payload wrapped in an envelope so the acting actor (defaulting
272
+ // to the system actor for background/unauthenticated emits) travels with it.
273
+ const envelope: HookEnvelope<T> = {
274
+ [HOOK_ENVELOPE_MARKER]: 1,
275
+ payload,
276
+ meta: meta ?? { actor: SYSTEM_ACTOR },
277
+ };
278
+ await channel.enqueue(envelope);
226
279
  this.logger.debug(`Emitted hook: ${hook.id}`);
227
280
  }
228
281
 
@@ -231,7 +284,11 @@ export class EventBus implements IEventBus {
231
284
  * Use this for instance-local hooks like pluginDeregistering.
232
285
  * Uses Promise.allSettled to ensure one listener error doesn't block others.
233
286
  */
234
- async emitLocal<T>(hook: Hook<T>, payload: T): Promise<void> {
287
+ async emitLocal<T>(
288
+ hook: Hook<T>,
289
+ payload: T,
290
+ meta?: HookEventMeta,
291
+ ): Promise<void> {
235
292
  const registrations = this.localListeners.get(hook.id) || [];
236
293
 
237
294
  if (registrations.length === 0) {
@@ -239,10 +296,13 @@ export class EventBus implements IEventBus {
239
296
  return;
240
297
  }
241
298
 
299
+ // Local hooks bypass the queue, so deliver `meta` straight to listeners
300
+ // (defaulting to the system actor when none was provided).
301
+ const resolvedMeta: HookEventMeta = meta ?? { actor: SYSTEM_ACTOR };
242
302
  const results = await Promise.allSettled(
243
303
  registrations.map(async (reg) => {
244
304
  try {
245
- await reg.listener(payload);
305
+ await reg.listener(payload, resolvedMeta);
246
306
  this.logger.debug(
247
307
  `Local listener ${reg.id} (${reg.pluginId}) processed successfully`
248
308
  );
@@ -315,10 +375,11 @@ export class EventBus implements IEventBus {
315
375
  */
316
376
  private async invokeListener(
317
377
  registration: ListenerRegistration,
318
- payload: unknown
378
+ data: unknown
319
379
  ): Promise<void> {
380
+ const { payload, meta } = unwrapHookData(data);
320
381
  try {
321
- await registration.listener(payload);
382
+ await registration.listener(payload, meta);
322
383
  this.logger.debug(
323
384
  `Listener ${registration.id} (${registration.consumerGroup}) processed successfully`
324
385
  );
@@ -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