@desplega.ai/agent-swarm 1.80.2 → 1.81.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/README.md +3 -0
- package/openapi.json +486 -29
- package/package.json +3 -3
- package/plugin/commands/user-management.md +85 -46
- package/plugin/pi-skills/user-management/SKILL.md +85 -46
- package/src/agentmail/handlers.ts +25 -3
- package/src/agentmail/types.ts +1 -0
- package/src/be/db.ts +33 -109
- package/src/be/migrations/067_users_first_class.sql +185 -0
- package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
- package/src/be/unmapped-identities.ts +98 -0
- package/src/be/users.ts +531 -0
- package/src/github/handlers.ts +67 -7
- package/src/gitlab/handlers.ts +73 -5
- package/src/http/operator-actor.ts +59 -0
- package/src/http/users.ts +611 -21
- package/src/http/webhooks.ts +9 -0
- package/src/http/workflows.ts +2 -15
- package/src/linear/oauth.ts +61 -1
- package/src/linear/sync.ts +134 -21
- package/src/slack/actions.ts +8 -2
- package/src/slack/assistant.ts +12 -9
- package/src/slack/enrich.ts +162 -0
- package/src/slack/handlers.ts +11 -19
- package/src/tests/agentmail-handlers.test.ts +166 -0
- package/src/tests/github-handlers.test.ts +290 -0
- package/src/tests/gitlab-handlers.test.ts +293 -1
- package/src/tests/http-api-integration.test.ts +8 -4
- package/src/tests/http-users.test.ts +605 -0
- package/src/tests/linear-sync-identity.test.ts +427 -0
- package/src/tests/mcp-tools-user.test.ts +292 -0
- package/src/tests/slack-identity-resolution.test.ts +349 -0
- package/src/tests/user-identity.test.ts +351 -81
- package/src/tests/workflow-triggers-v2.test.ts +261 -20
- package/src/tools/manage-user.ts +119 -24
- package/src/tools/resolve-user.ts +43 -29
- package/src/types.ts +26 -4
- package/src/utils/secret-scrubber.ts +5 -0
- package/src/workflows/input.ts +7 -2
- package/src/workflows/triggers.ts +89 -9
|
@@ -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/tools/manage-user.ts
CHANGED
|
@@ -8,30 +8,81 @@ import {
|
|
|
8
8
|
getUserById,
|
|
9
9
|
updateUser,
|
|
10
10
|
} from "@/be/db";
|
|
11
|
+
import {
|
|
12
|
+
getUserIdentities,
|
|
13
|
+
type IdentityActor,
|
|
14
|
+
linkIdentity,
|
|
15
|
+
recordIdentityEvent,
|
|
16
|
+
unlinkIdentity,
|
|
17
|
+
} from "@/be/users";
|
|
11
18
|
import { createToolRegistrar } from "@/tools/utils";
|
|
12
19
|
|
|
20
|
+
/**
|
|
21
|
+
* `manage-user` — Q18 break-and-migrate shape:
|
|
22
|
+
* - Identities passed as `identities: [{kind, externalId}, ...]` (was previously
|
|
23
|
+
* four denormalised columns: slackUserId / linearUserId / githubUsername /
|
|
24
|
+
* gitlabUsername — all dropped).
|
|
25
|
+
* - `dailyBudgetUsd`, `status`, `metadata` are new (migration 064).
|
|
26
|
+
* - Update path computes a diff against the current `getUserIdentities(userId)`
|
|
27
|
+
* so the call is declarative: pass the full desired set, helper emits
|
|
28
|
+
* `identity_added` / `identity_removed` events for each delta.
|
|
29
|
+
* - Email-alias edits emit `email_added` / `email_removed` events (Q19).
|
|
30
|
+
*/
|
|
31
|
+
const IdentityEntry = z.object({
|
|
32
|
+
kind: z.string().describe("Identity kind (e.g. 'slack', 'linear', 'github', 'gitlab', 'jira')."),
|
|
33
|
+
externalId: z.string().describe("Platform-specific identifier for the given kind."),
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const InputSchema = z.object({
|
|
37
|
+
action: z.enum(["create", "update", "delete", "list", "get"]).describe("Action to perform"),
|
|
38
|
+
userId: z.string().optional().describe("User ID (required for update/delete/get)"),
|
|
39
|
+
name: z.string().optional().describe("Display name (required for create)"),
|
|
40
|
+
email: z.string().optional().describe("Primary email address"),
|
|
41
|
+
role: z.string().optional().describe('Role (e.g., "founder", "engineer")'),
|
|
42
|
+
notes: z.string().optional().describe("Free-form notes"),
|
|
43
|
+
identities: z
|
|
44
|
+
.array(IdentityEntry)
|
|
45
|
+
.optional()
|
|
46
|
+
.describe(
|
|
47
|
+
"List of platform identities to link. On create: every entry is linked. On update: the list is treated as the desired set — entries not currently linked are added (identity_added), entries currently linked but missing are removed (identity_removed).",
|
|
48
|
+
),
|
|
49
|
+
emailAliases: z.array(z.string()).optional().describe("Additional email addresses"),
|
|
50
|
+
preferredChannel: z.string().optional().describe("Preferred contact channel"),
|
|
51
|
+
timezone: z.string().optional().describe("Timezone (e.g., America/New_York)"),
|
|
52
|
+
dailyBudgetUsd: z
|
|
53
|
+
.number()
|
|
54
|
+
.nullable()
|
|
55
|
+
.optional()
|
|
56
|
+
.describe("Daily budget in USD (null clears the cap)"),
|
|
57
|
+
status: z
|
|
58
|
+
.enum(["invited", "active", "suspended"])
|
|
59
|
+
.optional()
|
|
60
|
+
.describe("User status — invited / active / suspended"),
|
|
61
|
+
metadata: z
|
|
62
|
+
.record(z.string(), z.unknown())
|
|
63
|
+
.nullable()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe("Free-form JSON metadata (null clears the field)"),
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
function diffAliases(prev: string[], next: string[]): { added: string[]; removed: string[] } {
|
|
69
|
+
const prevSet = new Set(prev.map((a) => a.toLowerCase()));
|
|
70
|
+
const nextSet = new Set(next.map((a) => a.toLowerCase()));
|
|
71
|
+
return {
|
|
72
|
+
added: next.filter((a) => !prevSet.has(a.toLowerCase())),
|
|
73
|
+
removed: prev.filter((a) => !nextSet.has(a.toLowerCase())),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
13
77
|
export const registerManageUserTool = (server: McpServer) => {
|
|
14
78
|
createToolRegistrar(server)(
|
|
15
79
|
"manage-user",
|
|
16
80
|
{
|
|
17
81
|
title: "Manage user profiles",
|
|
18
|
-
description:
|
|
82
|
+
description:
|
|
83
|
+
"Create, update, delete, or list user profiles in the user registry. Identities are managed via an `identities: [{kind, externalId}]` array (declarative — update computes diff). Lead-only.",
|
|
19
84
|
annotations: { readOnlyHint: false },
|
|
20
|
-
inputSchema:
|
|
21
|
-
action: z.enum(["create", "update", "delete", "list", "get"]).describe("Action to perform"),
|
|
22
|
-
userId: z.string().optional().describe("User ID (required for update/delete/get)"),
|
|
23
|
-
name: z.string().optional().describe("Display name (required for create)"),
|
|
24
|
-
email: z.string().optional().describe("Primary email address"),
|
|
25
|
-
role: z.string().optional().describe('Role (e.g., "founder", "engineer")'),
|
|
26
|
-
notes: z.string().optional().describe("Free-form notes"),
|
|
27
|
-
slackUserId: z.string().optional().describe("Slack user ID"),
|
|
28
|
-
linearUserId: z.string().optional().describe("Linear user UUID"),
|
|
29
|
-
githubUsername: z.string().optional().describe("GitHub username"),
|
|
30
|
-
gitlabUsername: z.string().optional().describe("GitLab username"),
|
|
31
|
-
emailAliases: z.array(z.string()).optional().describe("Additional email addresses"),
|
|
32
|
-
preferredChannel: z.string().optional().describe("Preferred contact channel"),
|
|
33
|
-
timezone: z.string().optional().describe("Timezone (e.g., America/New_York)"),
|
|
34
|
-
}),
|
|
85
|
+
inputSchema: InputSchema,
|
|
35
86
|
},
|
|
36
87
|
async (input, requestInfo) => {
|
|
37
88
|
const callerAgent = requestInfo.agentId ? getAgentById(requestInfo.agentId) : null;
|
|
@@ -43,6 +94,12 @@ export const registerManageUserTool = (server: McpServer) => {
|
|
|
43
94
|
};
|
|
44
95
|
}
|
|
45
96
|
|
|
97
|
+
// Build the operator-actor used for every event emitted in this call.
|
|
98
|
+
const operatorActor: IdentityActor = {
|
|
99
|
+
kind: "operator",
|
|
100
|
+
id: callerAgent.id,
|
|
101
|
+
};
|
|
102
|
+
|
|
46
103
|
switch (input.action) {
|
|
47
104
|
case "list": {
|
|
48
105
|
const users = getAllUsers();
|
|
@@ -80,14 +137,16 @@ export const registerManageUserTool = (server: McpServer) => {
|
|
|
80
137
|
email: input.email,
|
|
81
138
|
role: input.role,
|
|
82
139
|
notes: input.notes,
|
|
83
|
-
slackUserId: input.slackUserId,
|
|
84
|
-
linearUserId: input.linearUserId,
|
|
85
|
-
githubUsername: input.githubUsername,
|
|
86
|
-
gitlabUsername: input.gitlabUsername,
|
|
87
140
|
emailAliases: input.emailAliases,
|
|
88
141
|
preferredChannel: input.preferredChannel,
|
|
89
142
|
timezone: input.timezone,
|
|
143
|
+
dailyBudgetUsd: input.dailyBudgetUsd ?? undefined,
|
|
144
|
+
status: input.status,
|
|
145
|
+
metadata: input.metadata ?? undefined,
|
|
90
146
|
});
|
|
147
|
+
for (const ident of input.identities ?? []) {
|
|
148
|
+
linkIdentity(user.id, ident.kind, ident.externalId, operatorActor);
|
|
149
|
+
}
|
|
91
150
|
return {
|
|
92
151
|
content: [
|
|
93
152
|
{
|
|
@@ -111,24 +170,60 @@ export const registerManageUserTool = (server: McpServer) => {
|
|
|
111
170
|
};
|
|
112
171
|
}
|
|
113
172
|
try {
|
|
173
|
+
const before = getUserById(input.userId);
|
|
174
|
+
if (!before) {
|
|
175
|
+
return {
|
|
176
|
+
content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
114
180
|
const user = updateUser(input.userId, {
|
|
115
181
|
name: input.name,
|
|
116
182
|
email: input.email,
|
|
117
183
|
role: input.role,
|
|
118
184
|
notes: input.notes,
|
|
119
|
-
slackUserId: input.slackUserId,
|
|
120
|
-
linearUserId: input.linearUserId,
|
|
121
|
-
githubUsername: input.githubUsername,
|
|
122
|
-
gitlabUsername: input.gitlabUsername,
|
|
123
185
|
emailAliases: input.emailAliases,
|
|
124
186
|
preferredChannel: input.preferredChannel,
|
|
125
187
|
timezone: input.timezone,
|
|
188
|
+
dailyBudgetUsd: input.dailyBudgetUsd,
|
|
189
|
+
status: input.status,
|
|
190
|
+
metadata: input.metadata,
|
|
126
191
|
});
|
|
127
192
|
if (!user) {
|
|
128
193
|
return {
|
|
129
194
|
content: [{ type: "text" as const, text: `User ${input.userId} not found.` }],
|
|
130
195
|
};
|
|
131
196
|
}
|
|
197
|
+
|
|
198
|
+
// Identity diff — pass the desired set, helper emits the deltas.
|
|
199
|
+
if (input.identities !== undefined) {
|
|
200
|
+
const current = getUserIdentities(input.userId);
|
|
201
|
+
const currentSet = new Set(current.map((i) => `${i.kind}:${i.externalId}`));
|
|
202
|
+
const desiredSet = new Set(input.identities.map((i) => `${i.kind}:${i.externalId}`));
|
|
203
|
+
|
|
204
|
+
for (const ident of input.identities) {
|
|
205
|
+
if (!currentSet.has(`${ident.kind}:${ident.externalId}`)) {
|
|
206
|
+
linkIdentity(input.userId, ident.kind, ident.externalId, operatorActor);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
for (const ident of current) {
|
|
210
|
+
if (!desiredSet.has(`${ident.kind}:${ident.externalId}`)) {
|
|
211
|
+
unlinkIdentity(input.userId, ident.kind, ident.externalId, operatorActor);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Email alias diff — emit dedicated email_added / email_removed events (Q19).
|
|
217
|
+
if (input.emailAliases !== undefined) {
|
|
218
|
+
const { added, removed } = diffAliases(before.emailAliases, input.emailAliases);
|
|
219
|
+
for (const alias of added) {
|
|
220
|
+
recordIdentityEvent(input.userId, "email_added", operatorActor, null, { alias });
|
|
221
|
+
}
|
|
222
|
+
for (const alias of removed) {
|
|
223
|
+
recordIdentityEvent(input.userId, "email_removed", operatorActor, { alias }, null);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
132
227
|
return {
|
|
133
228
|
content: [
|
|
134
229
|
{
|
|
@@ -1,46 +1,60 @@
|
|
|
1
1
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import { findUserByEmail, findUserByExternalId } from "@/be/users";
|
|
4
4
|
import { createToolRegistrar } from "@/tools/utils";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* `resolve-user` — Q18 break-and-migrate shape:
|
|
8
|
+
* - `{kind, externalId}` for platform-identity lookups (replaces the old
|
|
9
|
+
* `slackUserId` / `linearUserId` / `githubUsername` / `gitlabUsername` fields).
|
|
10
|
+
* - `email` for primary-email or alias lookup.
|
|
11
|
+
*
|
|
12
|
+
* Validator requires either (kind + externalId) OR email. Old worker payloads
|
|
13
|
+
* carrying `slackUserId`, `name`, etc. fail Zod validation at runtime — that
|
|
14
|
+
* is the documented no-soak behaviour for this refactor.
|
|
15
|
+
*
|
|
16
|
+
* Exported for tests so the schema can be validated without spinning up an
|
|
17
|
+
* MCP transport (the SDK only runs Zod at the transport layer).
|
|
18
|
+
*/
|
|
19
|
+
export const resolveUserInputSchema = z
|
|
20
|
+
.object({
|
|
21
|
+
kind: z
|
|
22
|
+
.string()
|
|
23
|
+
.optional()
|
|
24
|
+
.describe(
|
|
25
|
+
"Identity kind — e.g. 'slack', 'linear', 'github', 'gitlab', 'jira', or a custom value. Must be paired with externalId.",
|
|
26
|
+
),
|
|
27
|
+
externalId: z
|
|
28
|
+
.string()
|
|
29
|
+
.optional()
|
|
30
|
+
.describe(
|
|
31
|
+
"Platform-specific identifier for the given kind (e.g. Slack user ID 'U08NR6QD6CS', Linear user UUID, GitHub login).",
|
|
32
|
+
),
|
|
33
|
+
email: z.string().email().optional().describe("Email address (primary or alias)."),
|
|
34
|
+
})
|
|
35
|
+
.strict()
|
|
36
|
+
.refine((v) => (v.kind !== undefined && v.externalId !== undefined) || v.email !== undefined, {
|
|
37
|
+
message: "Provide either (kind + externalId) or email",
|
|
38
|
+
});
|
|
39
|
+
|
|
6
40
|
export const registerResolveUserTool = (server: McpServer) => {
|
|
7
41
|
createToolRegistrar(server)(
|
|
8
42
|
"resolve-user",
|
|
9
43
|
{
|
|
10
44
|
title: "Resolve user identity",
|
|
11
45
|
description:
|
|
12
|
-
"Look up a canonical user profile by
|
|
46
|
+
"Look up a canonical user profile by an `(kind, externalId)` pair (e.g. {kind: 'slack', externalId: 'U_X'}) OR by email (primary or alias). Returns the user profile or 'No user found'.",
|
|
13
47
|
annotations: { readOnlyHint: true },
|
|
14
|
-
inputSchema:
|
|
15
|
-
slackUserId: z.string().optional().describe("Slack user ID (e.g., U08NR6QD6CS)"),
|
|
16
|
-
linearUserId: z.string().optional().describe("Linear user UUID"),
|
|
17
|
-
githubUsername: z.string().optional().describe("GitHub username"),
|
|
18
|
-
gitlabUsername: z.string().optional().describe("GitLab username"),
|
|
19
|
-
email: z.string().optional().describe("Email address"),
|
|
20
|
-
name: z.string().optional().describe("Name (fuzzy substring match, lowest priority)"),
|
|
21
|
-
}),
|
|
48
|
+
inputSchema: resolveUserInputSchema,
|
|
22
49
|
},
|
|
23
|
-
async ({
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
text: "At least one search parameter is required.",
|
|
30
|
-
},
|
|
31
|
-
],
|
|
32
|
-
};
|
|
50
|
+
async ({ kind, externalId, email }) => {
|
|
51
|
+
let user = null;
|
|
52
|
+
if (kind && externalId) {
|
|
53
|
+
user = findUserByExternalId(kind, externalId);
|
|
54
|
+
} else if (email) {
|
|
55
|
+
user = findUserByEmail(email);
|
|
33
56
|
}
|
|
34
57
|
|
|
35
|
-
const user = resolveUser({
|
|
36
|
-
slackUserId,
|
|
37
|
-
linearUserId,
|
|
38
|
-
githubUsername,
|
|
39
|
-
gitlabUsername,
|
|
40
|
-
email,
|
|
41
|
-
name,
|
|
42
|
-
});
|
|
43
|
-
|
|
44
58
|
if (!user) {
|
|
45
59
|
return {
|
|
46
60
|
content: [{ type: "text" as const, text: "No user found matching the given criteria." }],
|
package/src/types.ts
CHANGED
|
@@ -224,19 +224,41 @@ export const UserSchema = z.object({
|
|
|
224
224
|
email: z.string().optional(),
|
|
225
225
|
role: z.string().optional(),
|
|
226
226
|
notes: z.string().optional(),
|
|
227
|
-
slackUserId: z.string().optional(),
|
|
228
|
-
linearUserId: z.string().optional(),
|
|
229
|
-
githubUsername: z.string().optional(),
|
|
230
|
-
gitlabUsername: z.string().optional(),
|
|
231
227
|
emailAliases: z.array(z.string()).default([]),
|
|
232
228
|
preferredChannel: z.string().default("slack"),
|
|
233
229
|
timezone: z.string().optional(),
|
|
230
|
+
// Phase 064: free-form JSON for operator notes + integration hints.
|
|
231
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
232
|
+
// NULL = unlimited (Phase 064).
|
|
233
|
+
dailyBudgetUsd: z.number().nullable().optional(),
|
|
234
|
+
// Lifecycle (Phase 064). CHECK constraint enforces these three values.
|
|
235
|
+
status: z.enum(["invited", "active", "suspended"]).default("active"),
|
|
234
236
|
createdAt: z.iso.datetime(),
|
|
235
237
|
lastUpdatedAt: z.iso.datetime(),
|
|
236
238
|
});
|
|
237
239
|
|
|
238
240
|
export type User = z.infer<typeof UserSchema>;
|
|
239
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Identity event types — mirrored in lockstep with the CHECK constraint on
|
|
244
|
+
* `user_identity_events.eventType` in migration 064. Drift breaks helper
|
|
245
|
+
* INSERTs at runtime; update both sides together.
|
|
246
|
+
*/
|
|
247
|
+
export const IdentityEventTypeSchema = z.enum([
|
|
248
|
+
"auto_merge",
|
|
249
|
+
"manual_merge",
|
|
250
|
+
"identity_added",
|
|
251
|
+
"identity_removed",
|
|
252
|
+
"email_added",
|
|
253
|
+
"email_removed",
|
|
254
|
+
"token_minted",
|
|
255
|
+
"token_revoked",
|
|
256
|
+
"budget_changed",
|
|
257
|
+
"status_changed",
|
|
258
|
+
"profile_changed",
|
|
259
|
+
]);
|
|
260
|
+
export type IdentityEventType = z.infer<typeof IdentityEventTypeSchema>;
|
|
261
|
+
|
|
240
262
|
// ============================================================================
|
|
241
263
|
// Inbox Item State (per-user dismiss/snooze/done for action-items inbox)
|
|
242
264
|
// ============================================================================
|
|
@@ -124,6 +124,11 @@ const TOKEN_REGEXES: ReadonlyArray<{ name: string; re: RegExp }> = [
|
|
|
124
124
|
name: "signoz_ingestion_key",
|
|
125
125
|
re: /\bsignoz-ingestion-key=[A-Za-z0-9._~+/-]{20,}={0,2}\b/g,
|
|
126
126
|
},
|
|
127
|
+
// Agent-swarm MCP user tokens (`aswt_<base62-20+>`). Schema lands in
|
|
128
|
+
// migration 064; mint/revoke endpoints ship with the MCP-token plan.
|
|
129
|
+
// Rule lives here now so plaintexts never leak into logs once endpoints
|
|
130
|
+
// come online.
|
|
131
|
+
{ name: "mcp_token", re: /\baswt_[A-Za-z0-9]{20,}\b/g },
|
|
127
132
|
];
|
|
128
133
|
|
|
129
134
|
interface EnvValueEntry {
|