@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 +93 -0
- package/package.json +12 -12
- package/src/plugin-manager/api-router.ts +7 -1
- package/src/plugin-manager/core-services.ts +11 -0
- package/src/plugin-manager/plugin-loader.getservice.test.ts +108 -0
- package/src/plugin-manager/plugin-loader.ts +8 -0
- package/src/plugin-manager.ts +6 -0
- package/src/rpc-rest-compat.test.ts +67 -58
- package/src/services/event-bus.test.ts +58 -0
- package/src/services/event-bus.ts +69 -8
- package/src/test-preload.ts +22 -4
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.
|
|
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.
|
|
18
|
-
"@checkstack/auth-common": "0.7.
|
|
19
|
-
"@checkstack/backend-api": "0.
|
|
20
|
-
"@checkstack/common": "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.
|
|
23
|
-
"@checkstack/queue-api": "0.3.
|
|
24
|
-
"@checkstack/signal-backend": "0.2.
|
|
25
|
-
"@checkstack/signal-common": "0.2.
|
|
26
|
-
"@checkstack/pluginmanager-common": "0.2.
|
|
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.
|
|
49
|
-
"@checkstack/test-utils-backend": "0.1.
|
|
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
|
-
|
|
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
|
},
|
package/src/plugin-manager.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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("
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
const rpcService = await pluginManager
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
//
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
pluginManager.
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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> = (
|
|
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>(
|
|
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<
|
|
267
|
+
channel = this.queueManager.getQueue<unknown>(hook.id);
|
|
222
268
|
this.queueChannels.set(hook.id, channel);
|
|
223
269
|
}
|
|
224
270
|
|
|
225
|
-
|
|
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>(
|
|
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
|
-
|
|
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
|
);
|
package/src/test-preload.ts
CHANGED
|
@@ -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: (
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|