@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.
Files changed (40) hide show
  1. package/README.md +3 -0
  2. package/openapi.json +486 -29
  3. package/package.json +3 -3
  4. package/plugin/commands/user-management.md +85 -46
  5. package/plugin/pi-skills/user-management/SKILL.md +85 -46
  6. package/src/agentmail/handlers.ts +25 -3
  7. package/src/agentmail/types.ts +1 -0
  8. package/src/be/db.ts +33 -109
  9. package/src/be/migrations/067_users_first_class.sql +185 -0
  10. package/src/be/migrations/068_profile_changed_event_type.sql +56 -0
  11. package/src/be/unmapped-identities.ts +98 -0
  12. package/src/be/users.ts +531 -0
  13. package/src/github/handlers.ts +67 -7
  14. package/src/gitlab/handlers.ts +73 -5
  15. package/src/http/operator-actor.ts +59 -0
  16. package/src/http/users.ts +611 -21
  17. package/src/http/webhooks.ts +9 -0
  18. package/src/http/workflows.ts +2 -15
  19. package/src/linear/oauth.ts +61 -1
  20. package/src/linear/sync.ts +134 -21
  21. package/src/slack/actions.ts +8 -2
  22. package/src/slack/assistant.ts +12 -9
  23. package/src/slack/enrich.ts +162 -0
  24. package/src/slack/handlers.ts +11 -19
  25. package/src/tests/agentmail-handlers.test.ts +166 -0
  26. package/src/tests/github-handlers.test.ts +290 -0
  27. package/src/tests/gitlab-handlers.test.ts +293 -1
  28. package/src/tests/http-api-integration.test.ts +8 -4
  29. package/src/tests/http-users.test.ts +605 -0
  30. package/src/tests/linear-sync-identity.test.ts +427 -0
  31. package/src/tests/mcp-tools-user.test.ts +292 -0
  32. package/src/tests/slack-identity-resolution.test.ts +349 -0
  33. package/src/tests/user-identity.test.ts +351 -81
  34. package/src/tests/workflow-triggers-v2.test.ts +261 -20
  35. package/src/tools/manage-user.ts +119 -24
  36. package/src/tools/resolve-user.ts +43 -29
  37. package/src/types.ts +26 -4
  38. package/src/utils/secret-scrubber.ts +5 -0
  39. package/src/workflows/input.ts +7 -2
  40. 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 { closeDb, createWorkflow, getWorkflowRun, initDb, updateWorkflow } from "../be/db";
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(workflow.id, body, sig, sig, registry);
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}', undefined, undefined, registry);
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, "{}", undefined, undefined, registry);
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)", () => {
@@ -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: "Create, update, delete, or list user profiles in the user registry. Lead-only.",
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: z.object({
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 { resolveUser } from "@/be/db";
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 any platform-specific identifier (Slack ID, Linear ID, GitHub username, email, or name). Returns the full user profile or null.",
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: z.object({
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 ({ slackUserId, linearUserId, githubUsername, gitlabUsername, email, name }) => {
24
- if (!slackUserId && !linearUserId && !githubUsername && !gitlabUsername && !email && !name) {
25
- return {
26
- content: [
27
- {
28
- type: "text" as const,
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 {