@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
package/src/http/users.ts
CHANGED
|
@@ -1,17 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator-facing HTTP surface for the People page + Unmapped triage.
|
|
3
|
+
*
|
|
4
|
+
* Every endpoint goes through the `route()` factory so OpenAPI picks it up
|
|
5
|
+
* automatically (`scripts/generate-openapi.ts` imports this file). All
|
|
6
|
+
* mutation paths read the operator's fingerprint via `getOperatorActor()` and
|
|
7
|
+
* pass it as the `IdentityActor` arg to the helpers in `src/be/users.ts` — so
|
|
8
|
+
* every identity mutation lands an event row tagged `op:<sha256-16>`.
|
|
9
|
+
*
|
|
10
|
+
* Endpoint set (Core Req #6, minus `POST/DELETE /users/:id/mcp-tokens` which
|
|
11
|
+
* are deferred to the MCP plan):
|
|
12
|
+
*
|
|
13
|
+
* GET /api/users
|
|
14
|
+
* POST /api/users
|
|
15
|
+
* GET /api/users/unmapped (must precede /:id)
|
|
16
|
+
* POST /api/users/unmapped/:kind/:externalId/resolve
|
|
17
|
+
* GET /api/users/:id
|
|
18
|
+
* PATCH /api/users/:id
|
|
19
|
+
* POST /api/users/:id/merge
|
|
20
|
+
* GET /api/users/:id/events
|
|
21
|
+
* POST /api/users/:id/identities
|
|
22
|
+
* DELETE /api/users/:id/identities/:kind/:externalId
|
|
23
|
+
*/
|
|
24
|
+
|
|
1
25
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
26
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createUser,
|
|
29
|
+
deleteKv,
|
|
30
|
+
deleteUser,
|
|
31
|
+
getAllUsers,
|
|
32
|
+
getUserById,
|
|
33
|
+
listKv,
|
|
34
|
+
updateUser,
|
|
35
|
+
} from "../be/db";
|
|
36
|
+
import {
|
|
37
|
+
getUserIdentities,
|
|
38
|
+
type IdentityEvent,
|
|
39
|
+
linkIdentity,
|
|
40
|
+
listUserEvents,
|
|
41
|
+
listUserTokens,
|
|
42
|
+
recordIdentityEvent,
|
|
43
|
+
unlinkIdentity,
|
|
44
|
+
} from "../be/users";
|
|
45
|
+
import { getOperatorActor } from "./operator-actor";
|
|
4
46
|
import { route } from "./route-def";
|
|
5
47
|
import { json, jsonError } from "./utils";
|
|
6
48
|
|
|
49
|
+
// ─── Response composition ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Compose the full People-page row for a user: base profile + identities +
|
|
53
|
+
* token summaries + the last N identity events. `recentEventLimit` defaults
|
|
54
|
+
* to 5 to keep the list-view response bounded.
|
|
55
|
+
*/
|
|
56
|
+
function composeUser(userId: string, recentEventLimit = 5) {
|
|
57
|
+
const user = getUserById(userId);
|
|
58
|
+
if (!user) return null;
|
|
59
|
+
return {
|
|
60
|
+
...user,
|
|
61
|
+
identities: getUserIdentities(userId),
|
|
62
|
+
tokens: listUserTokens(userId),
|
|
63
|
+
recentEvents: listUserEvents(userId, { limit: recentEventLimit }),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
7
67
|
// ─── Route Definitions ───────────────────────────────────────────────────────
|
|
8
68
|
|
|
9
69
|
const listUsers = route({
|
|
10
70
|
method: "get",
|
|
11
71
|
path: "/api/users",
|
|
12
72
|
pattern: ["api", "users"],
|
|
13
|
-
summary: "List all users",
|
|
73
|
+
summary: "List all users with identities, token summaries and recent events",
|
|
14
74
|
tags: ["Users"],
|
|
75
|
+
query: z.object({
|
|
76
|
+
recentEvents: z.coerce.number().int().min(0).max(50).optional(),
|
|
77
|
+
}),
|
|
15
78
|
responses: {
|
|
16
79
|
200: { description: "List of users" },
|
|
17
80
|
401: { description: "Unauthorized" },
|
|
@@ -23,20 +86,22 @@ const createUserRoute = route({
|
|
|
23
86
|
method: "post",
|
|
24
87
|
path: "/api/users",
|
|
25
88
|
pattern: ["api", "users"],
|
|
26
|
-
summary: "Create a new user",
|
|
89
|
+
summary: "Create a new user (optionally with initial identity links)",
|
|
27
90
|
tags: ["Users"],
|
|
28
91
|
body: z.object({
|
|
29
92
|
name: z.string().min(1),
|
|
30
93
|
email: z.string().optional(),
|
|
31
94
|
role: z.string().optional(),
|
|
32
95
|
notes: z.string().optional(),
|
|
33
|
-
slackUserId: z.string().optional(),
|
|
34
|
-
linearUserId: z.string().optional(),
|
|
35
|
-
githubUsername: z.string().optional(),
|
|
36
|
-
gitlabUsername: z.string().optional(),
|
|
37
96
|
emailAliases: z.array(z.string()).optional(),
|
|
38
97
|
preferredChannel: z.string().optional(),
|
|
39
98
|
timezone: z.string().optional(),
|
|
99
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
100
|
+
dailyBudgetUsd: z.number().nullable().optional(),
|
|
101
|
+
status: z.enum(["invited", "active", "suspended"]).optional(),
|
|
102
|
+
identities: z
|
|
103
|
+
.array(z.object({ kind: z.string().min(1), externalId: z.string().min(1) }))
|
|
104
|
+
.optional(),
|
|
40
105
|
}),
|
|
41
106
|
responses: {
|
|
42
107
|
200: { description: "User created" },
|
|
@@ -46,11 +111,66 @@ const createUserRoute = route({
|
|
|
46
111
|
auth: { apiKey: true },
|
|
47
112
|
});
|
|
48
113
|
|
|
114
|
+
const listUnmapped = route({
|
|
115
|
+
method: "get",
|
|
116
|
+
path: "/api/users/unmapped",
|
|
117
|
+
pattern: ["api", "users", "unmapped"],
|
|
118
|
+
summary: "List unmapped external identities (kv-backed triage queue)",
|
|
119
|
+
tags: ["Users"],
|
|
120
|
+
query: z.object({
|
|
121
|
+
kind: z.string().optional(),
|
|
122
|
+
limit: z.coerce.number().int().min(1).max(1000).optional(),
|
|
123
|
+
}),
|
|
124
|
+
responses: {
|
|
125
|
+
200: { description: "List of unmapped identities sorted by count DESC, lastSeenAt DESC" },
|
|
126
|
+
401: { description: "Unauthorized" },
|
|
127
|
+
},
|
|
128
|
+
auth: { apiKey: true },
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const resolveUnmapped = route({
|
|
132
|
+
method: "post",
|
|
133
|
+
path: "/api/users/unmapped/{kind}/{externalId}/resolve",
|
|
134
|
+
pattern: ["api", "users", "unmapped", null, null, "resolve"],
|
|
135
|
+
summary: "Resolve an unmapped identity — link to an existing user or create a new one",
|
|
136
|
+
tags: ["Users"],
|
|
137
|
+
params: z.object({ kind: z.string(), externalId: z.string() }),
|
|
138
|
+
body: z.union([
|
|
139
|
+
z.object({ userId: z.string().min(1) }),
|
|
140
|
+
z.object({ name: z.string().min(1), email: z.string().email() }),
|
|
141
|
+
]),
|
|
142
|
+
responses: {
|
|
143
|
+
200: { description: "Identity linked + kv entries cleared" },
|
|
144
|
+
400: { description: "Validation error" },
|
|
145
|
+
401: { description: "Unauthorized" },
|
|
146
|
+
404: { description: "Target user not found" },
|
|
147
|
+
},
|
|
148
|
+
auth: { apiKey: true },
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const getUserRoute = route({
|
|
152
|
+
method: "get",
|
|
153
|
+
path: "/api/users/{id}",
|
|
154
|
+
pattern: ["api", "users", null],
|
|
155
|
+
summary: "Get a user by ID with identities, token summaries and recent events",
|
|
156
|
+
tags: ["Users"],
|
|
157
|
+
params: z.object({ id: z.string() }),
|
|
158
|
+
query: z.object({
|
|
159
|
+
recentEvents: z.coerce.number().int().min(0).max(200).optional(),
|
|
160
|
+
}),
|
|
161
|
+
responses: {
|
|
162
|
+
200: { description: "User row" },
|
|
163
|
+
401: { description: "Unauthorized" },
|
|
164
|
+
404: { description: "User not found" },
|
|
165
|
+
},
|
|
166
|
+
auth: { apiKey: true },
|
|
167
|
+
});
|
|
168
|
+
|
|
49
169
|
const updateUserRoute = route({
|
|
50
|
-
method: "
|
|
170
|
+
method: "patch",
|
|
51
171
|
path: "/api/users/{id}",
|
|
52
172
|
pattern: ["api", "users", null],
|
|
53
|
-
summary: "Update an existing user (
|
|
173
|
+
summary: "Update an existing user (profile / budget / status / email-aliases / identities)",
|
|
54
174
|
tags: ["Users"],
|
|
55
175
|
params: z.object({ id: z.string() }),
|
|
56
176
|
body: z
|
|
@@ -59,13 +179,17 @@ const updateUserRoute = route({
|
|
|
59
179
|
email: z.string().optional(),
|
|
60
180
|
role: z.string().optional(),
|
|
61
181
|
notes: z.string().optional(),
|
|
62
|
-
slackUserId: z.string().optional(),
|
|
63
|
-
linearUserId: z.string().optional(),
|
|
64
|
-
githubUsername: z.string().optional(),
|
|
65
|
-
gitlabUsername: z.string().optional(),
|
|
66
182
|
emailAliases: z.array(z.string()).optional(),
|
|
67
183
|
preferredChannel: z.string().optional(),
|
|
68
184
|
timezone: z.string().optional(),
|
|
185
|
+
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
|
|
186
|
+
dailyBudgetUsd: z.number().nullable().optional(),
|
|
187
|
+
status: z.enum(["invited", "active", "suspended"]).optional(),
|
|
188
|
+
// Complete-list diff: passing this replaces the user's identity set,
|
|
189
|
+
// emitting `identity_added` / `identity_removed` for each delta.
|
|
190
|
+
identities: z
|
|
191
|
+
.array(z.object({ kind: z.string().min(1), externalId: z.string().min(1) }))
|
|
192
|
+
.optional(),
|
|
69
193
|
})
|
|
70
194
|
.refine((v) => Object.keys(v).length > 0, {
|
|
71
195
|
message: "At least one field must be provided",
|
|
@@ -79,6 +203,145 @@ const updateUserRoute = route({
|
|
|
79
203
|
auth: { apiKey: true },
|
|
80
204
|
});
|
|
81
205
|
|
|
206
|
+
const mergeUsersRoute = route({
|
|
207
|
+
method: "post",
|
|
208
|
+
path: "/api/users/{id}/merge",
|
|
209
|
+
pattern: ["api", "users", null, "merge"],
|
|
210
|
+
summary: "Merge another user into this one — moves identities + email aliases, deletes source",
|
|
211
|
+
tags: ["Users"],
|
|
212
|
+
params: z.object({ id: z.string() }),
|
|
213
|
+
body: z.object({ sourceUserId: z.string().min(1) }),
|
|
214
|
+
responses: {
|
|
215
|
+
200: { description: "Merged user" },
|
|
216
|
+
400: { description: "Validation error (e.g. target == source)" },
|
|
217
|
+
401: { description: "Unauthorized" },
|
|
218
|
+
404: { description: "Target or source user not found" },
|
|
219
|
+
},
|
|
220
|
+
auth: { apiKey: true },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const listEventsRoute = route({
|
|
224
|
+
method: "get",
|
|
225
|
+
path: "/api/users/{id}/events",
|
|
226
|
+
pattern: ["api", "users", null, "events"],
|
|
227
|
+
summary: "Paginated identity-event timeline for a user (DESC by createdAt)",
|
|
228
|
+
tags: ["Users"],
|
|
229
|
+
params: z.object({ id: z.string() }),
|
|
230
|
+
query: z.object({
|
|
231
|
+
limit: z.coerce.number().int().min(1).max(200).optional(),
|
|
232
|
+
before: z.string().optional(),
|
|
233
|
+
}),
|
|
234
|
+
responses: {
|
|
235
|
+
200: { description: "Array of identity events" },
|
|
236
|
+
401: { description: "Unauthorized" },
|
|
237
|
+
404: { description: "User not found" },
|
|
238
|
+
},
|
|
239
|
+
auth: { apiKey: true },
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const addIdentityRoute = route({
|
|
243
|
+
method: "post",
|
|
244
|
+
path: "/api/users/{id}/identities",
|
|
245
|
+
pattern: ["api", "users", null, "identities"],
|
|
246
|
+
summary: "Link a new (kind, externalId) identity to this user",
|
|
247
|
+
tags: ["Users"],
|
|
248
|
+
params: z.object({ id: z.string() }),
|
|
249
|
+
body: z.object({ kind: z.string().min(1), externalId: z.string().min(1) }),
|
|
250
|
+
responses: {
|
|
251
|
+
200: { description: "Updated identity list" },
|
|
252
|
+
400: { description: "Validation error or PK collision" },
|
|
253
|
+
401: { description: "Unauthorized" },
|
|
254
|
+
404: { description: "User not found" },
|
|
255
|
+
},
|
|
256
|
+
auth: { apiKey: true },
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const deleteIdentityRoute = route({
|
|
260
|
+
method: "delete",
|
|
261
|
+
path: "/api/users/{id}/identities/{kind}/{externalId}",
|
|
262
|
+
pattern: ["api", "users", null, "identities", null, null],
|
|
263
|
+
summary: "Remove a (kind, externalId) identity link from this user",
|
|
264
|
+
tags: ["Users"],
|
|
265
|
+
params: z.object({ id: z.string(), kind: z.string(), externalId: z.string() }),
|
|
266
|
+
responses: {
|
|
267
|
+
200: { description: "Updated identity list" },
|
|
268
|
+
401: { description: "Unauthorized" },
|
|
269
|
+
404: { description: "User not found" },
|
|
270
|
+
},
|
|
271
|
+
auth: { apiKey: true },
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
const UNMAPPED_KINDS = ["slack", "github", "gitlab", "linear"] as const;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Group the two-key-per-identity kv entries (`<externalId>:meta` json +
|
|
280
|
+
* `<externalId>:count` integer) under a single `externalId` and return a
|
|
281
|
+
* unified shape ready for the People-page Unmapped tab.
|
|
282
|
+
*/
|
|
283
|
+
function collectUnmappedForKind(kind: string, limit: number) {
|
|
284
|
+
const namespace = `integration:unmapped:${kind}`;
|
|
285
|
+
// listKv is bounded internally; we ask for 2x the cap so meta+count pairs
|
|
286
|
+
// produce up to `limit` unique externalIds.
|
|
287
|
+
const rows = listKv(namespace, { limit: Math.min(limit * 2, 1000), offset: 0 });
|
|
288
|
+
const byId = new Map<
|
|
289
|
+
string,
|
|
290
|
+
{
|
|
291
|
+
kind: string;
|
|
292
|
+
externalId: string;
|
|
293
|
+
lastSeenAt: string | null;
|
|
294
|
+
count: number;
|
|
295
|
+
sampleEventType: string | null;
|
|
296
|
+
sampleContext: unknown | null;
|
|
297
|
+
}
|
|
298
|
+
>();
|
|
299
|
+
for (const row of rows) {
|
|
300
|
+
const key = row.key;
|
|
301
|
+
let suffix: "meta" | "count" | null = null;
|
|
302
|
+
let externalId = "";
|
|
303
|
+
if (key.endsWith(":meta")) {
|
|
304
|
+
suffix = "meta";
|
|
305
|
+
externalId = key.slice(0, -":meta".length);
|
|
306
|
+
} else if (key.endsWith(":count")) {
|
|
307
|
+
suffix = "count";
|
|
308
|
+
externalId = key.slice(0, -":count".length);
|
|
309
|
+
} else {
|
|
310
|
+
// Legacy/unknown shape — skip.
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
let entry = byId.get(externalId);
|
|
314
|
+
if (!entry) {
|
|
315
|
+
entry = {
|
|
316
|
+
kind,
|
|
317
|
+
externalId,
|
|
318
|
+
lastSeenAt: null,
|
|
319
|
+
count: 0,
|
|
320
|
+
sampleEventType: null,
|
|
321
|
+
sampleContext: null,
|
|
322
|
+
};
|
|
323
|
+
byId.set(externalId, entry);
|
|
324
|
+
}
|
|
325
|
+
if (suffix === "meta") {
|
|
326
|
+
// Meta payload shape is producer-defined; we pull the common fields.
|
|
327
|
+
const meta =
|
|
328
|
+
row.value && typeof row.value === "object" ? (row.value as Record<string, unknown>) : null;
|
|
329
|
+
if (meta) {
|
|
330
|
+
const lastSeenAt = meta.lastSeenAt;
|
|
331
|
+
if (typeof lastSeenAt === "string") entry.lastSeenAt = lastSeenAt;
|
|
332
|
+
const sampleEventType = meta.sampleEventType ?? meta.eventType;
|
|
333
|
+
if (typeof sampleEventType === "string") entry.sampleEventType = sampleEventType;
|
|
334
|
+
entry.sampleContext = meta.sampleContext ?? meta.context ?? null;
|
|
335
|
+
}
|
|
336
|
+
} else {
|
|
337
|
+
// Count is stored as integer (decoded by listKv into a number); coerce.
|
|
338
|
+
const n = typeof row.value === "number" ? row.value : Number(row.value);
|
|
339
|
+
if (Number.isFinite(n)) entry.count = n;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return Array.from(byId.values());
|
|
343
|
+
}
|
|
344
|
+
|
|
82
345
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
83
346
|
|
|
84
347
|
export async function handleUsers(
|
|
@@ -87,43 +350,370 @@ export async function handleUsers(
|
|
|
87
350
|
pathSegments: string[],
|
|
88
351
|
queryParams: URLSearchParams,
|
|
89
352
|
): Promise<boolean> {
|
|
353
|
+
// ─── GET /api/users ────────────────────────────────────────────────────────
|
|
90
354
|
if (listUsers.match(req.method, pathSegments)) {
|
|
91
355
|
const parsed = await listUsers.parse(req, res, pathSegments, queryParams);
|
|
92
356
|
if (!parsed) return true;
|
|
93
|
-
const
|
|
357
|
+
const recentLimit = parsed.query.recentEvents ?? 5;
|
|
358
|
+
const users = getAllUsers().map((u) => composeUser(u.id, recentLimit));
|
|
94
359
|
json(res, { users });
|
|
95
360
|
return true;
|
|
96
361
|
}
|
|
97
362
|
|
|
363
|
+
// ─── POST /api/users ───────────────────────────────────────────────────────
|
|
98
364
|
if (createUserRoute.match(req.method, pathSegments)) {
|
|
99
365
|
const parsed = await createUserRoute.parse(req, res, pathSegments, queryParams);
|
|
100
366
|
if (!parsed) return true;
|
|
367
|
+
const actor = getOperatorActor(req, res);
|
|
368
|
+
if (!actor) return true;
|
|
369
|
+
|
|
101
370
|
try {
|
|
102
|
-
const
|
|
103
|
-
|
|
371
|
+
const { identities, ...userFields } = parsed.body;
|
|
372
|
+
const user = createUser(userFields);
|
|
373
|
+
for (const ident of identities ?? []) {
|
|
374
|
+
linkIdentity(user.id, ident.kind, ident.externalId, actor);
|
|
375
|
+
}
|
|
376
|
+
if (userFields.dailyBudgetUsd !== undefined) {
|
|
377
|
+
recordIdentityEvent(user.id, "budget_changed", actor, null, {
|
|
378
|
+
dailyBudgetUsd: userFields.dailyBudgetUsd,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const composed = composeUser(user.id);
|
|
382
|
+
json(res, { user: composed });
|
|
104
383
|
} catch (err) {
|
|
105
384
|
jsonError(res, err instanceof Error ? err.message : "Failed to create user", 500);
|
|
106
385
|
}
|
|
107
386
|
return true;
|
|
108
387
|
}
|
|
109
388
|
|
|
389
|
+
// ─── GET /api/users/unmapped ───────────────────────────────────────────────
|
|
390
|
+
// MUST be checked before /api/users/:id — same depth so first-match wins.
|
|
391
|
+
if (listUnmapped.match(req.method, pathSegments)) {
|
|
392
|
+
const parsed = await listUnmapped.parse(req, res, pathSegments, queryParams);
|
|
393
|
+
if (!parsed) return true;
|
|
394
|
+
const limit = parsed.query.limit ?? 100;
|
|
395
|
+
const kinds = parsed.query.kind ? [parsed.query.kind] : UNMAPPED_KINDS;
|
|
396
|
+
const rows = kinds.flatMap((k) => collectUnmappedForKind(k, limit));
|
|
397
|
+
rows.sort((a, b) => {
|
|
398
|
+
if (b.count !== a.count) return b.count - a.count;
|
|
399
|
+
const al = a.lastSeenAt ?? "";
|
|
400
|
+
const bl = b.lastSeenAt ?? "";
|
|
401
|
+
return bl.localeCompare(al);
|
|
402
|
+
});
|
|
403
|
+
json(res, { unmapped: rows.slice(0, limit) });
|
|
404
|
+
return true;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ─── POST /api/users/unmapped/:kind/:externalId/resolve ───────────────────
|
|
408
|
+
if (resolveUnmapped.match(req.method, pathSegments)) {
|
|
409
|
+
const parsed = await resolveUnmapped.parse(req, res, pathSegments, queryParams);
|
|
410
|
+
if (!parsed) return true;
|
|
411
|
+
const actor = getOperatorActor(req, res);
|
|
412
|
+
if (!actor) return true;
|
|
413
|
+
|
|
414
|
+
// Path params arrive URL-encoded — decode so externalIds with `@`, `+`, `:`
|
|
415
|
+
// etc. (AgentMail email-as-externalId, "@handle" Linear usernames) AND
|
|
416
|
+
// custom kinds containing `;`, `@`, `+` land both in user_external_ids and
|
|
417
|
+
// kv-delete with their real value.
|
|
418
|
+
const kind = decodeURIComponent(parsed.params.kind);
|
|
419
|
+
const externalId = decodeURIComponent(parsed.params.externalId);
|
|
420
|
+
try {
|
|
421
|
+
let targetUserId: string;
|
|
422
|
+
if ("userId" in parsed.body) {
|
|
423
|
+
const existing = getUserById(parsed.body.userId);
|
|
424
|
+
if (!existing) {
|
|
425
|
+
jsonError(res, "Target user not found", 404);
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
targetUserId = existing.id;
|
|
429
|
+
} else {
|
|
430
|
+
const created = createUser({ name: parsed.body.name, email: parsed.body.email });
|
|
431
|
+
targetUserId = created.id;
|
|
432
|
+
}
|
|
433
|
+
linkIdentity(targetUserId, kind, externalId, actor);
|
|
434
|
+
// Clear both kv rows (best-effort — DELETE is idempotent).
|
|
435
|
+
const ns = `integration:unmapped:${kind}`;
|
|
436
|
+
deleteKv(ns, `${externalId}:meta`);
|
|
437
|
+
deleteKv(ns, `${externalId}:count`);
|
|
438
|
+
const user = composeUser(targetUserId);
|
|
439
|
+
json(res, { user });
|
|
440
|
+
} catch (err) {
|
|
441
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to resolve unmapped", 500);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ─── GET /api/users/:id/events ─────────────────────────────────────────────
|
|
447
|
+
if (listEventsRoute.match(req.method, pathSegments)) {
|
|
448
|
+
const parsed = await listEventsRoute.parse(req, res, pathSegments, queryParams);
|
|
449
|
+
if (!parsed) return true;
|
|
450
|
+
if (!getUserById(parsed.params.id)) {
|
|
451
|
+
jsonError(res, "User not found", 404);
|
|
452
|
+
return true;
|
|
453
|
+
}
|
|
454
|
+
const events: IdentityEvent[] = listUserEvents(parsed.params.id, {
|
|
455
|
+
limit: parsed.query.limit,
|
|
456
|
+
before: parsed.query.before,
|
|
457
|
+
});
|
|
458
|
+
json(res, { events });
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ─── POST /api/users/:id/identities ────────────────────────────────────────
|
|
463
|
+
if (addIdentityRoute.match(req.method, pathSegments)) {
|
|
464
|
+
const parsed = await addIdentityRoute.parse(req, res, pathSegments, queryParams);
|
|
465
|
+
if (!parsed) return true;
|
|
466
|
+
const actor = getOperatorActor(req, res);
|
|
467
|
+
if (!actor) return true;
|
|
468
|
+
if (!getUserById(parsed.params.id)) {
|
|
469
|
+
jsonError(res, "User not found", 404);
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
try {
|
|
473
|
+
linkIdentity(parsed.params.id, parsed.body.kind, parsed.body.externalId, actor);
|
|
474
|
+
json(res, { identities: getUserIdentities(parsed.params.id) });
|
|
475
|
+
} catch (err) {
|
|
476
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to link identity", 400);
|
|
477
|
+
}
|
|
478
|
+
return true;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── DELETE /api/users/:id/identities/:kind/:externalId ────────────────────
|
|
482
|
+
if (deleteIdentityRoute.match(req.method, pathSegments)) {
|
|
483
|
+
const parsed = await deleteIdentityRoute.parse(req, res, pathSegments, queryParams);
|
|
484
|
+
if (!parsed) return true;
|
|
485
|
+
const actor = getOperatorActor(req, res);
|
|
486
|
+
if (!actor) return true;
|
|
487
|
+
if (!getUserById(parsed.params.id)) {
|
|
488
|
+
jsonError(res, "User not found", 404);
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
try {
|
|
492
|
+
// Path params arrive URL-encoded — decode so a stored `@handle` /
|
|
493
|
+
// email-as-externalId AND a custom kind with `;`, `@`, `+` can actually
|
|
494
|
+
// be unlinked from the UI.
|
|
495
|
+
const kind = decodeURIComponent(parsed.params.kind);
|
|
496
|
+
const externalId = decodeURIComponent(parsed.params.externalId);
|
|
497
|
+
unlinkIdentity(parsed.params.id, kind, externalId, actor);
|
|
498
|
+
json(res, { identities: getUserIdentities(parsed.params.id) });
|
|
499
|
+
} catch (err) {
|
|
500
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to unlink identity", 500);
|
|
501
|
+
}
|
|
502
|
+
return true;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// ─── POST /api/users/:id/merge ─────────────────────────────────────────────
|
|
506
|
+
if (mergeUsersRoute.match(req.method, pathSegments)) {
|
|
507
|
+
const parsed = await mergeUsersRoute.parse(req, res, pathSegments, queryParams);
|
|
508
|
+
if (!parsed) return true;
|
|
509
|
+
const actor = getOperatorActor(req, res);
|
|
510
|
+
if (!actor) return true;
|
|
511
|
+
|
|
512
|
+
const targetId = parsed.params.id;
|
|
513
|
+
const sourceId = parsed.body.sourceUserId;
|
|
514
|
+
if (targetId === sourceId) {
|
|
515
|
+
jsonError(res, "Cannot merge a user into itself", 400);
|
|
516
|
+
return true;
|
|
517
|
+
}
|
|
518
|
+
const targetBefore = composeUser(targetId);
|
|
519
|
+
const sourceBefore = composeUser(sourceId);
|
|
520
|
+
if (!targetBefore) {
|
|
521
|
+
jsonError(res, "Target user not found", 404);
|
|
522
|
+
return true;
|
|
523
|
+
}
|
|
524
|
+
if (!sourceBefore) {
|
|
525
|
+
jsonError(res, "Source user not found", 404);
|
|
526
|
+
return true;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
try {
|
|
530
|
+
// Move every identity from source → target.
|
|
531
|
+
for (const ident of sourceBefore.identities) {
|
|
532
|
+
unlinkIdentity(sourceId, ident.kind, ident.externalId, actor);
|
|
533
|
+
linkIdentity(targetId, ident.kind, ident.externalId, actor);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Merge email aliases — append source.email + source.emailAliases into
|
|
537
|
+
// target.emailAliases (de-duped). Emit `email_added` per added alias.
|
|
538
|
+
const targetAliases = new Set(targetBefore.emailAliases ?? []);
|
|
539
|
+
const targetPrimary = (targetBefore.email ?? "").toLowerCase();
|
|
540
|
+
const newAliases: string[] = [];
|
|
541
|
+
const candidates = [
|
|
542
|
+
...(sourceBefore.email ? [sourceBefore.email] : []),
|
|
543
|
+
...(sourceBefore.emailAliases ?? []),
|
|
544
|
+
];
|
|
545
|
+
for (const candidate of candidates) {
|
|
546
|
+
const lower = candidate.toLowerCase();
|
|
547
|
+
if (!lower || lower === targetPrimary) continue;
|
|
548
|
+
if (
|
|
549
|
+
![...targetAliases].some((a) => a.toLowerCase() === lower) &&
|
|
550
|
+
!newAliases.some((a) => a.toLowerCase() === lower)
|
|
551
|
+
) {
|
|
552
|
+
newAliases.push(candidate);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (newAliases.length > 0) {
|
|
556
|
+
const merged = [...(targetBefore.emailAliases ?? []), ...newAliases];
|
|
557
|
+
updateUser(targetId, { emailAliases: merged });
|
|
558
|
+
for (const alias of newAliases) {
|
|
559
|
+
recordIdentityEvent(targetId, "email_added", actor, null, { email: alias });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Delete source — CASCADE cleans up any leftover external_ids row that
|
|
564
|
+
// we may have missed (and clears tasks.requestedByUserId pointers).
|
|
565
|
+
deleteUser(sourceId);
|
|
566
|
+
|
|
567
|
+
// Single manual_merge event on target capturing the before/after rows.
|
|
568
|
+
// The source row is deleted above, so carry a minimal snapshot of the
|
|
569
|
+
// source user ({id, name, email}) inside the `after` payload under
|
|
570
|
+
// `source` — this lets the UI render "Merged manually from X → Y".
|
|
571
|
+
const targetAfter = composeUser(targetId);
|
|
572
|
+
recordIdentityEvent(targetId, "manual_merge", actor, targetBefore, {
|
|
573
|
+
...targetAfter,
|
|
574
|
+
source: {
|
|
575
|
+
id: sourceBefore.id,
|
|
576
|
+
name: sourceBefore.name,
|
|
577
|
+
email: sourceBefore.email,
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
// Re-compose AFTER the event so the response surfaces the merge event in
|
|
582
|
+
// recentEvents (otherwise the timeline is missing the event we just wrote).
|
|
583
|
+
json(res, { user: composeUser(targetId) });
|
|
584
|
+
} catch (err) {
|
|
585
|
+
jsonError(res, err instanceof Error ? err.message : "Failed to merge users", 500);
|
|
586
|
+
}
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
// ─── GET /api/users/:id ────────────────────────────────────────────────────
|
|
591
|
+
if (getUserRoute.match(req.method, pathSegments)) {
|
|
592
|
+
const parsed = await getUserRoute.parse(req, res, pathSegments, queryParams);
|
|
593
|
+
if (!parsed) return true;
|
|
594
|
+
const composed = composeUser(parsed.params.id, parsed.query.recentEvents ?? 50);
|
|
595
|
+
if (!composed) {
|
|
596
|
+
jsonError(res, "User not found", 404);
|
|
597
|
+
return true;
|
|
598
|
+
}
|
|
599
|
+
json(res, { user: composed });
|
|
600
|
+
return true;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ─── PATCH /api/users/:id ──────────────────────────────────────────────────
|
|
110
604
|
if (updateUserRoute.match(req.method, pathSegments)) {
|
|
111
605
|
const parsed = await updateUserRoute.parse(req, res, pathSegments, queryParams);
|
|
112
606
|
if (!parsed) return true;
|
|
607
|
+
const actor = getOperatorActor(req, res);
|
|
608
|
+
if (!actor) return true;
|
|
113
609
|
|
|
114
|
-
|
|
115
|
-
if (!
|
|
610
|
+
const before = getUserById(parsed.params.id);
|
|
611
|
+
if (!before) {
|
|
116
612
|
jsonError(res, "User not found", 404);
|
|
117
613
|
return true;
|
|
118
614
|
}
|
|
119
615
|
|
|
120
616
|
try {
|
|
121
|
-
const
|
|
122
|
-
|
|
617
|
+
const { identities, metadata, ...rest } = parsed.body;
|
|
618
|
+
// updateUser doesn't accept `metadata: null` directly — passthrough via cast.
|
|
619
|
+
const update: Parameters<typeof updateUser>[1] = { ...rest };
|
|
620
|
+
if (metadata !== undefined) {
|
|
621
|
+
update.metadata = metadata as Record<string, unknown> | null;
|
|
622
|
+
}
|
|
623
|
+
const updated = updateUser(parsed.params.id, update);
|
|
624
|
+
if (!updated) {
|
|
123
625
|
jsonError(res, "User not found", 404);
|
|
124
626
|
return true;
|
|
125
627
|
}
|
|
126
|
-
|
|
628
|
+
|
|
629
|
+
// Budget event
|
|
630
|
+
if (
|
|
631
|
+
parsed.body.dailyBudgetUsd !== undefined &&
|
|
632
|
+
(before.dailyBudgetUsd ?? null) !== (parsed.body.dailyBudgetUsd ?? null)
|
|
633
|
+
) {
|
|
634
|
+
recordIdentityEvent(
|
|
635
|
+
parsed.params.id,
|
|
636
|
+
"budget_changed",
|
|
637
|
+
actor,
|
|
638
|
+
{ dailyBudgetUsd: before.dailyBudgetUsd ?? null },
|
|
639
|
+
{ dailyBudgetUsd: parsed.body.dailyBudgetUsd ?? null },
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Status event
|
|
644
|
+
if (parsed.body.status !== undefined && before.status !== parsed.body.status) {
|
|
645
|
+
recordIdentityEvent(
|
|
646
|
+
parsed.params.id,
|
|
647
|
+
"status_changed",
|
|
648
|
+
actor,
|
|
649
|
+
{ status: before.status },
|
|
650
|
+
{ status: parsed.body.status },
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Email aliases diff — emit email_added / email_removed per Q19
|
|
655
|
+
if (parsed.body.emailAliases !== undefined) {
|
|
656
|
+
const beforeSet = new Set((before.emailAliases ?? []).map((a) => a.toLowerCase()));
|
|
657
|
+
const afterSet = new Set(parsed.body.emailAliases.map((a) => a.toLowerCase()));
|
|
658
|
+
for (const a of parsed.body.emailAliases) {
|
|
659
|
+
if (!beforeSet.has(a.toLowerCase())) {
|
|
660
|
+
recordIdentityEvent(parsed.params.id, "email_added", actor, null, { email: a });
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
for (const a of before.emailAliases ?? []) {
|
|
664
|
+
if (!afterSet.has(a.toLowerCase())) {
|
|
665
|
+
recordIdentityEvent(parsed.params.id, "email_removed", actor, { email: a }, null);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Profile-field diffs — emit one `profile_changed` event per field that
|
|
671
|
+
// changed value. Status / budget / aliases / identities already emit
|
|
672
|
+
// their own dedicated events above; skip them here to avoid double-emit.
|
|
673
|
+
const PROFILE_FIELDS = [
|
|
674
|
+
"name",
|
|
675
|
+
"email",
|
|
676
|
+
"role",
|
|
677
|
+
"timezone",
|
|
678
|
+
"preferredChannel",
|
|
679
|
+
"notes",
|
|
680
|
+
"metadata",
|
|
681
|
+
] as const;
|
|
682
|
+
for (const field of PROFILE_FIELDS) {
|
|
683
|
+
if (parsed.body[field] === undefined) continue;
|
|
684
|
+
const beforeVal = (before as unknown as Record<string, unknown>)[field] ?? null;
|
|
685
|
+
const afterVal = parsed.body[field] ?? null;
|
|
686
|
+
// Cheap deep-equal via JSON — fields are scalar strings or object/null.
|
|
687
|
+
if (JSON.stringify(beforeVal) === JSON.stringify(afterVal)) continue;
|
|
688
|
+
recordIdentityEvent(
|
|
689
|
+
parsed.params.id,
|
|
690
|
+
"profile_changed",
|
|
691
|
+
actor,
|
|
692
|
+
{ [field]: beforeVal },
|
|
693
|
+
{ [field]: afterVal },
|
|
694
|
+
);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Identities diff — complete-list semantics. linkIdentity / unlinkIdentity
|
|
698
|
+
// already emit the right event each.
|
|
699
|
+
if (identities !== undefined) {
|
|
700
|
+
const beforeIds = getUserIdentities(parsed.params.id);
|
|
701
|
+
const beforeKeys = new Set(beforeIds.map((i) => `${i.kind}:${i.externalId}`));
|
|
702
|
+
const afterKeys = new Set(identities.map((i) => `${i.kind}:${i.externalId}`));
|
|
703
|
+
for (const i of identities) {
|
|
704
|
+
if (!beforeKeys.has(`${i.kind}:${i.externalId}`)) {
|
|
705
|
+
linkIdentity(parsed.params.id, i.kind, i.externalId, actor);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (const i of beforeIds) {
|
|
709
|
+
if (!afterKeys.has(`${i.kind}:${i.externalId}`)) {
|
|
710
|
+
unlinkIdentity(parsed.params.id, i.kind, i.externalId, actor);
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
const composed = composeUser(parsed.params.id);
|
|
716
|
+
json(res, { user: composed });
|
|
127
717
|
} catch (err) {
|
|
128
718
|
jsonError(res, err instanceof Error ? err.message : "Failed to update user", 500);
|
|
129
719
|
}
|