@nordbyte/nordrelay 0.5.2 → 0.6.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 (52) hide show
  1. package/.env.example +63 -11
  2. package/README.md +90 -19
  3. package/dist/access-control.js +1 -0
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-rendering.js +10 -7
  7. package/dist/bot.js +458 -5
  8. package/dist/channel-actions.js +7 -2
  9. package/dist/channel-adapter.js +34 -7
  10. package/dist/channel-command-service.js +156 -0
  11. package/dist/channel-turn-service.js +237 -0
  12. package/dist/config-metadata.js +78 -13
  13. package/dist/config.js +77 -7
  14. package/dist/context-key.js +77 -5
  15. package/dist/discord-artifacts.js +165 -0
  16. package/dist/discord-bot.js +2014 -0
  17. package/dist/discord-channel-runtime.js +133 -0
  18. package/dist/discord-command-surface.js +119 -0
  19. package/dist/discord-rate-limit.js +141 -0
  20. package/dist/index.js +16 -5
  21. package/dist/job-store.js +127 -0
  22. package/dist/metrics.js +41 -0
  23. package/dist/relay-external-activity-monitor.js +47 -6
  24. package/dist/relay-runtime.js +986 -281
  25. package/dist/runtime-cache.js +57 -0
  26. package/dist/session-locks.js +10 -7
  27. package/dist/support-bundle.js +1 -0
  28. package/dist/telegram-access-commands.js +15 -2
  29. package/dist/telegram-access-middleware.js +16 -3
  30. package/dist/telegram-agent-commands.js +25 -0
  31. package/dist/telegram-artifact-commands.js +46 -0
  32. package/dist/telegram-diagnostics-command.js +5 -50
  33. package/dist/telegram-general-commands.js +2 -6
  34. package/dist/telegram-operational-commands.js +14 -6
  35. package/dist/telegram-queue-commands.js +74 -4
  36. package/dist/telegram-support-command.js +7 -0
  37. package/dist/telegram-update-commands.js +27 -0
  38. package/dist/user-management.js +208 -0
  39. package/dist/web-api-contract.js +9 -0
  40. package/dist/web-dashboard-access-routes.js +74 -1
  41. package/dist/web-dashboard-artifact-routes.js +3 -3
  42. package/dist/web-dashboard-assets.js +2 -0
  43. package/dist/web-dashboard-pages.js +97 -13
  44. package/dist/web-dashboard-runtime-routes.js +53 -8
  45. package/dist/web-dashboard-session-routes.js +27 -20
  46. package/dist/web-dashboard-ui.js +1 -0
  47. package/dist/web-dashboard.js +148 -6
  48. package/dist/web-state.js +33 -2
  49. package/dist/webui-assets/dashboard.css +75 -1
  50. package/dist/webui-assets/dashboard.js +358 -47
  51. package/package.json +3 -1
  52. package/plugins/nordrelay/scripts/nordrelay.mjs +210 -17
@@ -26,6 +26,13 @@ export function registerTelegramSupportCommands(options) {
26
26
  action: "command",
27
27
  status: "ok",
28
28
  contextKey,
29
+ actor: {
30
+ channel: "telegram",
31
+ id: ctx.from?.id !== undefined ? `telegram:${ctx.from.id}` : undefined,
32
+ label: ctx.from?.username || ctx.from?.first_name || (ctx.from?.id !== undefined ? String(ctx.from.id) : undefined),
33
+ username: ctx.from?.username,
34
+ channelUserId: ctx.from?.id !== undefined ? String(ctx.from.id) : undefined,
35
+ },
29
36
  actorId: ctx.from?.id,
30
37
  actorRole: options.getUserRole(ctx),
31
38
  description: "export diagnostics bundle",
@@ -33,12 +33,26 @@ export function registerTelegramUpdateCommands(deps) {
33
33
  }
34
34
  if (subcommand === "cancel" && tokens[1]) {
35
35
  const job = agentUpdates.cancel(tokens[1]);
36
+ deps.appendActivity?.(ctx, {
37
+ status: "aborted",
38
+ type: "agent_update_cancel_requested",
39
+ threadId: null,
40
+ agentId: job.agentId,
41
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
42
+ });
36
43
  const rendered = renderAgentUpdateJobAction(job);
37
44
  await replyChannelAction(ctx, rendered);
38
45
  return;
39
46
  }
40
47
  if ((subcommand === "input" || subcommand === "send") && tokens[1] && tokens.slice(2).join(" ").trim()) {
41
48
  const job = agentUpdates.sendInput(tokens[1], tokens.slice(2).join(" "));
49
+ deps.appendActivity?.(ctx, {
50
+ status: "info",
51
+ type: "agent_update_input_sent",
52
+ threadId: null,
53
+ agentId: job.agentId,
54
+ detail: `Input sent to ${job.agentLabel} ${job.operation}.`,
55
+ });
42
56
  const rendered = renderAgentUpdateJobAction(job);
43
57
  await replyChannelAction(ctx, rendered);
44
58
  return;
@@ -54,6 +68,12 @@ export function registerTelegramUpdateCommands(deps) {
54
68
  return;
55
69
  }
56
70
  const update = spawnSelfUpdate();
71
+ deps.appendActivity?.(ctx, {
72
+ status: "info",
73
+ type: "update_started",
74
+ threadId: null,
75
+ detail: `${update.method}: ${update.summary}`,
76
+ });
57
77
  const rendered = renderSelfUpdateStartedAction(update);
58
78
  await replyChannelAction(ctx, rendered);
59
79
  });
@@ -87,6 +107,13 @@ export function registerTelegramUpdateCommands(deps) {
87
107
  return;
88
108
  }
89
109
  const job = agentUpdates.cancel(id);
110
+ deps.appendActivity?.(ctx, {
111
+ status: "aborted",
112
+ type: "agent_update_cancel_requested",
113
+ threadId: null,
114
+ agentId: job.agentId,
115
+ detail: `${job.agentLabel} ${job.operation} cancellation requested.`,
116
+ });
90
117
  const rendered = renderAgentUpdateJobAction(job);
91
118
  await replyChannelAction(ctx, rendered);
92
119
  });
@@ -25,12 +25,14 @@ export class UserStore {
25
25
  ...user,
26
26
  groups: this.groupsForUser(payload, user.id),
27
27
  telegramIdentities: payload.telegramIdentities.filter((identity) => identity.userId === user.id),
28
+ discordIdentities: payload.discordIdentities.filter((identity) => identity.userId === user.id),
28
29
  webSessions: payload.webSessions
29
30
  .filter((session) => session.userId === user.id)
30
31
  .map(publicWebSession),
31
32
  })),
32
33
  groups: payload.groups,
33
34
  telegramChats: payload.telegramChats,
35
+ discordChannels: payload.discordChannels,
34
36
  adminConfigured: payload.users.some((user) => user.active && this.groupIdsForUser(payload, user.id).includes(ADMIN_GROUP_ID)),
35
37
  };
36
38
  }
@@ -83,6 +85,11 @@ export class UserStore {
83
85
  telegramUserId: input.telegramUserId,
84
86
  });
85
87
  }
88
+ if (input.discordUserId !== undefined) {
89
+ this.upsertDiscordIdentityInPayload(payload, user.id, {
90
+ discordUserId: input.discordUserId,
91
+ });
92
+ }
86
93
  return this.authenticatedUser(payload, user);
87
94
  });
88
95
  }
@@ -229,6 +236,19 @@ export class UserStore {
229
236
  const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
230
237
  return user ? this.authenticatedUser(payload, user) : null;
231
238
  }
239
+ resolveDiscordUser(discordUserId) {
240
+ const normalized = normalizeDiscordId(discordUserId);
241
+ if (!normalized) {
242
+ return null;
243
+ }
244
+ const payload = this.readPayload();
245
+ const identity = payload.discordIdentities.find((candidate) => candidate.discordUserId === normalized && candidate.active);
246
+ if (!identity) {
247
+ return null;
248
+ }
249
+ const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
250
+ return user ? this.authenticatedUser(payload, user) : null;
251
+ }
232
252
  linkTelegramUser(userId, input) {
233
253
  return this.mutatePayload((payload) => {
234
254
  const user = payload.users.find((candidate) => candidate.id === userId);
@@ -245,6 +265,22 @@ export class UserStore {
245
265
  return payload.telegramIdentities.length !== before;
246
266
  });
247
267
  }
268
+ linkDiscordUser(userId, input) {
269
+ return this.mutatePayload((payload) => {
270
+ const user = payload.users.find((candidate) => candidate.id === userId);
271
+ if (!user) {
272
+ throw new Error("User not found.");
273
+ }
274
+ return this.upsertDiscordIdentityInPayload(payload, userId, input);
275
+ });
276
+ }
277
+ unlinkDiscordIdentity(identityId) {
278
+ return this.mutatePayload((payload) => {
279
+ const before = payload.discordIdentities.length;
280
+ payload.discordIdentities = payload.discordIdentities.filter((identity) => identity.id !== identityId);
281
+ return payload.discordIdentities.length !== before;
282
+ });
283
+ }
248
284
  createTelegramLinkCode(userId) {
249
285
  return this.mutatePayload((payload) => {
250
286
  if (!payload.users.some((user) => user.id === userId && user.active)) {
@@ -262,6 +298,23 @@ export class UserStore {
262
298
  return code;
263
299
  });
264
300
  }
301
+ createDiscordLinkCode(userId) {
302
+ return this.mutatePayload((payload) => {
303
+ if (!payload.users.some((user) => user.id === userId && user.active)) {
304
+ throw new Error("Active user not found.");
305
+ }
306
+ const now = Date.now();
307
+ payload.discordLinkCodes = payload.discordLinkCodes.filter((code) => new Date(code.expiresAt).getTime() > now);
308
+ const code = {
309
+ code: randomLinkCode(),
310
+ userId,
311
+ createdAt: new Date(now).toISOString(),
312
+ expiresAt: new Date(now + LINK_CODE_TTL_MS).toISOString(),
313
+ };
314
+ payload.discordLinkCodes.push(code);
315
+ return code;
316
+ });
317
+ }
265
318
  consumeTelegramLinkCode(code, input) {
266
319
  return this.mutatePayload((payload) => {
267
320
  const normalized = code.trim().toUpperCase();
@@ -279,6 +332,23 @@ export class UserStore {
279
332
  return this.authenticatedUser(payload, user);
280
333
  });
281
334
  }
335
+ consumeDiscordLinkCode(code, input) {
336
+ return this.mutatePayload((payload) => {
337
+ const normalized = code.trim().toUpperCase();
338
+ const now = Date.now();
339
+ const link = payload.discordLinkCodes.find((candidate) => candidate.code === normalized && new Date(candidate.expiresAt).getTime() > now);
340
+ if (!link) {
341
+ throw new Error("Invalid or expired link code.");
342
+ }
343
+ const user = payload.users.find((candidate) => candidate.id === link.userId && candidate.active);
344
+ if (!user) {
345
+ throw new Error("Linked user is not active.");
346
+ }
347
+ this.upsertDiscordIdentityInPayload(payload, user.id, input);
348
+ payload.discordLinkCodes = payload.discordLinkCodes.filter((candidate) => candidate.code !== normalized);
349
+ return this.authenticatedUser(payload, user);
350
+ });
351
+ }
282
352
  registerTelegramChat(input) {
283
353
  return this.mutatePayload((payload) => {
284
354
  const now = new Date().toISOString();
@@ -322,6 +392,55 @@ export class UserStore {
322
392
  return chat;
323
393
  });
324
394
  }
395
+ registerDiscordChannel(input) {
396
+ return this.mutatePayload((payload) => {
397
+ const now = new Date().toISOString();
398
+ const channelId = normalizeDiscordId(input.channelId);
399
+ if (!channelId) {
400
+ throw new Error("Discord channel id is required.");
401
+ }
402
+ const guildId = normalizeDiscordId(input.guildId);
403
+ const existing = payload.discordChannels.find((channel) => channel.channelId === channelId && channel.guildId === guildId);
404
+ const allowedGroupIds = normalizeGroupIds(payload, input.allowedGroupIds ?? [], null);
405
+ if (existing) {
406
+ existing.title = input.title ?? existing.title;
407
+ existing.type = input.type ?? existing.type;
408
+ existing.enabled = input.enabled ?? existing.enabled;
409
+ existing.allowedGroupIds = allowedGroupIds;
410
+ existing.updatedAt = now;
411
+ return existing;
412
+ }
413
+ const channel = {
414
+ id: randomId(),
415
+ guildId,
416
+ channelId,
417
+ title: input.title,
418
+ type: input.type,
419
+ enabled: input.enabled ?? true,
420
+ allowedGroupIds,
421
+ createdAt: now,
422
+ updatedAt: now,
423
+ };
424
+ payload.discordChannels.push(channel);
425
+ return channel;
426
+ });
427
+ }
428
+ updateDiscordChannel(id, patch) {
429
+ return this.mutatePayload((payload) => {
430
+ const channel = payload.discordChannels.find((candidate) => candidate.id === id);
431
+ if (!channel) {
432
+ throw new Error("Discord channel not found.");
433
+ }
434
+ if (patch.enabled !== undefined)
435
+ channel.enabled = patch.enabled;
436
+ if (patch.title !== undefined)
437
+ channel.title = patch.title;
438
+ if (patch.allowedGroupIds !== undefined)
439
+ channel.allowedGroupIds = normalizeGroupIds(payload, patch.allowedGroupIds, null);
440
+ channel.updatedAt = new Date().toISOString();
441
+ return channel;
442
+ });
443
+ }
325
444
  isTelegramChatAllowed(chatId, chatType, user) {
326
445
  if (chatId === undefined) {
327
446
  return false;
@@ -340,6 +459,26 @@ export class UserStore {
340
459
  const userGroupIds = new Set(user.groups.map((group) => group.id));
341
460
  return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseTelegramChat(user, chatId);
342
461
  }
462
+ isDiscordChannelAllowed(input, user) {
463
+ const channelId = normalizeDiscordId(input.channelId);
464
+ if (!channelId) {
465
+ return false;
466
+ }
467
+ if (input.isDirectMessage) {
468
+ return this.canUseDiscordChannel(user, channelId);
469
+ }
470
+ const guildId = normalizeDiscordId(input.guildId);
471
+ const payload = this.readPayload();
472
+ const access = payload.discordChannels.find((channel) => channel.channelId === channelId && channel.guildId === guildId);
473
+ if (!access?.enabled) {
474
+ return false;
475
+ }
476
+ if (access.allowedGroupIds.length === 0) {
477
+ return this.canUseDiscordChannel(user, channelId);
478
+ }
479
+ const userGroupIds = new Set(user.groups.map((group) => group.id));
480
+ return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseDiscordChannel(user, channelId);
481
+ }
343
482
  hasPermission(user, permission) {
344
483
  return Boolean(permission && user?.permissions.includes(permission));
345
484
  }
@@ -363,6 +502,13 @@ export class UserStore {
363
502
  }
364
503
  return user.groups.some((group) => group.telegramChatIds.length === 0 || group.telegramChatIds.includes(chatId));
365
504
  }
505
+ canUseDiscordChannel(user, channelId) {
506
+ const normalized = normalizeDiscordId(channelId);
507
+ if (!user || !normalized) {
508
+ return true;
509
+ }
510
+ return user.groups.some((group) => group.discordChannelIds.length === 0 || group.discordChannelIds.includes(normalized));
511
+ }
366
512
  createGroup(input) {
367
513
  return this.mutatePayload((payload) => {
368
514
  const now = new Date().toISOString();
@@ -382,6 +528,7 @@ export class UserStore {
382
528
  agentIds: normalizeStringList(input.agentIds ?? []),
383
529
  workspaceRoots: normalizeStringList(input.workspaceRoots ?? []),
384
530
  telegramChatIds: normalizeNumberList(input.telegramChatIds ?? []),
531
+ discordChannelIds: normalizeStringList(input.discordChannelIds ?? []),
385
532
  createdAt: now,
386
533
  updatedAt: now,
387
534
  };
@@ -411,6 +558,8 @@ export class UserStore {
411
558
  group.workspaceRoots = normalizeStringList(patch.workspaceRoots);
412
559
  if (patch.telegramChatIds !== undefined)
413
560
  group.telegramChatIds = normalizeNumberList(patch.telegramChatIds);
561
+ if (patch.discordChannelIds !== undefined)
562
+ group.discordChannelIds = normalizeStringList(patch.discordChannelIds);
414
563
  group.updatedAt = new Date().toISOString();
415
564
  return group;
416
565
  });
@@ -460,6 +609,38 @@ export class UserStore {
460
609
  payload.telegramIdentities.push(identity);
461
610
  return identity;
462
611
  }
612
+ upsertDiscordIdentityInPayload(payload, userId, input) {
613
+ const discordUserId = normalizeDiscordId(input.discordUserId);
614
+ if (!discordUserId) {
615
+ throw new Error("Discord user id is required.");
616
+ }
617
+ const now = new Date().toISOString();
618
+ for (const identity of payload.discordIdentities) {
619
+ if (identity.discordUserId === discordUserId && identity.userId !== userId) {
620
+ identity.active = false;
621
+ }
622
+ }
623
+ const existing = payload.discordIdentities.find((identity) => identity.userId === userId && identity.discordUserId === discordUserId);
624
+ if (existing) {
625
+ existing.username = input.username ?? existing.username;
626
+ existing.globalName = input.globalName ?? existing.globalName;
627
+ existing.active = true;
628
+ existing.updatedAt = now;
629
+ return existing;
630
+ }
631
+ const identity = {
632
+ id: randomId(),
633
+ userId,
634
+ discordUserId,
635
+ username: input.username,
636
+ globalName: input.globalName,
637
+ active: true,
638
+ linkedAt: now,
639
+ updatedAt: now,
640
+ };
641
+ payload.discordIdentities.push(identity);
642
+ return identity;
643
+ }
463
644
  pruneExpiredSessionsInPayload(payload) {
464
645
  const now = Date.now();
465
646
  payload.webSessions = payload.webSessions.filter((session) => new Date(session.expiresAt).getTime() > now);
@@ -551,6 +732,7 @@ function normalizePayload(payload) {
551
732
  agentIds: [],
552
733
  workspaceRoots: [],
553
734
  telegramChatIds: [],
735
+ discordChannelIds: [],
554
736
  createdAt: now,
555
737
  updatedAt: now,
556
738
  });
@@ -565,6 +747,7 @@ function normalizePayload(payload) {
565
747
  agentIds: normalizeStringList(group.agentIds),
566
748
  workspaceRoots: normalizeStringList(group.workspaceRoots),
567
749
  telegramChatIds: normalizeNumberList(group.telegramChatIds),
750
+ discordChannelIds: normalizeStringList(group.discordChannelIds),
568
751
  });
569
752
  }
570
753
  const groups = Array.from(groupsById.values());
@@ -581,8 +764,14 @@ function normalizePayload(payload) {
581
764
  ...chat,
582
765
  allowedGroupIds: chat.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
583
766
  })),
767
+ discordIdentities: (payload?.discordIdentities ?? []).filter((item) => isDiscordIdentityRecord(item) && userIds.has(item.userId)),
768
+ discordChannels: (payload?.discordChannels ?? []).filter(isDiscordChannelAccessRecord).map((channel) => ({
769
+ ...channel,
770
+ allowedGroupIds: channel.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
771
+ })),
584
772
  webSessions: (payload?.webSessions ?? []).filter((item) => isWebSessionRecord(item) && userIds.has(item.userId)),
585
773
  telegramLinkCodes: (payload?.telegramLinkCodes ?? []).filter((item) => isTelegramLinkCodeRecord(item) && userIds.has(item.userId)),
774
+ discordLinkCodes: (payload?.discordLinkCodes ?? []).filter((item) => isDiscordLinkCodeRecord(item) && userIds.has(item.userId)),
586
775
  };
587
776
  }
588
777
  function normalizeEmail(email) {
@@ -619,6 +808,10 @@ function normalizeStringList(values) {
619
808
  function normalizeNumberList(values) {
620
809
  return Array.from(new Set((values ?? []).filter((value) => Number.isInteger(value))));
621
810
  }
811
+ function normalizeDiscordId(value) {
812
+ const normalized = String(value ?? "").trim();
813
+ return normalized || undefined;
814
+ }
622
815
  function assertActiveAdminExists(payload) {
623
816
  const hasAdmin = payload.users.some((user) => user.active && payload.userGroups.some((item) => item.userId === user.id && item.groupId === ADMIN_GROUP_ID));
624
817
  if (!hasAdmin) {
@@ -696,6 +889,16 @@ function isTelegramChatAccessRecord(value) {
696
889
  return Boolean(candidate) && typeof candidate.id === "string" && Number.isInteger(candidate.chatId) &&
697
890
  typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
698
891
  }
892
+ function isDiscordIdentityRecord(value) {
893
+ const candidate = value;
894
+ return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
895
+ typeof candidate.discordUserId === "string" && typeof candidate.active === "boolean";
896
+ }
897
+ function isDiscordChannelAccessRecord(value) {
898
+ const candidate = value;
899
+ return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.channelId === "string" &&
900
+ typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
901
+ }
699
902
  function isWebSessionRecord(value) {
700
903
  const candidate = value;
701
904
  return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
@@ -706,3 +909,8 @@ function isTelegramLinkCodeRecord(value) {
706
909
  return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
707
910
  typeof candidate.expiresAt === "string";
708
911
  }
912
+ function isDiscordLinkCodeRecord(value) {
913
+ const candidate = value;
914
+ return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
915
+ typeof candidate.expiresAt === "string";
916
+ }
@@ -7,6 +7,11 @@ export const WEB_API_ROUTE_DEFINITIONS = [
7
7
  exact("/api/snapshot", ["GET"], "inspect"),
8
8
  exact("/api/tasks", ["GET"], "inspect"),
9
9
  exact("/api/progress", ["GET"], "inspect"),
10
+ exact("/api/metrics", ["GET"], "inspect"),
11
+ exact("/api/jobs", ["GET"], "inspect"),
12
+ dynamic("/api/jobs/:id/log", "^/api/jobs/[^/]+/log$", ["GET"], "inspect", `/api/jobs/${stringToken}/log`),
13
+ dynamic("/api/jobs/:id/action", "^/api/jobs/[^/]+/action$", ["POST"], "inspect", `/api/jobs/${stringToken}/action`),
14
+ exact("/api/active-sessions", ["GET"], "sessions.read"),
10
15
  exact("/api/version", ["GET"], "inspect"),
11
16
  exact("/api/update", ["POST"], "updates.run"),
12
17
  exact("/api/agent-updates", ["GET"], "updates.run"),
@@ -23,10 +28,14 @@ export const WEB_API_ROUTE_DEFINITIONS = [
23
28
  dynamic("/api/users/:id/sessions/:sessionId", "^/api/users/[^/]+/sessions/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/sessions/${stringToken}`),
24
29
  dynamic("/api/users/:id/telegram", "^/api/users/[^/]+/telegram$", ["POST"], "users.write", `/api/users/${stringToken}/telegram`),
25
30
  dynamic("/api/users/:id/telegram/:identityId", "^/api/users/[^/]+/telegram/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/telegram/${stringToken}`),
31
+ dynamic("/api/users/:id/discord", "^/api/users/[^/]+/discord$", ["POST"], "users.write", `/api/users/${stringToken}/discord`),
32
+ dynamic("/api/users/:id/discord/:identityId", "^/api/users/[^/]+/discord/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/discord/${stringToken}`),
26
33
  exact("/api/groups", ["GET", "POST"], readWrite("users.read", "users.write")),
27
34
  dynamic("/api/groups/:id", "^/api/groups/[^/]+$", ["PATCH"], "users.write", `/api/groups/${stringToken}`),
28
35
  exact("/api/telegram-chats", ["GET", "POST"], readWrite("users.read", "users.write")),
29
36
  dynamic("/api/telegram-chats/:id", "^/api/telegram-chats/[^/]+$", ["PATCH"], "users.write", `/api/telegram-chats/${stringToken}`),
37
+ exact("/api/discord-channels", ["GET", "POST"], readWrite("users.read", "users.write")),
38
+ dynamic("/api/discord-channels/:id", "^/api/discord-channels/[^/]+$", ["PATCH"], "users.write", `/api/discord-channels/${stringToken}`),
30
39
  exact("/api/audit", ["GET"], "audit.read"),
31
40
  exact("/api/locks", ["GET", "POST", "DELETE"], readWrite("sessions.read", "sessions.write")),
32
41
  exact("/api/auth/status", ["GET"], "inspect"),
@@ -20,6 +20,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
20
20
  groupIds: arrayStringField(body, "groupIds"),
21
21
  active: optionalBooleanField(body, "active") ?? true,
22
22
  telegramUserId: optionalNumberField(body, "telegramUserId"),
23
+ discordUserId: optionalStringField(body, "discordUserId"),
23
24
  });
24
25
  options.auditUserAction(authUser, "user_created", user.user.email);
25
26
  sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
@@ -93,6 +94,33 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
93
94
  sendJson(res, 200, { removed });
94
95
  return true;
95
96
  }
97
+ const discordLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/discord$/);
98
+ if (discordLinkMatch?.[1] && req.method === "POST") {
99
+ const body = await readJsonBody(req);
100
+ if (body.createCode === true) {
101
+ const userId = decodeURIComponent(discordLinkMatch[1]);
102
+ const linkCode = users.createDiscordLinkCode(userId);
103
+ options.auditUserAction(authUser, "discord_link_created", userId);
104
+ sendJson(res, 201, { linkCode });
105
+ return true;
106
+ }
107
+ const identity = users.linkDiscordUser(decodeURIComponent(discordLinkMatch[1]), {
108
+ discordUserId: stringField(body, "discordUserId"),
109
+ username: optionalStringField(body, "username"),
110
+ globalName: optionalStringField(body, "globalName"),
111
+ });
112
+ options.auditUserAction(authUser, "discord_linked", identity.discordUserId);
113
+ sendJson(res, 201, { identity });
114
+ return true;
115
+ }
116
+ const discordUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/discord\/([^/]+)$/);
117
+ if (discordUnlinkMatch?.[1] && req.method === "DELETE") {
118
+ const identityId = decodeURIComponent(discordUnlinkMatch[1]);
119
+ const removed = users.unlinkDiscordIdentity(identityId);
120
+ options.auditUserAction(authUser, "discord_unlinked", identityId);
121
+ sendJson(res, 200, { removed });
122
+ return true;
123
+ }
96
124
  if (req.method === "GET" && url.pathname === "/api/groups") {
97
125
  sendJson(res, 200, { groups: users.listGroups() });
98
126
  return true;
@@ -106,6 +134,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
106
134
  agentIds: arrayStringField(body, "agentIds"),
107
135
  workspaceRoots: arrayStringField(body, "workspaceRoots"),
108
136
  telegramChatIds: arrayNumberField(body, "telegramChatIds"),
137
+ discordChannelIds: arrayStringField(body, "discordChannelIds"),
109
138
  });
110
139
  options.auditUserAction(authUser, "group_created", group.id);
111
140
  sendJson(res, 201, { group });
@@ -121,6 +150,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
121
150
  agentIds: body.agentIds === undefined ? undefined : arrayStringField(body, "agentIds"),
122
151
  workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
123
152
  telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
153
+ discordChannelIds: body.discordChannelIds === undefined ? undefined : arrayStringField(body, "discordChannelIds"),
124
154
  });
125
155
  options.auditUserAction(authUser, "group_updated", group.id);
126
156
  sendJson(res, 200, { group });
@@ -155,8 +185,51 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
155
185
  sendJson(res, 200, { chat });
156
186
  return true;
157
187
  }
188
+ if (req.method === "GET" && url.pathname === "/api/discord-channels") {
189
+ sendJson(res, 200, { channels: users.snapshot().discordChannels });
190
+ return true;
191
+ }
192
+ if (req.method === "POST" && url.pathname === "/api/discord-channels") {
193
+ const body = await readJsonBody(req);
194
+ const channel = users.registerDiscordChannel({
195
+ guildId: optionalStringField(body, "guildId"),
196
+ channelId: stringField(body, "channelId"),
197
+ title: optionalStringField(body, "title"),
198
+ type: optionalStringField(body, "type"),
199
+ enabled: optionalBooleanField(body, "enabled") ?? true,
200
+ allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
201
+ });
202
+ options.auditUserAction(authUser, "discord_channel_updated", channel.channelId);
203
+ sendJson(res, 201, { channel });
204
+ return true;
205
+ }
206
+ const discordChannelMatch = url.pathname.match(/^\/api\/discord-channels\/([^/]+)$/);
207
+ if (discordChannelMatch?.[1] && req.method === "PATCH") {
208
+ const body = await readJsonBody(req);
209
+ const channel = users.updateDiscordChannel(decodeURIComponent(discordChannelMatch[1]), {
210
+ enabled: optionalBooleanField(body, "enabled"),
211
+ title: optionalStringField(body, "title"),
212
+ allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
213
+ });
214
+ options.auditUserAction(authUser, "discord_channel_updated", channel.channelId);
215
+ sendJson(res, 200, { channel });
216
+ return true;
217
+ }
158
218
  if (req.method === "GET" && url.pathname === "/api/audit") {
159
- sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
219
+ sendJson(res, 200, {
220
+ events: runtime.audit({
221
+ limit: numberParam(url, "limit", 50),
222
+ channelId: (url.searchParams.get("channel") || "all"),
223
+ category: (url.searchParams.get("category") || "all"),
224
+ status: (url.searchParams.get("status") || "all"),
225
+ action: url.searchParams.get("action") || "all",
226
+ actor: url.searchParams.get("actor") || undefined,
227
+ agentId: url.searchParams.get("agent") || "all",
228
+ threadId: url.searchParams.get("thread") || undefined,
229
+ workspace: url.searchParams.get("workspace") || undefined,
230
+ since: url.searchParams.get("since") || undefined,
231
+ }),
232
+ });
160
233
  return true;
161
234
  }
162
235
  return false;
@@ -8,7 +8,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
8
8
  }
9
9
  if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
10
10
  await options.assertCurrentSessionScope(authUser);
11
- sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
11
+ sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId"), options.activityActor) });
12
12
  return true;
13
13
  }
14
14
  if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
@@ -21,7 +21,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
21
21
  }
22
22
  const removed = [];
23
23
  for (const turnId of turnIds) {
24
- if (await runtime.deleteArtifact(turnId)) {
24
+ if (await runtime.deleteArtifact(turnId, options.activityActor)) {
25
25
  removed.push(turnId);
26
26
  }
27
27
  }
@@ -30,7 +30,7 @@ export async function handleDashboardArtifactRoute(req, res, url, options) {
30
30
  }
31
31
  if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
32
32
  await options.assertCurrentSessionScope(authUser);
33
- const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
33
+ const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"), options.activityActor);
34
34
  if (!bundle) {
35
35
  sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
36
36
  return true;
@@ -9,6 +9,8 @@ const clientSources = [
9
9
  "client/overview.js",
10
10
  "client/events.js",
11
11
  "client/workflows.js",
12
+ "client/jobs.js",
13
+ "client/metrics.js",
12
14
  "client/admin.js",
13
15
  ];
14
16
  const styleSources = [