@decocms/runtime 1.2.15 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,411 @@
1
+ import { describe, expect, it, spyOn } from "bun:test";
2
+ import { z } from "zod";
3
+ import { createTriggers, type TriggerStorage } from "./triggers.ts";
4
+
5
+ // biome-ignore lint: test mocks don't need full type compliance
6
+ const mockCtx = (connectionId?: string) =>
7
+ ({
8
+ env: connectionId
9
+ ? { MESH_REQUEST_CONTEXT: { connectionId } }
10
+ : { MESH_REQUEST_CONTEXT: {} },
11
+ ctx: { waitUntil: () => {} },
12
+ }) as any; // eslint-disable-line @typescript-eslint/no-explicit-any
13
+
14
+ const triggers = createTriggers([
15
+ {
16
+ type: "github.push",
17
+ description: "Triggered when code is pushed",
18
+ params: z.object({
19
+ repo: z.string().describe("Repository full name (owner/repo)"),
20
+ }),
21
+ },
22
+ {
23
+ type: "github.pull_request.opened",
24
+ description: "Triggered when a PR is opened",
25
+ params: z.object({
26
+ repo: z.string().describe("Repository full name"),
27
+ }),
28
+ },
29
+ ]);
30
+
31
+ describe("createTriggers", () => {
32
+ it("tools() returns TRIGGER_LIST and TRIGGER_CONFIGURE", () => {
33
+ const tools = triggers.tools();
34
+ expect(tools).toHaveLength(2);
35
+ expect(tools[0].id).toBe("TRIGGER_LIST");
36
+ expect(tools[1].id).toBe("TRIGGER_CONFIGURE");
37
+ });
38
+
39
+ it("TRIGGER_LIST returns trigger definitions with paramsSchema", async () => {
40
+ const listTool = triggers.tools()[0];
41
+ const result = (await listTool.execute({
42
+ context: {},
43
+ runtimeContext: mockCtx(),
44
+ })) as {
45
+ triggers: Array<{ type: string; paramsSchema: Record<string, unknown> }>;
46
+ };
47
+
48
+ expect(result.triggers).toHaveLength(2);
49
+ expect(result.triggers[0].type).toBe("github.push");
50
+ expect(result.triggers[0].paramsSchema).toEqual({
51
+ repo: {
52
+ type: "string",
53
+ description: "Repository full name (owner/repo)",
54
+ },
55
+ });
56
+ expect(result.triggers[1].type).toBe("github.pull_request.opened");
57
+ });
58
+
59
+ it("TRIGGER_LIST includes enum values from z.enum params", async () => {
60
+ const enumTriggers = createTriggers([
61
+ {
62
+ type: "test.event",
63
+ description: "Test",
64
+ params: z.object({
65
+ action: z.enum(["opened", "closed", "merged"]).describe("PR action"),
66
+ }),
67
+ },
68
+ ]);
69
+ const listTool = enumTriggers.tools()[0];
70
+ const result = (await listTool.execute({
71
+ context: {},
72
+ runtimeContext: mockCtx(),
73
+ })) as {
74
+ triggers: Array<{ paramsSchema: Record<string, { enum?: string[] }> }>;
75
+ };
76
+ expect(result.triggers[0].paramsSchema.action.enum).toEqual([
77
+ "opened",
78
+ "closed",
79
+ "merged",
80
+ ]);
81
+ });
82
+
83
+ it("TRIGGER_CONFIGURE stores callback credentials and notify delivers", async () => {
84
+ const configureTool = triggers.tools()[1];
85
+
86
+ const mockResponse = new Response("ok", { status: 202 });
87
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(mockResponse);
88
+
89
+ // Configure a trigger with callback
90
+ await configureTool.execute({
91
+ context: {
92
+ type: "github.push",
93
+ params: { repo: "owner/repo" },
94
+ enabled: true,
95
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
96
+ callbackToken: "test-token-123",
97
+ },
98
+ runtimeContext: mockCtx("conn-1"),
99
+ });
100
+
101
+ // Notify should POST to the callback URL
102
+ triggers.notify("conn-1", "github.push", {
103
+ repository: { full_name: "owner/repo" },
104
+ });
105
+
106
+ // Wait for the fire-and-forget fetch
107
+ await new Promise((r) => setTimeout(r, 50));
108
+
109
+ expect(fetchSpy).toHaveBeenCalledWith(
110
+ "https://mesh.example.com/api/trigger-callback",
111
+ expect.objectContaining({
112
+ method: "POST",
113
+ headers: {
114
+ "Content-Type": "application/json",
115
+ Authorization: "Bearer test-token-123",
116
+ },
117
+ }),
118
+ );
119
+
120
+ const callBody = JSON.parse(
121
+ (fetchSpy.mock.calls[0][1] as RequestInit).body as string,
122
+ );
123
+ expect(callBody.type).toBe("github.push");
124
+ expect(callBody.data.repository.full_name).toBe("owner/repo");
125
+
126
+ fetchSpy.mockRestore();
127
+ });
128
+
129
+ it("disabling one trigger keeps credentials when another is still active", async () => {
130
+ const configureTool = triggers.tools()[1];
131
+
132
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
133
+ new Response("ok", { status: 202 }),
134
+ );
135
+
136
+ // Enable two trigger types
137
+ await configureTool.execute({
138
+ context: {
139
+ type: "github.push",
140
+ params: {},
141
+ enabled: true,
142
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
143
+ callbackToken: "token-multi",
144
+ },
145
+ runtimeContext: mockCtx("conn-multi"),
146
+ });
147
+ await configureTool.execute({
148
+ context: {
149
+ type: "github.pull_request.opened",
150
+ params: {},
151
+ enabled: true,
152
+ },
153
+ runtimeContext: mockCtx("conn-multi"),
154
+ });
155
+
156
+ // Disable one — credentials should stay for the other
157
+ await configureTool.execute({
158
+ context: {
159
+ type: "github.push",
160
+ params: {},
161
+ enabled: false,
162
+ },
163
+ runtimeContext: mockCtx("conn-multi"),
164
+ });
165
+
166
+ triggers.notify("conn-multi", "github.pull_request.opened", {});
167
+ await new Promise((r) => setTimeout(r, 50));
168
+ expect(fetchSpy).toHaveBeenCalled();
169
+
170
+ fetchSpy.mockRestore();
171
+ });
172
+
173
+ it("disabling the last trigger clears credentials", async () => {
174
+ const configureTool = triggers.tools()[1];
175
+
176
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
177
+ new Response("ok", { status: 202 }),
178
+ );
179
+ const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
180
+
181
+ // Enable a trigger
182
+ await configureTool.execute({
183
+ context: {
184
+ type: "github.push",
185
+ params: {},
186
+ enabled: true,
187
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
188
+ callbackToken: "token-cleanup",
189
+ },
190
+ runtimeContext: mockCtx("conn-cleanup"),
191
+ });
192
+
193
+ // Disable it — last trigger, credentials should be cleared
194
+ await configureTool.execute({
195
+ context: {
196
+ type: "github.push",
197
+ params: {},
198
+ enabled: false,
199
+ },
200
+ runtimeContext: mockCtx("conn-cleanup"),
201
+ });
202
+
203
+ triggers.notify("conn-cleanup", "github.push", {});
204
+ await new Promise((r) => setTimeout(r, 50));
205
+ expect(fetchSpy).not.toHaveBeenCalled();
206
+ expect(consoleSpy).toHaveBeenCalledWith(
207
+ expect.stringContaining("No callback credentials"),
208
+ );
209
+
210
+ fetchSpy.mockRestore();
211
+ consoleSpy.mockRestore();
212
+ });
213
+
214
+ it("notify is a no-op when no credentials exist", async () => {
215
+ const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
216
+ triggers.notify("unknown-conn", "github.push", {});
217
+ await new Promise((r) => setTimeout(r, 50));
218
+ expect(consoleSpy).toHaveBeenCalledWith(
219
+ expect.stringContaining("No callback credentials"),
220
+ );
221
+ consoleSpy.mockRestore();
222
+ });
223
+
224
+ it("notify logs error on non-2xx response", async () => {
225
+ const configureTool = triggers.tools()[1];
226
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
227
+ new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
228
+ );
229
+ const errorSpy = spyOn(console, "error").mockImplementation(() => {});
230
+
231
+ // Ensure credentials exist (reuse from prior test state or set up fresh)
232
+ await configureTool.execute({
233
+ context: {
234
+ type: "github.push",
235
+ params: {},
236
+ enabled: true,
237
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
238
+ callbackToken: "token-err",
239
+ },
240
+ runtimeContext: mockCtx("conn-err"),
241
+ });
242
+
243
+ fetchSpy.mockResolvedValue(
244
+ new Response("Unauthorized", { status: 401, statusText: "Unauthorized" }),
245
+ );
246
+
247
+ triggers.notify("conn-err", "github.push", {});
248
+ await new Promise((r) => setTimeout(r, 50));
249
+
250
+ expect(errorSpy).toHaveBeenCalledWith(
251
+ expect.stringContaining("Callback delivery failed"),
252
+ );
253
+
254
+ fetchSpy.mockRestore();
255
+ errorSpy.mockRestore();
256
+ });
257
+
258
+ it("TRIGGER_CONFIGURE throws without connectionId", async () => {
259
+ const configureTool = triggers.tools()[1];
260
+ expect(
261
+ configureTool.execute({
262
+ context: { type: "github.push", params: {}, enabled: true },
263
+ runtimeContext: mockCtx(),
264
+ }),
265
+ ).rejects.toThrow("Connection ID not available");
266
+ });
267
+ });
268
+
269
+ describe("createTriggers with storage", () => {
270
+ function createMockStorage(): TriggerStorage & {
271
+ data: Map<string, unknown>;
272
+ } {
273
+ const data = new Map<string, unknown>();
274
+ return {
275
+ data,
276
+ get: async (id) => (data.get(id) as any) ?? null,
277
+ set: async (id, state) => {
278
+ data.set(id, state);
279
+ },
280
+ delete: async (id) => {
281
+ data.delete(id);
282
+ },
283
+ };
284
+ }
285
+
286
+ const defs = [
287
+ {
288
+ type: "github.push" as const,
289
+ description: "Push",
290
+ params: z.object({
291
+ repo: z.string().describe("Repo"),
292
+ }),
293
+ },
294
+ ];
295
+
296
+ it("persists trigger state to storage on configure", async () => {
297
+ const storage = createMockStorage();
298
+ const t = createTriggers({ definitions: defs, storage });
299
+ const configureTool = t.tools()[1];
300
+
301
+ await configureTool.execute({
302
+ context: {
303
+ type: "github.push",
304
+ params: {},
305
+ enabled: true,
306
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
307
+ callbackToken: "persisted-token",
308
+ },
309
+ runtimeContext: mockCtx("conn-persist"),
310
+ });
311
+
312
+ expect(storage.data.has("conn-persist")).toBe(true);
313
+ const stored = storage.data.get("conn-persist") as any;
314
+ expect(stored.credentials.callbackToken).toBe("persisted-token");
315
+ expect(stored.activeTriggerTypes).toEqual(["github.push"]);
316
+ });
317
+
318
+ it("deletes from storage when last trigger is disabled", async () => {
319
+ const storage = createMockStorage();
320
+ const t = createTriggers({ definitions: defs, storage });
321
+ const configureTool = t.tools()[1];
322
+
323
+ await configureTool.execute({
324
+ context: {
325
+ type: "github.push",
326
+ params: {},
327
+ enabled: true,
328
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
329
+ callbackToken: "to-delete",
330
+ },
331
+ runtimeContext: mockCtx("conn-del"),
332
+ });
333
+
334
+ expect(storage.data.has("conn-del")).toBe(true);
335
+
336
+ await configureTool.execute({
337
+ context: { type: "github.push", params: {}, enabled: false },
338
+ runtimeContext: mockCtx("conn-del"),
339
+ });
340
+
341
+ expect(storage.data.has("conn-del")).toBe(false);
342
+ });
343
+
344
+ it("restores credentials from storage on notify after restart", async () => {
345
+ const storage = createMockStorage();
346
+
347
+ // Simulate prior session: write state directly to storage
348
+ storage.data.set("conn-restart", {
349
+ credentials: {
350
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
351
+ callbackToken: "restored-token",
352
+ },
353
+ activeTriggerTypes: ["github.push"],
354
+ });
355
+
356
+ // New instance (simulates restart) — in-memory cache is empty
357
+ const t = createTriggers({ definitions: defs, storage });
358
+
359
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue(
360
+ new Response("ok", { status: 202 }),
361
+ );
362
+
363
+ t.notify("conn-restart", "github.push", { test: true });
364
+ await new Promise((r) => setTimeout(r, 50));
365
+
366
+ expect(fetchSpy).toHaveBeenCalledWith(
367
+ "https://mesh.example.com/api/trigger-callback",
368
+ expect.objectContaining({
369
+ headers: expect.objectContaining({
370
+ Authorization: "Bearer restored-token",
371
+ }),
372
+ }),
373
+ );
374
+
375
+ fetchSpy.mockRestore();
376
+ });
377
+
378
+ it("disable after restart clears persisted credentials from storage", async () => {
379
+ const storage = createMockStorage();
380
+
381
+ // Simulate prior session
382
+ storage.data.set("conn-disable-restart", {
383
+ credentials: {
384
+ callbackUrl: "https://mesh.example.com/api/trigger-callback",
385
+ callbackToken: "stale-token",
386
+ },
387
+ activeTriggerTypes: ["github.push"],
388
+ });
389
+
390
+ // New instance (simulates restart)
391
+ const t = createTriggers({ definitions: defs, storage });
392
+ const configureTool = t.tools()[1];
393
+
394
+ // Disable the trigger — should load from storage, then clean up
395
+ await configureTool.execute({
396
+ context: { type: "github.push", params: {}, enabled: false },
397
+ runtimeContext: mockCtx("conn-disable-restart"),
398
+ });
399
+
400
+ expect(storage.data.has("conn-disable-restart")).toBe(false);
401
+
402
+ // notify should be a no-op
403
+ const consoleSpy = spyOn(console, "log").mockImplementation(() => {});
404
+ t.notify("conn-disable-restart", "github.push", {});
405
+ await new Promise((r) => setTimeout(r, 50));
406
+ expect(consoleSpy).toHaveBeenCalledWith(
407
+ expect.stringContaining("No callback credentials"),
408
+ );
409
+ consoleSpy.mockRestore();
410
+ });
411
+ });
@@ -0,0 +1,307 @@
1
+ import {
2
+ TriggerConfigureInputSchema,
3
+ TriggerListOutputSchema,
4
+ type TriggerDefinition,
5
+ } from "@decocms/bindings/trigger";
6
+ import { z, type ZodObject, type ZodRawShape } from "zod";
7
+ import type { DefaultEnv } from "./index.ts";
8
+ import { createTool, type CreatedTool } from "./tools.ts";
9
+
10
+ interface CallbackCredentials {
11
+ callbackUrl: string;
12
+ callbackToken: string;
13
+ }
14
+
15
+ interface TriggerState {
16
+ credentials: CallbackCredentials;
17
+ activeTriggerTypes: string[];
18
+ }
19
+
20
+ /**
21
+ * Storage interface for persisting trigger state across MCP restarts.
22
+ *
23
+ * Implement this with your storage backend (KV, DB, file system, etc.)
24
+ * and pass it to `createTriggers({ storage })`.
25
+ *
26
+ * Keys are connection IDs, values are serializable trigger state objects.
27
+ */
28
+ export interface TriggerStorage {
29
+ get(connectionId: string): Promise<TriggerState | null>;
30
+ set(connectionId: string, state: TriggerState): Promise<void>;
31
+ delete(connectionId: string): Promise<void>;
32
+ }
33
+
34
+ interface TriggerDef<
35
+ TType extends string = string,
36
+ TParams extends ZodObject<ZodRawShape> = ZodObject<ZodRawShape>,
37
+ > {
38
+ type: TType;
39
+ description: string;
40
+ params: TParams;
41
+ }
42
+
43
+ interface TriggersOptions<TDefs extends TriggerDef[]> {
44
+ definitions: TDefs;
45
+ storage?: TriggerStorage;
46
+ }
47
+
48
+ interface Triggers<TDefs extends TriggerDef[]> {
49
+ /**
50
+ * Returns TRIGGER_LIST and TRIGGER_CONFIGURE tools
51
+ * ready to be spread into your `withRuntime({ tools })` array.
52
+ */
53
+ tools(): CreatedTool[];
54
+
55
+ /**
56
+ * Notify Mesh that an event occurred.
57
+ * The SDK matches it to stored callback credentials and POSTs to Mesh.
58
+ * Fire-and-forget — errors are logged, not thrown.
59
+ */
60
+ notify<T extends TDefs[number]["type"]>(
61
+ connectionId: string,
62
+ type: T,
63
+ data: Record<string, unknown>,
64
+ ): void;
65
+ }
66
+
67
+ // In-memory cache backed by optional persistent storage
68
+ class TriggerStateManager {
69
+ private credentials = new Map<string, CallbackCredentials>();
70
+ private activeTriggers = new Map<string, Set<string>>();
71
+ private storage: TriggerStorage | null;
72
+
73
+ constructor(storage?: TriggerStorage) {
74
+ this.storage = storage ?? null;
75
+ }
76
+
77
+ getCredentials(connectionId: string): CallbackCredentials | undefined {
78
+ return this.credentials.get(connectionId);
79
+ }
80
+
81
+ async loadFromStorage(connectionId: string): Promise<void> {
82
+ if (!this.storage || this.credentials.has(connectionId)) return;
83
+ const state = await this.storage.get(connectionId);
84
+ if (state) {
85
+ this.credentials.set(connectionId, state.credentials);
86
+ this.activeTriggers.set(connectionId, new Set(state.activeTriggerTypes));
87
+ }
88
+ }
89
+
90
+ async enable(
91
+ connectionId: string,
92
+ triggerType: string,
93
+ newCredentials?: CallbackCredentials,
94
+ ): Promise<void> {
95
+ if (newCredentials) {
96
+ this.credentials.set(connectionId, newCredentials);
97
+ }
98
+
99
+ const types = this.activeTriggers.get(connectionId) ?? new Set();
100
+ types.add(triggerType);
101
+ this.activeTriggers.set(connectionId, types);
102
+
103
+ await this.persist(connectionId);
104
+ }
105
+
106
+ async disable(connectionId: string, triggerType: string): Promise<void> {
107
+ // Ensure state is loaded (may be empty after restart)
108
+ await this.loadFromStorage(connectionId);
109
+ const types = this.activeTriggers.get(connectionId);
110
+ if (types) {
111
+ types.delete(triggerType);
112
+ if (types.size === 0) {
113
+ this.activeTriggers.delete(connectionId);
114
+ this.credentials.delete(connectionId);
115
+ await this.storage?.delete(connectionId);
116
+ return;
117
+ }
118
+ }
119
+
120
+ await this.persist(connectionId);
121
+ }
122
+
123
+ private async persist(connectionId: string): Promise<void> {
124
+ if (!this.storage) return;
125
+ const creds = this.credentials.get(connectionId);
126
+ const types = this.activeTriggers.get(connectionId);
127
+ if (!creds || !types || types.size === 0) return;
128
+ await this.storage.set(connectionId, {
129
+ credentials: creds,
130
+ activeTriggerTypes: [...types],
131
+ });
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Create a trigger SDK for your MCP.
137
+ *
138
+ * @example
139
+ * ```typescript
140
+ * import { createTriggers } from "@decocms/runtime/triggers";
141
+ * import { z } from "zod";
142
+ *
143
+ * const triggers = createTriggers({
144
+ * definitions: [
145
+ * {
146
+ * type: "github.push",
147
+ * description: "Triggered when code is pushed to a repository",
148
+ * params: z.object({
149
+ * repo: z.string().describe("Repository full name (owner/repo)"),
150
+ * }),
151
+ * },
152
+ * ],
153
+ * // Optional: persist trigger state across restarts
154
+ * storage: myKVStorage,
155
+ * });
156
+ *
157
+ * // In withRuntime:
158
+ * export default withRuntime({
159
+ * tools: [() => triggers.tools()],
160
+ * });
161
+ *
162
+ * // In webhook handler:
163
+ * triggers.notify(connectionId, "github.push", payload);
164
+ * ```
165
+ */
166
+ export function createTriggers<const TDefs extends TriggerDef[]>(
167
+ input: TDefs | TriggersOptions<TDefs>,
168
+ ): Triggers<TDefs> {
169
+ const { definitions, storage } = Array.isArray(input)
170
+ ? { definitions: input as TDefs, storage: undefined }
171
+ : input;
172
+
173
+ const state = new TriggerStateManager(storage);
174
+
175
+ const triggerDefinitions: TriggerDefinition[] = definitions.map((def) => {
176
+ const shape = def.params.shape;
177
+ const paramsSchema: Record<
178
+ string,
179
+ { type: "string"; description?: string; enum?: string[] }
180
+ > = {};
181
+
182
+ for (const [key, value] of Object.entries(shape)) {
183
+ const zodField = value as z.ZodTypeAny;
184
+ const entry: {
185
+ type: "string";
186
+ description?: string;
187
+ enum?: string[];
188
+ } = {
189
+ type: "string" as const,
190
+ description: zodField.description,
191
+ };
192
+
193
+ // Extract enum values from z.enum() schemas
194
+ if ("options" in zodField && Array.isArray(zodField.options)) {
195
+ entry.enum = zodField.options as string[];
196
+ }
197
+
198
+ paramsSchema[key] = entry;
199
+ }
200
+
201
+ return {
202
+ type: def.type,
203
+ description: def.description,
204
+ paramsSchema,
205
+ };
206
+ });
207
+
208
+ const TRIGGER_LIST = createTool({
209
+ id: "TRIGGER_LIST" as const,
210
+ description: "List available trigger definitions",
211
+ inputSchema: z.object({}),
212
+ outputSchema: TriggerListOutputSchema,
213
+ execute: async () => {
214
+ return { triggers: triggerDefinitions };
215
+ },
216
+ });
217
+
218
+ const TRIGGER_CONFIGURE = createTool({
219
+ id: "TRIGGER_CONFIGURE" as const,
220
+ description: "Configure a trigger with parameters",
221
+ inputSchema: TriggerConfigureInputSchema,
222
+ outputSchema: z.object({ success: z.boolean() }),
223
+ execute: async ({ context, runtimeContext }) => {
224
+ const connectionId = (runtimeContext?.env as unknown as DefaultEnv)
225
+ ?.MESH_REQUEST_CONTEXT?.connectionId;
226
+
227
+ if (!connectionId) {
228
+ throw new Error("Connection ID not available");
229
+ }
230
+
231
+ if (context.enabled) {
232
+ const creds =
233
+ context.callbackUrl && context.callbackToken
234
+ ? {
235
+ callbackUrl: context.callbackUrl,
236
+ callbackToken: context.callbackToken,
237
+ }
238
+ : undefined;
239
+ await state.enable(connectionId, context.type, creds);
240
+ } else {
241
+ await state.disable(connectionId, context.type);
242
+ }
243
+
244
+ return { success: true };
245
+ },
246
+ });
247
+
248
+ return {
249
+ tools() {
250
+ return [TRIGGER_LIST, TRIGGER_CONFIGURE] as CreatedTool[];
251
+ },
252
+
253
+ notify(connectionId, type, data) {
254
+ // Try in-memory first, fall back to storage load
255
+ const credentials = state.getCredentials(connectionId);
256
+ if (credentials) {
257
+ deliverCallback(credentials, type, data);
258
+ return;
259
+ }
260
+
261
+ // Attempt async load from storage (fire-and-forget)
262
+ state
263
+ .loadFromStorage(connectionId)
264
+ .then(() => {
265
+ const loaded = state.getCredentials(connectionId);
266
+ if (loaded) {
267
+ deliverCallback(loaded, type, data);
268
+ } else {
269
+ console.log(
270
+ `[Triggers] No callback credentials for connection=${connectionId}, skipping notify`,
271
+ );
272
+ }
273
+ })
274
+ .catch((err) => {
275
+ console.error(
276
+ `[Triggers] Failed to load credentials for ${connectionId}:`,
277
+ err,
278
+ );
279
+ });
280
+ },
281
+ };
282
+ }
283
+
284
+ function deliverCallback(
285
+ credentials: CallbackCredentials,
286
+ type: string,
287
+ data: Record<string, unknown>,
288
+ ): void {
289
+ fetch(credentials.callbackUrl, {
290
+ method: "POST",
291
+ headers: {
292
+ "Content-Type": "application/json",
293
+ Authorization: `Bearer ${credentials.callbackToken}`,
294
+ },
295
+ body: JSON.stringify({ type, data }),
296
+ })
297
+ .then((res) => {
298
+ if (!res.ok) {
299
+ console.error(
300
+ `[Triggers] Callback delivery failed for ${type}: ${res.status} ${res.statusText}`,
301
+ );
302
+ }
303
+ })
304
+ .catch((err) => {
305
+ console.error(`[Triggers] Failed to deliver callback for ${type}:`, err);
306
+ });
307
+ }