@nordbyte/nordrelay 0.7.0 → 0.8.1

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 (47) hide show
  1. package/.env.example +35 -0
  2. package/README.md +118 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +90 -2
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-server.js +20 -4
  23. package/dist/peer-store.js +17 -2
  24. package/dist/relay-runtime-helpers.js +3 -1
  25. package/dist/relay-runtime.js +7 -0
  26. package/dist/settings-wizard-test.js +216 -0
  27. package/dist/slack-artifacts.js +165 -0
  28. package/dist/slack-bot.js +1461 -0
  29. package/dist/slack-channel-runtime.js +147 -0
  30. package/dist/slack-command-surface.js +46 -0
  31. package/dist/slack-diagnostics.js +116 -0
  32. package/dist/slack-rate-limit.js +139 -0
  33. package/dist/user-management-crypto.js +38 -0
  34. package/dist/user-management-normalize.js +188 -0
  35. package/dist/user-management-types.js +1 -0
  36. package/dist/user-management.js +193 -196
  37. package/dist/web-api-contract.js +8 -0
  38. package/dist/web-dashboard-access-routes.js +62 -0
  39. package/dist/web-dashboard-assets.js +1 -0
  40. package/dist/web-dashboard-pages.js +14 -4
  41. package/dist/web-dashboard-peer-routes.js +32 -11
  42. package/dist/web-dashboard.js +34 -0
  43. package/dist/web-state.js +2 -2
  44. package/dist/webui-assets/dashboard.css +193 -0
  45. package/dist/webui-assets/dashboard.js +546 -145
  46. package/package.json +3 -1
  47. package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
@@ -1,12 +1,12 @@
1
- import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
2
1
  import { closeSync, mkdirSync, openSync, rmSync, statSync } from "node:fs";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
- import { ADMIN_GROUP_ID, BUILTIN_GROUPS, READONLY_GROUP_ID, USER_GROUP_ID, isPermission, } from "./access-control.js";
4
+ import { ADMIN_GROUP_ID, USER_GROUP_ID, } from "./access-control.js";
6
5
  import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
6
+ import { allPermissionsSafe as ALL_PERMISSIONS_SAFE, assertActiveAdminExists, isPathInside, normalizeDiscordId, normalizeEmail, normalizeGroupIds, normalizeNumberList, normalizePayload, normalizePermissions, normalizeSlackId, normalizeStringList, normalizeWorkspacePath, slugify, } from "./user-management-normalize.js";
7
+ import { constantTimeStringEqual, hashPassword, hashToken, randomId, randomLinkCode, randomSessionToken, sleepSync, verifyPasswordHash, } from "./user-management-crypto.js";
7
8
  const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
8
9
  const LINK_CODE_TTL_MS = 15 * 60 * 1000;
9
- const PASSWORD_KEYLEN = 64;
10
10
  const WRITE_LOCK_TIMEOUT_MS = 5_000;
11
11
  const STALE_LOCK_MS = 30_000;
12
12
  export class UserStore {
@@ -26,6 +26,7 @@ export class UserStore {
26
26
  groups: this.groupsForUser(payload, user.id),
27
27
  telegramIdentities: payload.telegramIdentities.filter((identity) => identity.userId === user.id),
28
28
  discordIdentities: payload.discordIdentities.filter((identity) => identity.userId === user.id),
29
+ slackIdentities: payload.slackIdentities.filter((identity) => identity.userId === user.id),
29
30
  webSessions: payload.webSessions
30
31
  .filter((session) => session.userId === user.id)
31
32
  .map(publicWebSession),
@@ -33,6 +34,7 @@ export class UserStore {
33
34
  groups: payload.groups,
34
35
  telegramChats: payload.telegramChats,
35
36
  discordChannels: payload.discordChannels,
37
+ slackChannels: payload.slackChannels,
36
38
  adminConfigured: payload.users.some((user) => user.active && this.groupIdsForUser(payload, user.id).includes(ADMIN_GROUP_ID)),
37
39
  };
38
40
  }
@@ -90,6 +92,12 @@ export class UserStore {
90
92
  discordUserId: input.discordUserId,
91
93
  });
92
94
  }
95
+ if (input.slackUserId !== undefined) {
96
+ this.upsertSlackIdentityInPayload(payload, user.id, {
97
+ slackUserId: input.slackUserId,
98
+ teamId: input.slackTeamId,
99
+ });
100
+ }
93
101
  return this.authenticatedUser(payload, user);
94
102
  });
95
103
  }
@@ -166,7 +174,7 @@ export class UserStore {
166
174
  if (!user) {
167
175
  throw new Error("Active user not found.");
168
176
  }
169
- const token = randomBytes(32).toString("hex");
177
+ const token = randomSessionToken();
170
178
  const now = new Date();
171
179
  const session = {
172
180
  id: randomId(),
@@ -249,6 +257,22 @@ export class UserStore {
249
257
  const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
250
258
  return user ? this.authenticatedUser(payload, user) : null;
251
259
  }
260
+ resolveSlackUser(input) {
261
+ const slackUserId = normalizeSlackId(input.slackUserId);
262
+ if (!slackUserId) {
263
+ return null;
264
+ }
265
+ const teamId = normalizeSlackId(input.teamId);
266
+ const payload = this.readPayload();
267
+ const identity = payload.slackIdentities.find((candidate) => candidate.slackUserId === slackUserId &&
268
+ candidate.active &&
269
+ (!teamId || !candidate.teamId || candidate.teamId === teamId));
270
+ if (!identity) {
271
+ return null;
272
+ }
273
+ const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
274
+ return user ? this.authenticatedUser(payload, user) : null;
275
+ }
252
276
  linkTelegramUser(userId, input) {
253
277
  return this.mutatePayload((payload) => {
254
278
  const user = payload.users.find((candidate) => candidate.id === userId);
@@ -281,6 +305,22 @@ export class UserStore {
281
305
  return payload.discordIdentities.length !== before;
282
306
  });
283
307
  }
308
+ linkSlackUser(userId, input) {
309
+ return this.mutatePayload((payload) => {
310
+ const user = payload.users.find((candidate) => candidate.id === userId);
311
+ if (!user) {
312
+ throw new Error("User not found.");
313
+ }
314
+ return this.upsertSlackIdentityInPayload(payload, userId, input);
315
+ });
316
+ }
317
+ unlinkSlackIdentity(identityId) {
318
+ return this.mutatePayload((payload) => {
319
+ const before = payload.slackIdentities.length;
320
+ payload.slackIdentities = payload.slackIdentities.filter((identity) => identity.id !== identityId);
321
+ return payload.slackIdentities.length !== before;
322
+ });
323
+ }
284
324
  createTelegramLinkCode(userId) {
285
325
  return this.mutatePayload((payload) => {
286
326
  if (!payload.users.some((user) => user.id === userId && user.active)) {
@@ -315,6 +355,23 @@ export class UserStore {
315
355
  return code;
316
356
  });
317
357
  }
358
+ createSlackLinkCode(userId) {
359
+ return this.mutatePayload((payload) => {
360
+ if (!payload.users.some((user) => user.id === userId && user.active)) {
361
+ throw new Error("Active user not found.");
362
+ }
363
+ const now = Date.now();
364
+ payload.slackLinkCodes = payload.slackLinkCodes.filter((code) => new Date(code.expiresAt).getTime() > now);
365
+ const code = {
366
+ code: randomLinkCode(),
367
+ userId,
368
+ createdAt: new Date(now).toISOString(),
369
+ expiresAt: new Date(now + LINK_CODE_TTL_MS).toISOString(),
370
+ };
371
+ payload.slackLinkCodes.push(code);
372
+ return code;
373
+ });
374
+ }
318
375
  consumeTelegramLinkCode(code, input) {
319
376
  return this.mutatePayload((payload) => {
320
377
  const normalized = code.trim().toUpperCase();
@@ -349,6 +406,23 @@ export class UserStore {
349
406
  return this.authenticatedUser(payload, user);
350
407
  });
351
408
  }
409
+ consumeSlackLinkCode(code, input) {
410
+ return this.mutatePayload((payload) => {
411
+ const normalized = code.trim().toUpperCase();
412
+ const now = Date.now();
413
+ const link = payload.slackLinkCodes.find((candidate) => candidate.code === normalized && new Date(candidate.expiresAt).getTime() > now);
414
+ if (!link) {
415
+ throw new Error("Invalid or expired link code.");
416
+ }
417
+ const user = payload.users.find((candidate) => candidate.id === link.userId && candidate.active);
418
+ if (!user) {
419
+ throw new Error("Linked user is not active.");
420
+ }
421
+ this.upsertSlackIdentityInPayload(payload, user.id, input);
422
+ payload.slackLinkCodes = payload.slackLinkCodes.filter((candidate) => candidate.code !== normalized);
423
+ return this.authenticatedUser(payload, user);
424
+ });
425
+ }
352
426
  registerTelegramChat(input) {
353
427
  return this.mutatePayload((payload) => {
354
428
  const now = new Date().toISOString();
@@ -441,6 +515,55 @@ export class UserStore {
441
515
  return channel;
442
516
  });
443
517
  }
518
+ registerSlackChannel(input) {
519
+ return this.mutatePayload((payload) => {
520
+ const now = new Date().toISOString();
521
+ const channelId = normalizeSlackId(input.channelId);
522
+ if (!channelId) {
523
+ throw new Error("Slack channel id is required.");
524
+ }
525
+ const teamId = normalizeSlackId(input.teamId);
526
+ const existing = payload.slackChannels.find((channel) => channel.channelId === channelId && channel.teamId === teamId);
527
+ const allowedGroupIds = normalizeGroupIds(payload, input.allowedGroupIds ?? [], null);
528
+ if (existing) {
529
+ existing.title = input.title ?? existing.title;
530
+ existing.type = input.type ?? existing.type;
531
+ existing.enabled = input.enabled ?? existing.enabled;
532
+ existing.allowedGroupIds = allowedGroupIds;
533
+ existing.updatedAt = now;
534
+ return existing;
535
+ }
536
+ const channel = {
537
+ id: randomId(),
538
+ teamId,
539
+ channelId,
540
+ title: input.title,
541
+ type: input.type,
542
+ enabled: input.enabled ?? true,
543
+ allowedGroupIds,
544
+ createdAt: now,
545
+ updatedAt: now,
546
+ };
547
+ payload.slackChannels.push(channel);
548
+ return channel;
549
+ });
550
+ }
551
+ updateSlackChannel(id, patch) {
552
+ return this.mutatePayload((payload) => {
553
+ const channel = payload.slackChannels.find((candidate) => candidate.id === id);
554
+ if (!channel) {
555
+ throw new Error("Slack channel not found.");
556
+ }
557
+ if (patch.enabled !== undefined)
558
+ channel.enabled = patch.enabled;
559
+ if (patch.title !== undefined)
560
+ channel.title = patch.title;
561
+ if (patch.allowedGroupIds !== undefined)
562
+ channel.allowedGroupIds = normalizeGroupIds(payload, patch.allowedGroupIds, null);
563
+ channel.updatedAt = new Date().toISOString();
564
+ return channel;
565
+ });
566
+ }
444
567
  isTelegramChatAllowed(chatId, chatType, user) {
445
568
  if (chatId === undefined) {
446
569
  return false;
@@ -479,6 +602,26 @@ export class UserStore {
479
602
  const userGroupIds = new Set(user.groups.map((group) => group.id));
480
603
  return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseDiscordChannel(user, channelId);
481
604
  }
605
+ isSlackChannelAllowed(input, user) {
606
+ const channelId = normalizeSlackId(input.channelId);
607
+ if (!channelId) {
608
+ return false;
609
+ }
610
+ if (input.isDirectMessage) {
611
+ return this.canUseSlackChannel(user, channelId);
612
+ }
613
+ const teamId = normalizeSlackId(input.teamId);
614
+ const payload = this.readPayload();
615
+ const access = payload.slackChannels.find((channel) => channel.channelId === channelId && channel.teamId === teamId);
616
+ if (!access?.enabled) {
617
+ return false;
618
+ }
619
+ if (access.allowedGroupIds.length === 0) {
620
+ return this.canUseSlackChannel(user, channelId);
621
+ }
622
+ const userGroupIds = new Set(user.groups.map((group) => group.id));
623
+ return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseSlackChannel(user, channelId);
624
+ }
482
625
  hasPermission(user, permission) {
483
626
  return Boolean(permission && user?.permissions.includes(permission));
484
627
  }
@@ -509,6 +652,13 @@ export class UserStore {
509
652
  }
510
653
  return user.groups.some((group) => group.discordChannelIds.length === 0 || group.discordChannelIds.includes(normalized));
511
654
  }
655
+ canUseSlackChannel(user, channelId) {
656
+ const normalized = normalizeSlackId(channelId);
657
+ if (!user || !normalized) {
658
+ return true;
659
+ }
660
+ return user.groups.some((group) => group.slackChannelIds.length === 0 || group.slackChannelIds.includes(normalized));
661
+ }
512
662
  createGroup(input) {
513
663
  return this.mutatePayload((payload) => {
514
664
  const now = new Date().toISOString();
@@ -529,6 +679,7 @@ export class UserStore {
529
679
  workspaceRoots: normalizeStringList(input.workspaceRoots ?? []),
530
680
  telegramChatIds: normalizeNumberList(input.telegramChatIds ?? []),
531
681
  discordChannelIds: normalizeStringList(input.discordChannelIds ?? []),
682
+ slackChannelIds: normalizeStringList(input.slackChannelIds ?? []),
532
683
  createdAt: now,
533
684
  updatedAt: now,
534
685
  };
@@ -560,6 +711,8 @@ export class UserStore {
560
711
  group.telegramChatIds = normalizeNumberList(patch.telegramChatIds);
561
712
  if (patch.discordChannelIds !== undefined)
562
713
  group.discordChannelIds = normalizeStringList(patch.discordChannelIds);
714
+ if (patch.slackChannelIds !== undefined)
715
+ group.slackChannelIds = normalizeStringList(patch.slackChannelIds);
563
716
  group.updatedAt = new Date().toISOString();
564
717
  return group;
565
718
  });
@@ -641,6 +794,42 @@ export class UserStore {
641
794
  payload.discordIdentities.push(identity);
642
795
  return identity;
643
796
  }
797
+ upsertSlackIdentityInPayload(payload, userId, input) {
798
+ const slackUserId = normalizeSlackId(input.slackUserId);
799
+ if (!slackUserId) {
800
+ throw new Error("Slack user id is required.");
801
+ }
802
+ const teamId = normalizeSlackId(input.teamId);
803
+ const now = new Date().toISOString();
804
+ for (const identity of payload.slackIdentities) {
805
+ if (identity.slackUserId === slackUserId && (identity.teamId ?? "") === (teamId ?? "") && identity.userId !== userId) {
806
+ identity.active = false;
807
+ }
808
+ }
809
+ const existing = payload.slackIdentities.find((identity) => identity.userId === userId &&
810
+ identity.slackUserId === slackUserId &&
811
+ (identity.teamId ?? "") === (teamId ?? ""));
812
+ if (existing) {
813
+ existing.username = input.username ?? existing.username;
814
+ existing.realName = input.realName ?? existing.realName;
815
+ existing.active = true;
816
+ existing.updatedAt = now;
817
+ return existing;
818
+ }
819
+ const identity = {
820
+ id: randomId(),
821
+ userId,
822
+ slackUserId,
823
+ teamId,
824
+ username: input.username,
825
+ realName: input.realName,
826
+ active: true,
827
+ linkedAt: now,
828
+ updatedAt: now,
829
+ };
830
+ payload.slackIdentities.push(identity);
831
+ return identity;
832
+ }
644
833
  pruneExpiredSessionsInPayload(payload) {
645
834
  const now = Date.now();
646
835
  payload.webSessions = payload.webSessions.filter((session) => new Date(session.expiresAt).getTime() > now);
@@ -722,195 +911,3 @@ export function publicUserSnapshot(snapshot) {
722
911
  }),
723
912
  };
724
913
  }
725
- function normalizePayload(payload) {
726
- const now = new Date().toISOString();
727
- const groupsById = new Map();
728
- for (const group of BUILTIN_GROUPS) {
729
- groupsById.set(group.id, {
730
- ...group,
731
- permissions: group.id === ADMIN_GROUP_ID ? ALL_PERMISSIONS_SAFE() : group.permissions,
732
- agentIds: [],
733
- workspaceRoots: [],
734
- telegramChatIds: [],
735
- discordChannelIds: [],
736
- createdAt: now,
737
- updatedAt: now,
738
- });
739
- }
740
- for (const group of payload?.groups ?? []) {
741
- if (!isGroupRecord(group))
742
- continue;
743
- groupsById.set(group.id, {
744
- ...group,
745
- permissions: group.id === ADMIN_GROUP_ID ? ALL_PERMISSIONS_SAFE() : normalizePermissions(group.permissions),
746
- system: BUILTIN_GROUPS.some((builtin) => builtin.id === group.id) || group.system,
747
- agentIds: normalizeStringList(group.agentIds),
748
- workspaceRoots: normalizeStringList(group.workspaceRoots),
749
- telegramChatIds: normalizeNumberList(group.telegramChatIds),
750
- discordChannelIds: normalizeStringList(group.discordChannelIds),
751
- });
752
- }
753
- const groups = Array.from(groupsById.values());
754
- const groupIds = new Set(groups.map((group) => group.id));
755
- const users = (payload?.users ?? []).filter(isUserRecord);
756
- const userIds = new Set(users.map((user) => user.id));
757
- return {
758
- version: 1,
759
- users,
760
- groups,
761
- userGroups: (payload?.userGroups ?? []).filter((item) => isUserGroupRecord(item) && userIds.has(item.userId) && groupIds.has(item.groupId)),
762
- telegramIdentities: (payload?.telegramIdentities ?? []).filter((item) => isTelegramIdentityRecord(item) && userIds.has(item.userId)),
763
- telegramChats: (payload?.telegramChats ?? []).filter(isTelegramChatAccessRecord).map((chat) => ({
764
- ...chat,
765
- allowedGroupIds: chat.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
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
- })),
772
- webSessions: (payload?.webSessions ?? []).filter((item) => isWebSessionRecord(item) && userIds.has(item.userId)),
773
- telegramLinkCodes: (payload?.telegramLinkCodes ?? []).filter((item) => isTelegramLinkCodeRecord(item) && userIds.has(item.userId)),
774
- discordLinkCodes: (payload?.discordLinkCodes ?? []).filter((item) => isDiscordLinkCodeRecord(item) && userIds.has(item.userId)),
775
- };
776
- }
777
- function normalizeEmail(email) {
778
- return email.trim().toLowerCase();
779
- }
780
- function normalizeGroupIds(payload, values, emptyFallback = READONLY_GROUP_ID) {
781
- const available = new Set(payload.groups.map((group) => group.id));
782
- const groupIds = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
783
- for (const groupId of groupIds) {
784
- if (!available.has(groupId)) {
785
- throw new Error(`Unknown group: ${groupId}`);
786
- }
787
- }
788
- return groupIds.length > 0 ? groupIds : (emptyFallback ? [emptyFallback] : []);
789
- }
790
- function normalizePermissions(values, strict = false) {
791
- const permissions = [];
792
- for (const value of values ?? []) {
793
- if (isPermission(value)) {
794
- if (!permissions.includes(value)) {
795
- permissions.push(value);
796
- }
797
- continue;
798
- }
799
- if (strict && value.trim()) {
800
- throw new Error(`Unknown permission: ${value}`);
801
- }
802
- }
803
- return permissions;
804
- }
805
- function normalizeStringList(values) {
806
- return Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)));
807
- }
808
- function normalizeNumberList(values) {
809
- return Array.from(new Set((values ?? []).filter((value) => Number.isInteger(value))));
810
- }
811
- function normalizeDiscordId(value) {
812
- const normalized = String(value ?? "").trim();
813
- return normalized || undefined;
814
- }
815
- function assertActiveAdminExists(payload) {
816
- const hasAdmin = payload.users.some((user) => user.active && payload.userGroups.some((item) => item.userId === user.id && item.groupId === ADMIN_GROUP_ID));
817
- if (!hasAdmin) {
818
- throw new Error("Cannot remove or disable the last active admin user.");
819
- }
820
- }
821
- function normalizeWorkspacePath(value) {
822
- return path.resolve(value);
823
- }
824
- function isPathInside(candidate, root) {
825
- const relative = path.relative(root, candidate);
826
- return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
827
- }
828
- function sleepSync(ms) {
829
- const end = Date.now() + ms;
830
- while (Date.now() < end) {
831
- // The lock is only held around tiny JSON mutations; a short spin keeps the implementation dependency-free.
832
- }
833
- }
834
- function hashPassword(password) {
835
- if (password.length < 8) {
836
- throw new Error("Password must be at least 8 characters.");
837
- }
838
- const salt = randomBytes(16).toString("hex");
839
- const hash = scryptSync(password, salt, PASSWORD_KEYLEN).toString("hex");
840
- return { salt, hash };
841
- }
842
- function verifyPasswordHash(password, salt, expectedHash) {
843
- const actual = scryptSync(password, salt, PASSWORD_KEYLEN);
844
- const expected = Buffer.from(expectedHash, "hex");
845
- return actual.length === expected.length && timingSafeEqual(actual, expected);
846
- }
847
- function hashToken(token) {
848
- return createHash("sha256").update(token).digest("hex");
849
- }
850
- function constantTimeStringEqual(left, right) {
851
- const leftBuffer = Buffer.from(left);
852
- const rightBuffer = Buffer.from(right);
853
- return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
854
- }
855
- function randomId() {
856
- return randomUUID().replace(/-/g, "").slice(0, 12);
857
- }
858
- function randomLinkCode() {
859
- return `NR-${randomBytes(4).toString("hex").toUpperCase()}`;
860
- }
861
- function slugify(value) {
862
- return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
863
- }
864
- function ALL_PERMISSIONS_SAFE() {
865
- return [...BUILTIN_GROUPS.find((group) => group.id === ADMIN_GROUP_ID).permissions];
866
- }
867
- function isUserRecord(value) {
868
- const candidate = value;
869
- return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.email === "string" &&
870
- typeof candidate.displayName === "string" && typeof candidate.passwordHash === "string" &&
871
- typeof candidate.passwordSalt === "string" && typeof candidate.active === "boolean";
872
- }
873
- function isGroupRecord(value) {
874
- const candidate = value;
875
- return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.name === "string" &&
876
- Array.isArray(candidate.permissions);
877
- }
878
- function isUserGroupRecord(value) {
879
- const candidate = value;
880
- return Boolean(candidate) && typeof candidate.userId === "string" && typeof candidate.groupId === "string";
881
- }
882
- function isTelegramIdentityRecord(value) {
883
- const candidate = value;
884
- return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
885
- Number.isInteger(candidate.telegramUserId) && typeof candidate.active === "boolean";
886
- }
887
- function isTelegramChatAccessRecord(value) {
888
- const candidate = value;
889
- return Boolean(candidate) && typeof candidate.id === "string" && Number.isInteger(candidate.chatId) &&
890
- typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
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
- }
902
- function isWebSessionRecord(value) {
903
- const candidate = value;
904
- return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
905
- typeof candidate.tokenHash === "string" && typeof candidate.expiresAt === "string";
906
- }
907
- function isTelegramLinkCodeRecord(value) {
908
- const candidate = value;
909
- return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
910
- typeof candidate.expiresAt === "string";
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
- }
@@ -20,10 +20,13 @@ export const WEB_API_ROUTE_DEFINITIONS = [
20
20
  dynamic("/api/agent-update/:id/input", "^/api/agent-update/[^/]+/input$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/input`),
21
21
  dynamic("/api/agent-update/:id/cancel", "^/api/agent-update/[^/]+/cancel$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/cancel`),
22
22
  exact("/api/adapters/health", ["GET"], "inspect"),
23
+ exact("/api/adapters/conformance", ["GET"], "inspect"),
23
24
  exact("/api/peers", ["GET", "POST"], readWrite("peers.read", "peers.write")),
24
25
  exact("/api/peers/invite", ["POST"], "peers.write"),
25
26
  exact("/api/peers/pair", ["POST"], "peers.write"),
27
+ exact("/api/peers/probe", ["POST"], "peers.connect"),
26
28
  exact("/api/peers/global-sessions", ["GET"], "sessions.read"),
29
+ dynamic("/api/peers/invitations/:id", "^/api/peers/invitations/[^/]+$", ["DELETE"], "peers.write", `/api/peers/invitations/${stringToken}`),
27
30
  dynamic("/api/peers/:id/health", "^/api/peers/[^/]+/health$", ["GET"], "peers.connect", `/api/peers/${stringToken}/health`),
28
31
  dynamic("/api/peers/:id", "^/api/peers/[^/]+$", ["PATCH", "DELETE"], "peers.write", `/api/peers/${stringToken}`),
29
32
  dynamic("/api/peers/:id/proxy", "^/api/peers/[^/]+/proxy$", ["POST"], "peers.connect", `/api/peers/${stringToken}/proxy`),
@@ -38,18 +41,23 @@ export const WEB_API_ROUTE_DEFINITIONS = [
38
41
  dynamic("/api/users/:id/telegram/:identityId", "^/api/users/[^/]+/telegram/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/telegram/${stringToken}`),
39
42
  dynamic("/api/users/:id/discord", "^/api/users/[^/]+/discord$", ["POST"], "users.write", `/api/users/${stringToken}/discord`),
40
43
  dynamic("/api/users/:id/discord/:identityId", "^/api/users/[^/]+/discord/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/discord/${stringToken}`),
44
+ dynamic("/api/users/:id/slack", "^/api/users/[^/]+/slack$", ["POST"], "users.write", `/api/users/${stringToken}/slack`),
45
+ dynamic("/api/users/:id/slack/:identityId", "^/api/users/[^/]+/slack/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/slack/${stringToken}`),
41
46
  exact("/api/groups", ["GET", "POST"], readWrite("users.read", "users.write")),
42
47
  dynamic("/api/groups/:id", "^/api/groups/[^/]+$", ["PATCH"], "users.write", `/api/groups/${stringToken}`),
43
48
  exact("/api/telegram-chats", ["GET", "POST"], readWrite("users.read", "users.write")),
44
49
  dynamic("/api/telegram-chats/:id", "^/api/telegram-chats/[^/]+$", ["PATCH"], "users.write", `/api/telegram-chats/${stringToken}`),
45
50
  exact("/api/discord-channels", ["GET", "POST"], readWrite("users.read", "users.write")),
46
51
  dynamic("/api/discord-channels/:id", "^/api/discord-channels/[^/]+$", ["PATCH"], "users.write", `/api/discord-channels/${stringToken}`),
52
+ exact("/api/slack-channels", ["GET", "POST"], readWrite("users.read", "users.write")),
53
+ dynamic("/api/slack-channels/:id", "^/api/slack-channels/[^/]+$", ["PATCH"], "users.write", `/api/slack-channels/${stringToken}`),
47
54
  exact("/api/audit", ["GET"], "audit.read"),
48
55
  exact("/api/locks", ["GET", "POST", "DELETE"], readWrite("sessions.read", "sessions.write")),
49
56
  exact("/api/auth/status", ["GET"], "inspect"),
50
57
  exact("/api/auth/login", ["POST"], "auth.manage"),
51
58
  exact("/api/auth/logout", ["POST"], "auth.manage"),
52
59
  exact("/api/settings", ["GET", "PATCH"], readWrite("settings.read", "settings.write")),
60
+ exact("/api/settings/wizard/test", ["POST"], "settings.write"),
53
61
  exact("/api/control-options", ["GET"], "settings.read"),
54
62
  exact("/api/sessions", ["GET"], "sessions.read"),
55
63
  exact("/api/sessions/new", ["POST"], "sessions.write"),
@@ -21,6 +21,8 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
21
21
  active: optionalBooleanField(body, "active") ?? true,
22
22
  telegramUserId: optionalNumberField(body, "telegramUserId"),
23
23
  discordUserId: optionalStringField(body, "discordUserId"),
24
+ slackUserId: optionalStringField(body, "slackUserId"),
25
+ slackTeamId: optionalStringField(body, "slackTeamId"),
24
26
  });
25
27
  options.auditUserAction(authUser, "user_created", user.user.email);
26
28
  sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
@@ -121,6 +123,34 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
121
123
  sendJson(res, 200, { removed });
122
124
  return true;
123
125
  }
126
+ const slackLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/slack$/);
127
+ if (slackLinkMatch?.[1] && req.method === "POST") {
128
+ const body = await readJsonBody(req);
129
+ if (body.createCode === true) {
130
+ const userId = decodeURIComponent(slackLinkMatch[1]);
131
+ const linkCode = users.createSlackLinkCode(userId);
132
+ options.auditUserAction(authUser, "slack_link_created", userId);
133
+ sendJson(res, 201, { linkCode });
134
+ return true;
135
+ }
136
+ const identity = users.linkSlackUser(decodeURIComponent(slackLinkMatch[1]), {
137
+ slackUserId: stringField(body, "slackUserId"),
138
+ teamId: optionalStringField(body, "teamId"),
139
+ username: optionalStringField(body, "username"),
140
+ realName: optionalStringField(body, "realName"),
141
+ });
142
+ options.auditUserAction(authUser, "slack_linked", identity.slackUserId);
143
+ sendJson(res, 201, { identity });
144
+ return true;
145
+ }
146
+ const slackUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/slack\/([^/]+)$/);
147
+ if (slackUnlinkMatch?.[1] && req.method === "DELETE") {
148
+ const identityId = decodeURIComponent(slackUnlinkMatch[1]);
149
+ const removed = users.unlinkSlackIdentity(identityId);
150
+ options.auditUserAction(authUser, "slack_unlinked", identityId);
151
+ sendJson(res, 200, { removed });
152
+ return true;
153
+ }
124
154
  if (req.method === "GET" && url.pathname === "/api/groups") {
125
155
  sendJson(res, 200, { groups: users.listGroups() });
126
156
  return true;
@@ -135,6 +165,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
135
165
  workspaceRoots: arrayStringField(body, "workspaceRoots"),
136
166
  telegramChatIds: arrayNumberField(body, "telegramChatIds"),
137
167
  discordChannelIds: arrayStringField(body, "discordChannelIds"),
168
+ slackChannelIds: arrayStringField(body, "slackChannelIds"),
138
169
  });
139
170
  options.auditUserAction(authUser, "group_created", group.id);
140
171
  sendJson(res, 201, { group });
@@ -151,6 +182,7 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
151
182
  workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
152
183
  telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
153
184
  discordChannelIds: body.discordChannelIds === undefined ? undefined : arrayStringField(body, "discordChannelIds"),
185
+ slackChannelIds: body.slackChannelIds === undefined ? undefined : arrayStringField(body, "slackChannelIds"),
154
186
  });
155
187
  options.auditUserAction(authUser, "group_updated", group.id);
156
188
  sendJson(res, 200, { group });
@@ -215,6 +247,36 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
215
247
  sendJson(res, 200, { channel });
216
248
  return true;
217
249
  }
250
+ if (req.method === "GET" && url.pathname === "/api/slack-channels") {
251
+ sendJson(res, 200, { channels: users.snapshot().slackChannels });
252
+ return true;
253
+ }
254
+ if (req.method === "POST" && url.pathname === "/api/slack-channels") {
255
+ const body = await readJsonBody(req);
256
+ const channel = users.registerSlackChannel({
257
+ teamId: optionalStringField(body, "teamId"),
258
+ channelId: stringField(body, "channelId"),
259
+ title: optionalStringField(body, "title"),
260
+ type: optionalStringField(body, "type"),
261
+ enabled: optionalBooleanField(body, "enabled") ?? true,
262
+ allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
263
+ });
264
+ options.auditUserAction(authUser, "slack_channel_updated", channel.channelId);
265
+ sendJson(res, 201, { channel });
266
+ return true;
267
+ }
268
+ const slackChannelMatch = url.pathname.match(/^\/api\/slack-channels\/([^/]+)$/);
269
+ if (slackChannelMatch?.[1] && req.method === "PATCH") {
270
+ const body = await readJsonBody(req);
271
+ const channel = users.updateSlackChannel(decodeURIComponent(slackChannelMatch[1]), {
272
+ enabled: optionalBooleanField(body, "enabled"),
273
+ title: optionalStringField(body, "title"),
274
+ allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
275
+ });
276
+ options.auditUserAction(authUser, "slack_channel_updated", channel.channelId);
277
+ sendJson(res, 200, { channel });
278
+ return true;
279
+ }
218
280
  if (req.method === "GET" && url.pathname === "/api/audit") {
219
281
  sendJson(res, 200, {
220
282
  events: runtime.audit({
@@ -12,6 +12,7 @@ const clientSources = [
12
12
  "client/jobs.js",
13
13
  "client/metrics.js",
14
14
  "client/admin.js",
15
+ "client/settings-wizard.js",
15
16
  ];
16
17
  const styleSources = [
17
18
  "styles/theme.css",