@checkstack/backend 0.10.3 → 0.11.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,73 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.11.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 6d52276: feat(automation): expose `trigger.actor` so automations can filter on who/what caused an event
8
+
9
+ Every platform event now carries an **actor** - the user, application (API
10
+ client), service (backend-to-backend), or `system` (background /
11
+ unauthenticated) that caused it - and the automation engine surfaces it to
12
+ automations as `trigger.actor`. This lets a trigger filter gate on the
13
+ origin of the event it reacts to:
14
+
15
+ ```text
16
+ {{ trigger.actor.type == "system" }} # auto-created by the platform
17
+ {{ trigger.actor.type == "user" }} # a human
18
+ {{ trigger.actor.id == "app-deploybot" }} # a specific application
19
+ ```
20
+
21
+ `trigger.actor` is available on **every** trigger - it is injected by the
22
+ platform, not declared per trigger - and editor autocomplete + Run Script
23
+ context types include `trigger.actor.{type,id,name}`.
24
+
25
+ How it works:
26
+
27
+ - **`@checkstack/common`** adds the canonical `Actor` type / `ActorSchema`
28
+ and `SYSTEM_ACTOR`.
29
+ - **`@checkstack/backend-api`** adds `resolveActor(user)` and a
30
+ `HookEventMeta` envelope. The hook listener / `onHook` signature gains an
31
+ optional second `meta` argument (additive, backward compatible).
32
+ - **`@checkstack/backend`** wraps emitted hooks in an envelope so the actor
33
+ travels with the payload through the distributed queue, unwrapping it
34
+ before delivery. The RPC emit path captures the authenticated caller;
35
+ background emits default to the system actor. Raw/legacy queue data is
36
+ treated as a system-actor payload, so delivery stays backward compatible.
37
+ - **`@checkstack/automation-backend`** threads the actor into the dispatch
38
+ scope (`trigger.actor`), available to trigger filters, top-level
39
+ conditions, and all run templates, and persisted in the run's scope
40
+ snapshot. Manual runs are attributed to the invoking user.
41
+ - **`@checkstack/automation-common`** / **`@checkstack/automation-frontend`**
42
+ expose `trigger.actor` in the editor variable scope and the generated
43
+ Run Script `context.trigger.actor` types.
44
+
45
+ No database migration and no per-trigger schema changes: the actor rides as
46
+ event-envelope metadata and in the run scope snapshot.
47
+
48
+ ### Patch Changes
49
+
50
+ - Updated dependencies [6d52276]
51
+ - Updated dependencies [35bc682]
52
+ - @checkstack/common@0.12.0
53
+ - @checkstack/backend-api@0.18.0
54
+ - @checkstack/api-docs-common@0.1.15
55
+ - @checkstack/auth-common@0.7.2
56
+ - @checkstack/pluginmanager-common@0.2.4
57
+ - @checkstack/signal-backend@0.2.10
58
+ - @checkstack/signal-common@0.2.5
59
+ - @checkstack/cache-api@0.3.6
60
+ - @checkstack/queue-api@0.3.6
61
+
62
+ ## 0.10.4
63
+
64
+ ### Patch Changes
65
+
66
+ - @checkstack/backend-api@0.17.1
67
+ - @checkstack/cache-api@0.3.5
68
+ - @checkstack/queue-api@0.3.5
69
+ - @checkstack/signal-backend@0.2.9
70
+
3
71
  ## 0.10.3
4
72
 
5
73
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.10.3",
3
+ "version": "0.11.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.13",
18
- "@checkstack/auth-common": "0.7.0",
19
- "@checkstack/backend-api": "0.16.0",
20
- "@checkstack/common": "0.10.0",
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",
21
21
  "@checkstack/drizzle-helper": "0.0.5",
22
- "@checkstack/cache-api": "0.3.3",
23
- "@checkstack/queue-api": "0.3.3",
24
- "@checkstack/signal-backend": "0.2.7",
25
- "@checkstack/signal-common": "0.2.3",
26
- "@checkstack/pluginmanager-common": "0.2.2",
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",
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.2",
49
- "@checkstack/test-utils-backend": "0.1.28",
48
+ "@checkstack/scripts": "0.3.3",
49
+ "@checkstack/test-utils-backend": "0.1.30",
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 =
@@ -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
  );