@nordbyte/nordrelay 0.7.0 → 0.8.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/.env.example +35 -0
- package/README.md +109 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +33 -1
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-store.js +13 -0
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +544 -144
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +101 -10
package/dist/user-management.js
CHANGED
|
@@ -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,
|
|
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 =
|
|
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
|
-
}
|
package/dist/web-api-contract.js
CHANGED
|
@@ -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({
|