@desplega.ai/agent-swarm 1.80.2 → 1.80.3
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/README.md +3 -0
- package/openapi.json +1 -1
- package/package.json +1 -1
- package/src/agentmail/types.ts +1 -0
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
package/README.md
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
<a href="https://x.com/desplegalabs">
|
|
35
35
|
<img src="https://img.shields.io/badge/𝕏-@desplegalabs-000?style=for-the-badge&logo=x&logoColor=white" alt="Follow on X">
|
|
36
36
|
</a>
|
|
37
|
+
<a href="https://www.linkedin.com/company/desplega-labs/">
|
|
38
|
+
<img src="https://img.shields.io/badge/LinkedIn-Desplega%20Labs-0A66C2?style=for-the-badge&logo=linkedin&logoColor=white" alt="Desplega Labs on LinkedIn">
|
|
39
|
+
</a>
|
|
37
40
|
</p>
|
|
38
41
|
|
|
39
42
|
> **What if your AI agents remembered everything, learned from every mistake, and got better with every task?**
|
package/openapi.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"openapi": "3.1.0",
|
|
3
3
|
"info": {
|
|
4
4
|
"title": "Agent Swarm API",
|
|
5
|
-
"version": "1.80.
|
|
5
|
+
"version": "1.80.3",
|
|
6
6
|
"description": "Multi-agent orchestration API for Claude Code, Codex, and Gemini CLI. Enables task distribution, agent communication, and service discovery.\n\nMCP tools are documented separately in [MCP.md](./MCP.md)."
|
|
7
7
|
},
|
|
8
8
|
"servers": [
|
package/package.json
CHANGED
package/src/agentmail/types.ts
CHANGED
package/src/http/webhooks.ts
CHANGED
|
@@ -412,9 +412,18 @@ export async function handleWebhooks(
|
|
|
412
412
|
|
|
413
413
|
try {
|
|
414
414
|
switch (payload.event_type) {
|
|
415
|
+
case "message.received.unauthenticated":
|
|
416
|
+
console.warn(
|
|
417
|
+
`[AgentMail] Received unauthenticated message - treating as received event for inbox ${payload.message?.inbox_id ?? "unknown"}`,
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
await handleMessageReceived(payload);
|
|
421
|
+
break;
|
|
422
|
+
|
|
415
423
|
case "message.received":
|
|
416
424
|
await handleMessageReceived(payload);
|
|
417
425
|
break;
|
|
426
|
+
|
|
418
427
|
default:
|
|
419
428
|
console.log(`[AgentMail] Ignoring event type: ${payload.event_type}`);
|
|
420
429
|
}
|
package/src/http/workflows.ts
CHANGED
|
@@ -341,24 +341,11 @@ export async function handleWorkflows(
|
|
|
341
341
|
}
|
|
342
342
|
const rawBody = Buffer.concat(chunks).toString();
|
|
343
343
|
|
|
344
|
-
// Validate JSON before processing (but pass raw string for HMAC)
|
|
345
|
-
try {
|
|
346
|
-
if (rawBody) JSON.parse(rawBody);
|
|
347
|
-
} catch {
|
|
348
|
-
jsonError(res, "Invalid JSON body", 400);
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
const signature =
|
|
353
|
-
(req.headers["x-hub-signature-256"] as string | undefined) ??
|
|
354
|
-
(req.headers["x-signature"] as string | undefined);
|
|
355
|
-
|
|
356
344
|
try {
|
|
357
345
|
const result = await handleWebhookTrigger(
|
|
358
346
|
workflowId,
|
|
359
|
-
rawBody, // Raw body string —
|
|
360
|
-
signature
|
|
361
|
-
signature,
|
|
347
|
+
rawBody, // Raw body string — HMAC is verified against raw bytes; JSON parsing happens inside
|
|
348
|
+
req.headers, // Full header bag — signature header resolved per trigger config
|
|
362
349
|
getExecutorRegistry(),
|
|
363
350
|
);
|
|
364
351
|
json(res, result, 201);
|
|
@@ -1586,10 +1586,14 @@ describe("AgentMail Webhooks (with filters)", () => {
|
|
|
1586
1586
|
expect(body).toEqual({ received: true });
|
|
1587
1587
|
});
|
|
1588
1588
|
|
|
1589
|
-
test(
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1589
|
+
test.each([
|
|
1590
|
+
"message.received",
|
|
1591
|
+
"message.received.unauthenticated",
|
|
1592
|
+
])("accepts webhook for allowed event type '%s'", async (eventType) => {
|
|
1593
|
+
const { status } = await postWebhook({
|
|
1594
|
+
...makePayload({ inboxId: "support@y.xyz", from: "bob@b.com" }),
|
|
1595
|
+
event_type: eventType,
|
|
1596
|
+
});
|
|
1593
1597
|
expect(status).toBe(200);
|
|
1594
1598
|
});
|
|
1595
1599
|
|
|
@@ -2,7 +2,14 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import { unlink } from "node:fs/promises";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
closeDb,
|
|
7
|
+
createWorkflow,
|
|
8
|
+
getWorkflowRun,
|
|
9
|
+
initDb,
|
|
10
|
+
updateWorkflow,
|
|
11
|
+
upsertSwarmConfig,
|
|
12
|
+
} from "../be/db";
|
|
6
13
|
import type { Workflow } from "../types";
|
|
7
14
|
import { startWorkflowExecution } from "../workflows/engine";
|
|
8
15
|
import { BaseExecutor, type ExecutorResult } from "../workflows/executors/base";
|
|
@@ -118,7 +125,12 @@ describe("handleWebhookTrigger", () => {
|
|
|
118
125
|
hmac.update(body);
|
|
119
126
|
const sig = `sha256=${hmac.digest("hex")}`;
|
|
120
127
|
|
|
121
|
-
const result = await handleWebhookTrigger(
|
|
128
|
+
const result = await handleWebhookTrigger(
|
|
129
|
+
workflow.id,
|
|
130
|
+
body,
|
|
131
|
+
{ "x-hub-signature-256": sig },
|
|
132
|
+
registry,
|
|
133
|
+
);
|
|
122
134
|
|
|
123
135
|
expect(result.runId).toBeDefined();
|
|
124
136
|
expect(typeof result.runId).toBe("string");
|
|
@@ -138,8 +150,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
138
150
|
await handleWebhookTrigger(
|
|
139
151
|
workflow.id,
|
|
140
152
|
'{"test":true}',
|
|
141
|
-
"sha256=invalid",
|
|
142
|
-
"sha256=invalid",
|
|
153
|
+
{ "x-hub-signature-256": "sha256=invalid" },
|
|
143
154
|
registry,
|
|
144
155
|
);
|
|
145
156
|
expect(true).toBe(false); // Should not reach here
|
|
@@ -155,7 +166,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
155
166
|
});
|
|
156
167
|
|
|
157
168
|
try {
|
|
158
|
-
await handleWebhookTrigger(workflow.id, '{"test":true}',
|
|
169
|
+
await handleWebhookTrigger(workflow.id, '{"test":true}', {}, registry);
|
|
159
170
|
expect(true).toBe(false);
|
|
160
171
|
} catch (err) {
|
|
161
172
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -168,13 +179,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
168
179
|
triggers: [{ type: "webhook" }],
|
|
169
180
|
});
|
|
170
181
|
|
|
171
|
-
const result = await handleWebhookTrigger(
|
|
172
|
-
workflow.id,
|
|
173
|
-
'{"data":"hello"}',
|
|
174
|
-
undefined,
|
|
175
|
-
undefined,
|
|
176
|
-
registry,
|
|
177
|
-
);
|
|
182
|
+
const result = await handleWebhookTrigger(workflow.id, '{"data":"hello"}', {}, registry);
|
|
178
183
|
|
|
179
184
|
expect(result.runId).toBeDefined();
|
|
180
185
|
const run = getWorkflowRun(result.runId);
|
|
@@ -183,13 +188,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
183
188
|
|
|
184
189
|
test("workflow not found returns 404", async () => {
|
|
185
190
|
try {
|
|
186
|
-
await handleWebhookTrigger(
|
|
187
|
-
"00000000-0000-0000-0000-000000000000",
|
|
188
|
-
"{}",
|
|
189
|
-
undefined,
|
|
190
|
-
undefined,
|
|
191
|
-
registry,
|
|
192
|
-
);
|
|
191
|
+
await handleWebhookTrigger("00000000-0000-0000-0000-000000000000", "{}", {}, registry);
|
|
193
192
|
expect(true).toBe(false);
|
|
194
193
|
} catch (err) {
|
|
195
194
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -205,7 +204,7 @@ describe("handleWebhookTrigger", () => {
|
|
|
205
204
|
updateWorkflow(workflow.id, { enabled: false });
|
|
206
205
|
|
|
207
206
|
try {
|
|
208
|
-
await handleWebhookTrigger(workflow.id, "{}",
|
|
207
|
+
await handleWebhookTrigger(workflow.id, "{}", {}, registry);
|
|
209
208
|
expect(true).toBe(false);
|
|
210
209
|
} catch (err) {
|
|
211
210
|
expect(err).toBeInstanceOf(WebhookError);
|
|
@@ -214,6 +213,248 @@ describe("handleWebhookTrigger", () => {
|
|
|
214
213
|
});
|
|
215
214
|
});
|
|
216
215
|
|
|
216
|
+
// ─── Custom HMAC header + secret refs ───────────────────────
|
|
217
|
+
|
|
218
|
+
describe("handleWebhookTrigger — custom hmacHeader", () => {
|
|
219
|
+
function signRaw(secret: string, body: string): string {
|
|
220
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
test("custom hmacHeader (X-Webhook-Signature) is picked up and verified", async () => {
|
|
224
|
+
const secret = "kapso-secret";
|
|
225
|
+
const workflow = makeWorkflow({
|
|
226
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const body = '{"event":"message"}';
|
|
230
|
+
// Kapso-style: raw hex, no `sha256=` prefix.
|
|
231
|
+
const result = await handleWebhookTrigger(
|
|
232
|
+
workflow.id,
|
|
233
|
+
body,
|
|
234
|
+
{ "x-webhook-signature": signRaw(secret, body) },
|
|
235
|
+
registry,
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
expect(result.runId).toBeDefined();
|
|
239
|
+
expect(getWorkflowRun(result.runId)).not.toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("custom hmacHeader lookup is case-insensitive", async () => {
|
|
243
|
+
const secret = "kapso-secret-ci";
|
|
244
|
+
const workflow = makeWorkflow({
|
|
245
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
const body = '{"event":"ci"}';
|
|
249
|
+
const result = await handleWebhookTrigger(
|
|
250
|
+
workflow.id,
|
|
251
|
+
body,
|
|
252
|
+
{ "X-Webhook-Signature": signRaw(secret, body) },
|
|
253
|
+
registry,
|
|
254
|
+
);
|
|
255
|
+
|
|
256
|
+
expect(result.runId).toBeDefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("signature on a non-configured header is rejected as missing", async () => {
|
|
260
|
+
const secret = "kapso-secret-2";
|
|
261
|
+
const workflow = makeWorkflow({
|
|
262
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const body = '{"event":"x"}';
|
|
266
|
+
// Use a header that is neither the configured one nor a known fallback.
|
|
267
|
+
try {
|
|
268
|
+
await handleWebhookTrigger(
|
|
269
|
+
workflow.id,
|
|
270
|
+
body,
|
|
271
|
+
{ "x-some-other-header": signRaw(secret, body) },
|
|
272
|
+
registry,
|
|
273
|
+
);
|
|
274
|
+
expect(true).toBe(false);
|
|
275
|
+
} catch (err) {
|
|
276
|
+
expect(err).toBeInstanceOf(WebhookError);
|
|
277
|
+
expect((err as WebhookError).statusCode).toBe(401);
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("fallback header (x-signature) still works without explicit hmacHeader", async () => {
|
|
282
|
+
const secret = "fallback-secret";
|
|
283
|
+
const workflow = makeWorkflow({
|
|
284
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const body = '{"event":"fallback"}';
|
|
288
|
+
const result = await handleWebhookTrigger(
|
|
289
|
+
workflow.id,
|
|
290
|
+
body,
|
|
291
|
+
{ "x-signature": signRaw(secret, body) },
|
|
292
|
+
registry,
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
expect(result.runId).toBeDefined();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("default X-Hub-Signature-256 path still works (no regression)", async () => {
|
|
299
|
+
const secret = "default-header-secret";
|
|
300
|
+
const workflow = makeWorkflow({
|
|
301
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
const body = '{"event":"default"}';
|
|
305
|
+
const sig = `sha256=${signRaw(secret, body)}`;
|
|
306
|
+
const result = await handleWebhookTrigger(
|
|
307
|
+
workflow.id,
|
|
308
|
+
body,
|
|
309
|
+
{ "x-hub-signature-256": sig },
|
|
310
|
+
registry,
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
expect(result.runId).toBeDefined();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("handleWebhookTrigger — hmacSecret references", () => {
|
|
318
|
+
function signRaw(secret: string, body: string): string {
|
|
319
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
test("hmacSecret as secret.NAME ref resolves and verifies", async () => {
|
|
323
|
+
const SECRET_VALUE = "resolved-kapso-hmac-value";
|
|
324
|
+
upsertSwarmConfig({
|
|
325
|
+
scope: "global",
|
|
326
|
+
key: "TEST_KAPSO_WEBHOOK_HMAC_SECRET",
|
|
327
|
+
value: SECRET_VALUE,
|
|
328
|
+
isSecret: true,
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
const workflow = makeWorkflow({
|
|
332
|
+
triggers: [
|
|
333
|
+
{
|
|
334
|
+
type: "webhook",
|
|
335
|
+
hmacSecret: "secret.TEST_KAPSO_WEBHOOK_HMAC_SECRET",
|
|
336
|
+
hmacHeader: "X-Webhook-Signature",
|
|
337
|
+
},
|
|
338
|
+
],
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const body = '{"event":"secret-ref"}';
|
|
342
|
+
const result = await handleWebhookTrigger(
|
|
343
|
+
workflow.id,
|
|
344
|
+
body,
|
|
345
|
+
{ "x-webhook-signature": signRaw(SECRET_VALUE, body) },
|
|
346
|
+
registry,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
expect(result.runId).toBeDefined();
|
|
350
|
+
expect(getWorkflowRun(result.runId)).not.toBeNull();
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("unresolvable secret.NAME ref fails cleanly with a WebhookError", async () => {
|
|
354
|
+
const workflow = makeWorkflow({
|
|
355
|
+
triggers: [{ type: "webhook", hmacSecret: "secret.NONEXISTENT_HMAC_SECRET_12345" }],
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const body = '{"event":"missing-secret"}';
|
|
359
|
+
try {
|
|
360
|
+
await handleWebhookTrigger(
|
|
361
|
+
workflow.id,
|
|
362
|
+
body,
|
|
363
|
+
{ "x-hub-signature-256": "deadbeef" },
|
|
364
|
+
registry,
|
|
365
|
+
);
|
|
366
|
+
expect(true).toBe(false);
|
|
367
|
+
} catch (err) {
|
|
368
|
+
expect(err).toBeInstanceOf(WebhookError);
|
|
369
|
+
expect((err as WebhookError).statusCode).toBe(500);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
test("a literal hmacSecret is not treated as a reference", async () => {
|
|
374
|
+
const secret = "plain.literal-not-a-ref";
|
|
375
|
+
const workflow = makeWorkflow({
|
|
376
|
+
triggers: [{ type: "webhook", hmacSecret: secret }],
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
const body = '{"event":"literal"}';
|
|
380
|
+
const result = await handleWebhookTrigger(
|
|
381
|
+
workflow.id,
|
|
382
|
+
body,
|
|
383
|
+
{ "x-hub-signature-256": signRaw(secret, body) },
|
|
384
|
+
registry,
|
|
385
|
+
);
|
|
386
|
+
|
|
387
|
+
expect(result.runId).toBeDefined();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// ─── Trigger payload JSON parsing ───────────────────────────
|
|
392
|
+
|
|
393
|
+
describe("handleWebhookTrigger — triggerData JSON parsing", () => {
|
|
394
|
+
function signRaw(secret: string, body: string): string {
|
|
395
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
test("JSON body is parsed and run.triggerData is a deep-equal object", async () => {
|
|
399
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
400
|
+
const payload = {
|
|
401
|
+
message: { from: "+34000111222", text: "hi" },
|
|
402
|
+
conversation: { id: "conv-abc-123" },
|
|
403
|
+
};
|
|
404
|
+
const body = JSON.stringify(payload);
|
|
405
|
+
|
|
406
|
+
const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
|
|
407
|
+
|
|
408
|
+
const run = getWorkflowRun(result.runId);
|
|
409
|
+
expect(run).not.toBeNull();
|
|
410
|
+
expect(run!.triggerData).toEqual(payload);
|
|
411
|
+
// Deep paths must be reachable (this is what `{{trigger.message.from}}` needs).
|
|
412
|
+
expect((run!.triggerData as { message: { from: string } }).message.from).toBe("+34000111222");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("signed JSON body: HMAC verified against raw bytes, triggerData parsed to object", async () => {
|
|
416
|
+
const secret = "kapso-deep-secret";
|
|
417
|
+
const workflow = makeWorkflow({
|
|
418
|
+
triggers: [{ type: "webhook", hmacSecret: secret, hmacHeader: "X-Webhook-Signature" }],
|
|
419
|
+
});
|
|
420
|
+
// Use whitespace + unsorted keys so any re-serialization would change the bytes.
|
|
421
|
+
const body = '{ "message": {"from":"+1","text":"hi"}, "id":"x" }';
|
|
422
|
+
const sig = signRaw(secret, body);
|
|
423
|
+
|
|
424
|
+
const result = await handleWebhookTrigger(
|
|
425
|
+
workflow.id,
|
|
426
|
+
body,
|
|
427
|
+
{ "x-webhook-signature": sig },
|
|
428
|
+
registry,
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
const run = getWorkflowRun(result.runId);
|
|
432
|
+
expect(run).not.toBeNull();
|
|
433
|
+
expect(run!.triggerData).toEqual({ message: { from: "+1", text: "hi" }, id: "x" });
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("non-JSON body falls back to the raw string and does not throw", async () => {
|
|
437
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
438
|
+
const body = "this is not json at all";
|
|
439
|
+
|
|
440
|
+
const result = await handleWebhookTrigger(workflow.id, body, {}, registry);
|
|
441
|
+
|
|
442
|
+
const run = getWorkflowRun(result.runId);
|
|
443
|
+
expect(run).not.toBeNull();
|
|
444
|
+
expect(run!.triggerData).toBe(body);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("empty body produces a run without throwing", async () => {
|
|
448
|
+
const workflow = makeWorkflow({ triggers: [{ type: "webhook" }] });
|
|
449
|
+
|
|
450
|
+
const result = await handleWebhookTrigger(workflow.id, "", {}, registry);
|
|
451
|
+
|
|
452
|
+
expect(result.runId).toBeDefined();
|
|
453
|
+
const run = getWorkflowRun(result.runId);
|
|
454
|
+
expect(run).not.toBeNull();
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
217
458
|
// ─── Manual Trigger ─────────────────────────────────────────
|
|
218
459
|
|
|
219
460
|
describe("manual trigger (startWorkflowExecution)", () => {
|
package/src/workflows/input.ts
CHANGED
|
@@ -13,13 +13,18 @@ export function resolveInputs(input: Record<string, string>): Record<string, str
|
|
|
13
13
|
const resolved: Record<string, string> = {};
|
|
14
14
|
|
|
15
15
|
for (const [key, value] of Object.entries(input)) {
|
|
16
|
-
resolved[key] =
|
|
16
|
+
resolved[key] = resolveInputValue(value);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
return resolved;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Resolve a single input value, supporting `${ENV_VAR}` and `secret.NAME`
|
|
24
|
+
* references. A plain string is returned unchanged. Throws if a referenced
|
|
25
|
+
* env var or swarm secret cannot be found.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveInputValue(value: string): string {
|
|
23
28
|
// Env var reference: ${MY_VAR}
|
|
24
29
|
const envMatch = /^\$\{(.+)\}$/.exec(value);
|
|
25
30
|
if (envMatch?.[1]) {
|
|
@@ -3,19 +3,75 @@ import { getWorkflow, getWorkflowsByScheduleId } from "../be/db";
|
|
|
3
3
|
import type { ScheduledTask, TriggerConfig } from "../types";
|
|
4
4
|
import { startWorkflowExecution } from "./engine";
|
|
5
5
|
import type { ExecutorRegistry } from "./executors/registry";
|
|
6
|
+
import { resolveInputValue } from "./input";
|
|
7
|
+
|
|
8
|
+
/** Header name used to look up an HMAC signature when the trigger configures none. */
|
|
9
|
+
const DEFAULT_HMAC_HEADER = "X-Hub-Signature-256";
|
|
10
|
+
|
|
11
|
+
/** Fallback header names checked (case-insensitive) after the trigger's configured header. */
|
|
12
|
+
const FALLBACK_HMAC_HEADERS = ["x-hub-signature-256", "x-signature", "x-webhook-signature"];
|
|
13
|
+
|
|
14
|
+
/** A bag of HTTP headers — values may be a string, a string array, or absent. */
|
|
15
|
+
export type HeaderBag = Record<string, string | string[] | undefined>;
|
|
16
|
+
|
|
17
|
+
/** Case-insensitive header lookup; returns the first value when an array is given. */
|
|
18
|
+
function getHeader(headers: HeaderBag, name: string): string | undefined {
|
|
19
|
+
const target = name.toLowerCase();
|
|
20
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
21
|
+
if (key.toLowerCase() === target) {
|
|
22
|
+
return Array.isArray(value) ? value[0] : value;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the HMAC signature from request headers. Checks the trigger's
|
|
30
|
+
* configured `hmacHeader` first, then well-known fallback header names.
|
|
31
|
+
*/
|
|
32
|
+
function resolveSignature(headers: HeaderBag, hmacHeader: string): string | undefined {
|
|
33
|
+
for (const name of [hmacHeader, ...FALLBACK_HMAC_HEADERS]) {
|
|
34
|
+
const value = getHeader(headers, name);
|
|
35
|
+
if (value) return value;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the configured `hmacSecret`. Supports `secret.NAME` swarm-secret refs
|
|
42
|
+
* and `${ENV_VAR}` env refs (reusing the workflow input resolver); a plain
|
|
43
|
+
* string is treated as a literal. Resolved per request, never at create time.
|
|
44
|
+
*/
|
|
45
|
+
function resolveHmacSecret(raw: string): string {
|
|
46
|
+
if (/^secret\..+$/.test(raw) || /^\$\{.+\}$/.test(raw)) {
|
|
47
|
+
try {
|
|
48
|
+
return resolveInputValue(raw);
|
|
49
|
+
} catch (err) {
|
|
50
|
+
throw new WebhookError(
|
|
51
|
+
`Failed to resolve webhook HMAC secret: ${err instanceof Error ? err.message : String(err)}`,
|
|
52
|
+
500,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return raw;
|
|
57
|
+
}
|
|
6
58
|
|
|
7
59
|
/**
|
|
8
60
|
* Handle an incoming webhook trigger for a workflow.
|
|
9
61
|
*
|
|
10
62
|
* 1. Loads the workflow and finds a webhook trigger in `triggers[]`
|
|
11
|
-
* 2. If `hmacSecret` is set,
|
|
12
|
-
*
|
|
63
|
+
* 2. If `hmacSecret` is set, resolves the signature header + secret and
|
|
64
|
+
* verifies the HMAC-SHA256 signature against the raw body bytes
|
|
65
|
+
* 3. Parses the raw body as JSON (falling back to the raw string when the
|
|
66
|
+
* body is non-JSON) so downstream `{{trigger.deep.path}}` interpolation
|
|
67
|
+
* can traverse the object — matches the shape produced by the
|
|
68
|
+
* `trigger-workflow` MCP tool.
|
|
69
|
+
* 4. Starts the workflow execution with the parsed payload
|
|
13
70
|
*/
|
|
14
71
|
export async function handleWebhookTrigger(
|
|
15
72
|
workflowId: string,
|
|
16
73
|
payload: unknown,
|
|
17
|
-
|
|
18
|
-
signatureHeader: string | undefined,
|
|
74
|
+
headers: HeaderBag,
|
|
19
75
|
registry: ExecutorRegistry,
|
|
20
76
|
): Promise<{ runId: string }> {
|
|
21
77
|
const workflow = getWorkflow(workflowId);
|
|
@@ -31,16 +87,20 @@ export async function handleWebhookTrigger(
|
|
|
31
87
|
const webhookTrigger = workflow.triggers.find((t: TriggerConfig) => t.type === "webhook");
|
|
32
88
|
|
|
33
89
|
// If the workflow has a webhook trigger with an hmacSecret, verify the signature
|
|
90
|
+
// against the RAW body bytes — re-serializing would change whitespace / key order
|
|
91
|
+
// and break the HMAC.
|
|
34
92
|
if (webhookTrigger && webhookTrigger.type === "webhook" && webhookTrigger.hmacSecret) {
|
|
35
|
-
|
|
93
|
+
const hmacHeader = webhookTrigger.hmacHeader || DEFAULT_HMAC_HEADER;
|
|
94
|
+
const signature = resolveSignature(headers, hmacHeader);
|
|
95
|
+
if (!signature) {
|
|
36
96
|
throw new WebhookError("Missing signature", 401);
|
|
37
97
|
}
|
|
38
98
|
|
|
39
|
-
const
|
|
99
|
+
const secret = resolveHmacSecret(webhookTrigger.hmacSecret);
|
|
40
100
|
const isValid = verifyHmacSignature(
|
|
41
|
-
|
|
101
|
+
secret,
|
|
42
102
|
typeof payload === "string" ? payload : JSON.stringify(payload),
|
|
43
|
-
|
|
103
|
+
signature,
|
|
44
104
|
);
|
|
45
105
|
|
|
46
106
|
if (!isValid) {
|
|
@@ -48,10 +108,30 @@ export async function handleWebhookTrigger(
|
|
|
48
108
|
}
|
|
49
109
|
}
|
|
50
110
|
|
|
51
|
-
|
|
111
|
+
// Parse the raw body so downstream nodes can interpolate deep paths
|
|
112
|
+
// (e.g. `{{trigger.message.from}}`). A non-JSON body falls back to the raw
|
|
113
|
+
// string so non-JSON webhooks don't break.
|
|
114
|
+
const triggerData = parseTriggerPayload(payload);
|
|
115
|
+
|
|
116
|
+
const runId = await startWorkflowExecution(workflow, triggerData, registry);
|
|
52
117
|
return { runId };
|
|
53
118
|
}
|
|
54
119
|
|
|
120
|
+
/**
|
|
121
|
+
* If `payload` is a JSON string, parse and return the resulting value;
|
|
122
|
+
* otherwise return it as-is. Empty / non-JSON strings fall back to the raw
|
|
123
|
+
* value so non-JSON webhooks (text/plain, form-encoded, etc.) still produce
|
|
124
|
+
* a usable workflow run.
|
|
125
|
+
*/
|
|
126
|
+
function parseTriggerPayload(payload: unknown): unknown {
|
|
127
|
+
if (typeof payload !== "string" || payload.length === 0) return payload;
|
|
128
|
+
try {
|
|
129
|
+
return JSON.parse(payload);
|
|
130
|
+
} catch {
|
|
131
|
+
return payload;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
55
135
|
/**
|
|
56
136
|
* Handle a schedule trigger: find workflows linked to this schedule and execute them.
|
|
57
137
|
* Returns an array of workflow run IDs. Empty array means no workflows matched
|