@nordbyte/nordrelay 0.4.0 → 0.5.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 +155 -64
- package/README.md +80 -58
- package/dist/access-control.js +126 -114
- package/dist/agent-feature-matrix.js +42 -0
- package/dist/agent-updates.js +312 -0
- package/dist/bot-rendering.js +838 -0
- package/dist/bot.js +130 -1371
- package/dist/channel-actions.js +372 -0
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +33 -8
- package/dist/relay-runtime.js +159 -31
- package/dist/session-format.js +72 -3
- package/dist/settings-service.js +2 -117
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +54 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-update-commands.js +88 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +56 -0
- package/dist/web-dashboard-assets.js +33 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +649 -369
- package/dist/webui-assets/dashboard.css +919 -0
- package/dist/webui-assets/dashboard.js +1611 -0
- package/package.json +6 -3
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +283 -87
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, rmSync, statSync } from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ADMIN_GROUP_ID, BUILTIN_GROUPS, READONLY_GROUP_ID, USER_GROUP_ID, isPermission, } from "./access-control.js";
|
|
6
|
+
import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
|
|
7
|
+
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
8
|
+
const LINK_CODE_TTL_MS = 15 * 60 * 1000;
|
|
9
|
+
const PASSWORD_KEYLEN = 64;
|
|
10
|
+
const WRITE_LOCK_TIMEOUT_MS = 5_000;
|
|
11
|
+
const STALE_LOCK_MS = 30_000;
|
|
12
|
+
export class UserStore {
|
|
13
|
+
filePath;
|
|
14
|
+
constructor(home = process.env.NORDRELAY_HOME || path.join(os.homedir(), ".nordrelay")) {
|
|
15
|
+
this.filePath = path.join(home, "users.json");
|
|
16
|
+
}
|
|
17
|
+
hasAdminUser() {
|
|
18
|
+
const payload = this.readPayload();
|
|
19
|
+
return payload.users.some((user) => user.active && this.groupIdsForUser(payload, user.id).includes(ADMIN_GROUP_ID));
|
|
20
|
+
}
|
|
21
|
+
snapshot() {
|
|
22
|
+
const payload = this.readPayload();
|
|
23
|
+
return {
|
|
24
|
+
users: payload.users.map((user) => ({
|
|
25
|
+
...user,
|
|
26
|
+
groups: this.groupsForUser(payload, user.id),
|
|
27
|
+
telegramIdentities: payload.telegramIdentities.filter((identity) => identity.userId === user.id),
|
|
28
|
+
webSessions: payload.webSessions
|
|
29
|
+
.filter((session) => session.userId === user.id)
|
|
30
|
+
.map(publicWebSession),
|
|
31
|
+
})),
|
|
32
|
+
groups: payload.groups,
|
|
33
|
+
telegramChats: payload.telegramChats,
|
|
34
|
+
adminConfigured: payload.users.some((user) => user.active && this.groupIdsForUser(payload, user.id).includes(ADMIN_GROUP_ID)),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
listGroups() {
|
|
38
|
+
return this.readPayload().groups;
|
|
39
|
+
}
|
|
40
|
+
listWebSessions(userId) {
|
|
41
|
+
const payload = this.readPayload();
|
|
42
|
+
return payload.webSessions
|
|
43
|
+
.filter((session) => !userId || session.userId === userId)
|
|
44
|
+
.map(publicWebSession);
|
|
45
|
+
}
|
|
46
|
+
getUser(id) {
|
|
47
|
+
const payload = this.readPayload();
|
|
48
|
+
const user = payload.users.find((candidate) => candidate.id === id);
|
|
49
|
+
return user ? this.authenticatedUser(payload, user) : null;
|
|
50
|
+
}
|
|
51
|
+
getUserByEmail(email) {
|
|
52
|
+
const payload = this.readPayload();
|
|
53
|
+
const normalized = normalizeEmail(email);
|
|
54
|
+
const user = payload.users.find((candidate) => candidate.email === normalized);
|
|
55
|
+
return user ? this.authenticatedUser(payload, user) : null;
|
|
56
|
+
}
|
|
57
|
+
createUser(input) {
|
|
58
|
+
return this.mutatePayload((payload) => {
|
|
59
|
+
const email = normalizeEmail(input.email);
|
|
60
|
+
if (!email) {
|
|
61
|
+
throw new Error("Email is required.");
|
|
62
|
+
}
|
|
63
|
+
if (payload.users.some((user) => user.email === email)) {
|
|
64
|
+
throw new Error(`User already exists: ${email}`);
|
|
65
|
+
}
|
|
66
|
+
const now = new Date().toISOString();
|
|
67
|
+
const password = hashPassword(input.password);
|
|
68
|
+
const user = {
|
|
69
|
+
id: randomId(),
|
|
70
|
+
email,
|
|
71
|
+
displayName: input.displayName.trim() || email,
|
|
72
|
+
passwordHash: password.hash,
|
|
73
|
+
passwordSalt: password.salt,
|
|
74
|
+
active: input.active ?? true,
|
|
75
|
+
createdAt: now,
|
|
76
|
+
updatedAt: now,
|
|
77
|
+
};
|
|
78
|
+
const groupIds = normalizeGroupIds(payload, input.groupIds?.length ? input.groupIds : [USER_GROUP_ID]);
|
|
79
|
+
payload.users.push(user);
|
|
80
|
+
payload.userGroups.push(...groupIds.map((groupId) => ({ userId: user.id, groupId })));
|
|
81
|
+
if (input.telegramUserId !== undefined) {
|
|
82
|
+
this.upsertTelegramIdentityInPayload(payload, user.id, {
|
|
83
|
+
telegramUserId: input.telegramUserId,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return this.authenticatedUser(payload, user);
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
createAdmin(input) {
|
|
90
|
+
return this.createUser({
|
|
91
|
+
...input,
|
|
92
|
+
groupIds: [ADMIN_GROUP_ID],
|
|
93
|
+
active: true,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
updateUser(id, patch) {
|
|
97
|
+
return this.mutatePayload((payload) => {
|
|
98
|
+
const user = payload.users.find((candidate) => candidate.id === id);
|
|
99
|
+
if (!user) {
|
|
100
|
+
throw new Error("User not found.");
|
|
101
|
+
}
|
|
102
|
+
const shouldRevokeSessions = patch.active === false || patch.groupIds !== undefined;
|
|
103
|
+
if (patch.email !== undefined) {
|
|
104
|
+
const email = normalizeEmail(patch.email);
|
|
105
|
+
if (!email) {
|
|
106
|
+
throw new Error("Email is required.");
|
|
107
|
+
}
|
|
108
|
+
if (payload.users.some((candidate) => candidate.id !== id && candidate.email === email)) {
|
|
109
|
+
throw new Error(`User already exists: ${email}`);
|
|
110
|
+
}
|
|
111
|
+
user.email = email;
|
|
112
|
+
}
|
|
113
|
+
if (patch.displayName !== undefined) {
|
|
114
|
+
user.displayName = patch.displayName.trim() || user.email;
|
|
115
|
+
}
|
|
116
|
+
if (patch.active !== undefined) {
|
|
117
|
+
user.active = patch.active;
|
|
118
|
+
}
|
|
119
|
+
if (patch.groupIds !== undefined) {
|
|
120
|
+
const groupIds = normalizeGroupIds(payload, patch.groupIds);
|
|
121
|
+
payload.userGroups = payload.userGroups.filter((item) => item.userId !== id);
|
|
122
|
+
payload.userGroups.push(...groupIds.map((groupId) => ({ userId: id, groupId })));
|
|
123
|
+
}
|
|
124
|
+
assertActiveAdminExists(payload);
|
|
125
|
+
if (shouldRevokeSessions) {
|
|
126
|
+
this.revokeUserSessionsInPayload(payload, id);
|
|
127
|
+
}
|
|
128
|
+
user.updatedAt = new Date().toISOString();
|
|
129
|
+
return this.authenticatedUser(payload, user);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
setPassword(id, password) {
|
|
133
|
+
this.mutatePayload((payload) => {
|
|
134
|
+
const user = payload.users.find((candidate) => candidate.id === id);
|
|
135
|
+
if (!user) {
|
|
136
|
+
throw new Error("User not found.");
|
|
137
|
+
}
|
|
138
|
+
const next = hashPassword(password);
|
|
139
|
+
user.passwordHash = next.hash;
|
|
140
|
+
user.passwordSalt = next.salt;
|
|
141
|
+
user.updatedAt = new Date().toISOString();
|
|
142
|
+
this.revokeUserSessionsInPayload(payload, id);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
verifyPassword(email, password) {
|
|
146
|
+
return this.mutatePayload((payload) => {
|
|
147
|
+
const user = payload.users.find((candidate) => candidate.email === normalizeEmail(email));
|
|
148
|
+
if (!user || !user.active || !verifyPasswordHash(password, user.passwordSalt, user.passwordHash)) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
user.lastLoginAt = new Date().toISOString();
|
|
152
|
+
user.updatedAt = user.lastLoginAt;
|
|
153
|
+
return this.authenticatedUser(payload, user);
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
createWebSession(userId) {
|
|
157
|
+
return this.mutatePayload((payload) => {
|
|
158
|
+
const user = payload.users.find((candidate) => candidate.id === userId && candidate.active);
|
|
159
|
+
if (!user) {
|
|
160
|
+
throw new Error("Active user not found.");
|
|
161
|
+
}
|
|
162
|
+
const token = randomBytes(32).toString("hex");
|
|
163
|
+
const now = new Date();
|
|
164
|
+
const session = {
|
|
165
|
+
id: randomId(),
|
|
166
|
+
userId,
|
|
167
|
+
tokenHash: hashToken(token),
|
|
168
|
+
createdAt: now.toISOString(),
|
|
169
|
+
expiresAt: new Date(now.getTime() + SESSION_TTL_MS).toISOString(),
|
|
170
|
+
lastSeenAt: now.toISOString(),
|
|
171
|
+
};
|
|
172
|
+
payload.webSessions.push(session);
|
|
173
|
+
this.pruneExpiredSessionsInPayload(payload);
|
|
174
|
+
return { token, session };
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
resolveWebSession(token) {
|
|
178
|
+
if (!token) {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
return this.mutatePayload((payload) => {
|
|
182
|
+
this.pruneExpiredSessionsInPayload(payload);
|
|
183
|
+
const tokenHash = hashToken(token);
|
|
184
|
+
const session = payload.webSessions.find((candidate) => constantTimeStringEqual(candidate.tokenHash, tokenHash));
|
|
185
|
+
if (!session) {
|
|
186
|
+
return null;
|
|
187
|
+
}
|
|
188
|
+
const user = payload.users.find((candidate) => candidate.id === session.userId && candidate.active);
|
|
189
|
+
if (!user) {
|
|
190
|
+
payload.webSessions = payload.webSessions.filter((candidate) => candidate.id !== session.id);
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
session.lastSeenAt = new Date().toISOString();
|
|
194
|
+
return this.authenticatedUser(payload, user);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
destroyWebSession(token) {
|
|
198
|
+
if (!token) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
this.mutatePayload((payload) => {
|
|
202
|
+
const tokenHash = hashToken(token);
|
|
203
|
+
payload.webSessions = payload.webSessions.filter((session) => !constantTimeStringEqual(session.tokenHash, tokenHash));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
revokeWebSession(sessionId) {
|
|
207
|
+
return this.mutatePayload((payload) => {
|
|
208
|
+
const before = payload.webSessions.length;
|
|
209
|
+
payload.webSessions = payload.webSessions.filter((session) => session.id !== sessionId);
|
|
210
|
+
return payload.webSessions.length !== before;
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
revokeUserSessions(userId) {
|
|
214
|
+
return this.mutatePayload((payload) => {
|
|
215
|
+
const before = payload.webSessions.length;
|
|
216
|
+
this.revokeUserSessionsInPayload(payload, userId);
|
|
217
|
+
return before - payload.webSessions.length;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
resolveTelegramUser(telegramUserId) {
|
|
221
|
+
if (telegramUserId === undefined) {
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
const payload = this.readPayload();
|
|
225
|
+
const identity = payload.telegramIdentities.find((candidate) => candidate.telegramUserId === telegramUserId && candidate.active);
|
|
226
|
+
if (!identity) {
|
|
227
|
+
return null;
|
|
228
|
+
}
|
|
229
|
+
const user = payload.users.find((candidate) => candidate.id === identity.userId && candidate.active);
|
|
230
|
+
return user ? this.authenticatedUser(payload, user) : null;
|
|
231
|
+
}
|
|
232
|
+
linkTelegramUser(userId, input) {
|
|
233
|
+
return this.mutatePayload((payload) => {
|
|
234
|
+
const user = payload.users.find((candidate) => candidate.id === userId);
|
|
235
|
+
if (!user) {
|
|
236
|
+
throw new Error("User not found.");
|
|
237
|
+
}
|
|
238
|
+
return this.upsertTelegramIdentityInPayload(payload, userId, input);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
unlinkTelegramIdentity(identityId) {
|
|
242
|
+
return this.mutatePayload((payload) => {
|
|
243
|
+
const before = payload.telegramIdentities.length;
|
|
244
|
+
payload.telegramIdentities = payload.telegramIdentities.filter((identity) => identity.id !== identityId);
|
|
245
|
+
return payload.telegramIdentities.length !== before;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
createTelegramLinkCode(userId) {
|
|
249
|
+
return this.mutatePayload((payload) => {
|
|
250
|
+
if (!payload.users.some((user) => user.id === userId && user.active)) {
|
|
251
|
+
throw new Error("Active user not found.");
|
|
252
|
+
}
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
payload.telegramLinkCodes = payload.telegramLinkCodes.filter((code) => new Date(code.expiresAt).getTime() > now);
|
|
255
|
+
const code = {
|
|
256
|
+
code: randomLinkCode(),
|
|
257
|
+
userId,
|
|
258
|
+
createdAt: new Date(now).toISOString(),
|
|
259
|
+
expiresAt: new Date(now + LINK_CODE_TTL_MS).toISOString(),
|
|
260
|
+
};
|
|
261
|
+
payload.telegramLinkCodes.push(code);
|
|
262
|
+
return code;
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
consumeTelegramLinkCode(code, input) {
|
|
266
|
+
return this.mutatePayload((payload) => {
|
|
267
|
+
const normalized = code.trim().toUpperCase();
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const link = payload.telegramLinkCodes.find((candidate) => candidate.code === normalized && new Date(candidate.expiresAt).getTime() > now);
|
|
270
|
+
if (!link) {
|
|
271
|
+
throw new Error("Invalid or expired link code.");
|
|
272
|
+
}
|
|
273
|
+
const user = payload.users.find((candidate) => candidate.id === link.userId && candidate.active);
|
|
274
|
+
if (!user) {
|
|
275
|
+
throw new Error("Linked user is not active.");
|
|
276
|
+
}
|
|
277
|
+
this.upsertTelegramIdentityInPayload(payload, user.id, input);
|
|
278
|
+
payload.telegramLinkCodes = payload.telegramLinkCodes.filter((candidate) => candidate.code !== normalized);
|
|
279
|
+
return this.authenticatedUser(payload, user);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
registerTelegramChat(input) {
|
|
283
|
+
return this.mutatePayload((payload) => {
|
|
284
|
+
const now = new Date().toISOString();
|
|
285
|
+
const existing = payload.telegramChats.find((chat) => chat.chatId === input.chatId);
|
|
286
|
+
const allowedGroupIds = normalizeGroupIds(payload, input.allowedGroupIds ?? [], null);
|
|
287
|
+
if (existing) {
|
|
288
|
+
existing.title = input.title ?? existing.title;
|
|
289
|
+
existing.type = input.type ?? existing.type;
|
|
290
|
+
existing.enabled = input.enabled ?? existing.enabled;
|
|
291
|
+
existing.allowedGroupIds = allowedGroupIds;
|
|
292
|
+
existing.updatedAt = now;
|
|
293
|
+
return existing;
|
|
294
|
+
}
|
|
295
|
+
const chat = {
|
|
296
|
+
id: randomId(),
|
|
297
|
+
chatId: input.chatId,
|
|
298
|
+
title: input.title,
|
|
299
|
+
type: input.type,
|
|
300
|
+
enabled: input.enabled ?? true,
|
|
301
|
+
allowedGroupIds,
|
|
302
|
+
createdAt: now,
|
|
303
|
+
updatedAt: now,
|
|
304
|
+
};
|
|
305
|
+
payload.telegramChats.push(chat);
|
|
306
|
+
return chat;
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
updateTelegramChat(id, patch) {
|
|
310
|
+
return this.mutatePayload((payload) => {
|
|
311
|
+
const chat = payload.telegramChats.find((candidate) => candidate.id === id);
|
|
312
|
+
if (!chat) {
|
|
313
|
+
throw new Error("Telegram chat not found.");
|
|
314
|
+
}
|
|
315
|
+
if (patch.enabled !== undefined)
|
|
316
|
+
chat.enabled = patch.enabled;
|
|
317
|
+
if (patch.title !== undefined)
|
|
318
|
+
chat.title = patch.title;
|
|
319
|
+
if (patch.allowedGroupIds !== undefined)
|
|
320
|
+
chat.allowedGroupIds = normalizeGroupIds(payload, patch.allowedGroupIds, null);
|
|
321
|
+
chat.updatedAt = new Date().toISOString();
|
|
322
|
+
return chat;
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
isTelegramChatAllowed(chatId, chatType, user) {
|
|
326
|
+
if (chatId === undefined) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
if (chatType === "private") {
|
|
330
|
+
return this.canUseTelegramChat(user, chatId);
|
|
331
|
+
}
|
|
332
|
+
const payload = this.readPayload();
|
|
333
|
+
const access = payload.telegramChats.find((chat) => chat.chatId === chatId);
|
|
334
|
+
if (!access?.enabled) {
|
|
335
|
+
return false;
|
|
336
|
+
}
|
|
337
|
+
if (access.allowedGroupIds.length === 0) {
|
|
338
|
+
return this.canUseTelegramChat(user, chatId);
|
|
339
|
+
}
|
|
340
|
+
const userGroupIds = new Set(user.groups.map((group) => group.id));
|
|
341
|
+
return access.allowedGroupIds.some((groupId) => userGroupIds.has(groupId)) && this.canUseTelegramChat(user, chatId);
|
|
342
|
+
}
|
|
343
|
+
hasPermission(user, permission) {
|
|
344
|
+
return Boolean(permission && user?.permissions.includes(permission));
|
|
345
|
+
}
|
|
346
|
+
canUseAgent(user, agentId) {
|
|
347
|
+
if (!user || !agentId) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
return user.groups.some((group) => group.agentIds.length === 0 || group.agentIds.includes(agentId));
|
|
351
|
+
}
|
|
352
|
+
canUseWorkspace(user, workspace) {
|
|
353
|
+
if (!user || !workspace) {
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
const normalizedWorkspace = normalizeWorkspacePath(workspace);
|
|
357
|
+
return user.groups.some((group) => group.workspaceRoots.length === 0 ||
|
|
358
|
+
group.workspaceRoots.some((root) => isPathInside(normalizedWorkspace, normalizeWorkspacePath(root))));
|
|
359
|
+
}
|
|
360
|
+
canUseTelegramChat(user, chatId) {
|
|
361
|
+
if (!user || chatId === undefined) {
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
return user.groups.some((group) => group.telegramChatIds.length === 0 || group.telegramChatIds.includes(chatId));
|
|
365
|
+
}
|
|
366
|
+
createGroup(input) {
|
|
367
|
+
return this.mutatePayload((payload) => {
|
|
368
|
+
const now = new Date().toISOString();
|
|
369
|
+
const id = slugify(input.name);
|
|
370
|
+
if (!id) {
|
|
371
|
+
throw new Error("Group name is required.");
|
|
372
|
+
}
|
|
373
|
+
if (payload.groups.some((group) => group.id === id)) {
|
|
374
|
+
throw new Error(`Group already exists: ${id}`);
|
|
375
|
+
}
|
|
376
|
+
const group = {
|
|
377
|
+
id,
|
|
378
|
+
name: input.name.trim(),
|
|
379
|
+
description: input.description?.trim() ?? "",
|
|
380
|
+
permissions: normalizePermissions(input.permissions ?? [], true),
|
|
381
|
+
system: false,
|
|
382
|
+
agentIds: normalizeStringList(input.agentIds ?? []),
|
|
383
|
+
workspaceRoots: normalizeStringList(input.workspaceRoots ?? []),
|
|
384
|
+
telegramChatIds: normalizeNumberList(input.telegramChatIds ?? []),
|
|
385
|
+
createdAt: now,
|
|
386
|
+
updatedAt: now,
|
|
387
|
+
};
|
|
388
|
+
payload.groups.push(group);
|
|
389
|
+
return group;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
updateGroup(id, patch) {
|
|
393
|
+
return this.mutatePayload((payload) => {
|
|
394
|
+
const group = payload.groups.find((candidate) => candidate.id === id);
|
|
395
|
+
if (!group) {
|
|
396
|
+
throw new Error("Group not found.");
|
|
397
|
+
}
|
|
398
|
+
if (group.system && id === ADMIN_GROUP_ID && patch.permissions) {
|
|
399
|
+
group.permissions = ALL_PERMISSIONS_SAFE();
|
|
400
|
+
}
|
|
401
|
+
else if (patch.permissions !== undefined) {
|
|
402
|
+
group.permissions = normalizePermissions(patch.permissions, true);
|
|
403
|
+
}
|
|
404
|
+
if (!group.system && patch.name !== undefined)
|
|
405
|
+
group.name = patch.name.trim() || group.name;
|
|
406
|
+
if (patch.description !== undefined)
|
|
407
|
+
group.description = patch.description.trim();
|
|
408
|
+
if (patch.agentIds !== undefined)
|
|
409
|
+
group.agentIds = normalizeStringList(patch.agentIds);
|
|
410
|
+
if (patch.workspaceRoots !== undefined)
|
|
411
|
+
group.workspaceRoots = normalizeStringList(patch.workspaceRoots);
|
|
412
|
+
if (patch.telegramChatIds !== undefined)
|
|
413
|
+
group.telegramChatIds = normalizeNumberList(patch.telegramChatIds);
|
|
414
|
+
group.updatedAt = new Date().toISOString();
|
|
415
|
+
return group;
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
authenticatedUser(payload, user) {
|
|
419
|
+
const groups = this.groupsForUser(payload, user.id);
|
|
420
|
+
const permissions = Array.from(new Set(groups.flatMap((group) => group.permissions)));
|
|
421
|
+
return { user, groups, permissions };
|
|
422
|
+
}
|
|
423
|
+
groupIdsForUser(payload, userId) {
|
|
424
|
+
return payload.userGroups.filter((item) => item.userId === userId).map((item) => item.groupId);
|
|
425
|
+
}
|
|
426
|
+
groupsForUser(payload, userId) {
|
|
427
|
+
const groupIds = new Set(this.groupIdsForUser(payload, userId));
|
|
428
|
+
return payload.groups.filter((group) => groupIds.has(group.id));
|
|
429
|
+
}
|
|
430
|
+
upsertTelegramIdentityInPayload(payload, userId, input) {
|
|
431
|
+
if (!Number.isInteger(input.telegramUserId) || input.telegramUserId <= 0) {
|
|
432
|
+
throw new Error("Telegram user id must be a positive integer.");
|
|
433
|
+
}
|
|
434
|
+
const now = new Date().toISOString();
|
|
435
|
+
for (const identity of payload.telegramIdentities) {
|
|
436
|
+
if (identity.telegramUserId === input.telegramUserId && identity.userId !== userId) {
|
|
437
|
+
identity.active = false;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const existing = payload.telegramIdentities.find((identity) => identity.userId === userId && identity.telegramUserId === input.telegramUserId);
|
|
441
|
+
if (existing) {
|
|
442
|
+
existing.username = input.username ?? existing.username;
|
|
443
|
+
existing.firstName = input.firstName ?? existing.firstName;
|
|
444
|
+
existing.lastName = input.lastName ?? existing.lastName;
|
|
445
|
+
existing.active = true;
|
|
446
|
+
existing.updatedAt = now;
|
|
447
|
+
return existing;
|
|
448
|
+
}
|
|
449
|
+
const identity = {
|
|
450
|
+
id: randomId(),
|
|
451
|
+
userId,
|
|
452
|
+
telegramUserId: input.telegramUserId,
|
|
453
|
+
username: input.username,
|
|
454
|
+
firstName: input.firstName,
|
|
455
|
+
lastName: input.lastName,
|
|
456
|
+
active: true,
|
|
457
|
+
linkedAt: now,
|
|
458
|
+
updatedAt: now,
|
|
459
|
+
};
|
|
460
|
+
payload.telegramIdentities.push(identity);
|
|
461
|
+
return identity;
|
|
462
|
+
}
|
|
463
|
+
pruneExpiredSessionsInPayload(payload) {
|
|
464
|
+
const now = Date.now();
|
|
465
|
+
payload.webSessions = payload.webSessions.filter((session) => new Date(session.expiresAt).getTime() > now);
|
|
466
|
+
}
|
|
467
|
+
revokeUserSessionsInPayload(payload, userId) {
|
|
468
|
+
payload.webSessions = payload.webSessions.filter((session) => session.userId !== userId);
|
|
469
|
+
}
|
|
470
|
+
mutatePayload(updater) {
|
|
471
|
+
return this.withWriteLock(() => {
|
|
472
|
+
const payload = this.readPayload();
|
|
473
|
+
const result = updater(payload);
|
|
474
|
+
this.writePayload(payload);
|
|
475
|
+
return result;
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
withWriteLock(operation) {
|
|
479
|
+
const lockPath = `${this.filePath}.lock`;
|
|
480
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
481
|
+
const deadline = Date.now() + WRITE_LOCK_TIMEOUT_MS;
|
|
482
|
+
let fd;
|
|
483
|
+
for (;;) {
|
|
484
|
+
try {
|
|
485
|
+
fd = openSync(lockPath, "wx");
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
catch (error) {
|
|
489
|
+
const code = error.code;
|
|
490
|
+
if (code !== "EEXIST") {
|
|
491
|
+
throw error;
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const stat = statSync(lockPath);
|
|
495
|
+
if (Date.now() - stat.mtimeMs > STALE_LOCK_MS) {
|
|
496
|
+
rmSync(lockPath, { force: true });
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (Date.now() > deadline) {
|
|
504
|
+
throw new Error("User store is busy. Try again shortly.");
|
|
505
|
+
}
|
|
506
|
+
sleepSync(25);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
try {
|
|
510
|
+
return operation();
|
|
511
|
+
}
|
|
512
|
+
finally {
|
|
513
|
+
if (fd !== undefined) {
|
|
514
|
+
closeSync(fd);
|
|
515
|
+
}
|
|
516
|
+
rmSync(lockPath, { force: true });
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
readPayload() {
|
|
520
|
+
const payload = readJsonFileWithBackup(this.filePath).value;
|
|
521
|
+
return normalizePayload(payload);
|
|
522
|
+
}
|
|
523
|
+
writePayload(payload) {
|
|
524
|
+
writeJsonFileAtomic(this.filePath, normalizePayload(payload));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
export function publicUser(user) {
|
|
528
|
+
const { passwordHash: _passwordHash, passwordSalt: _passwordSalt, ...rest } = user;
|
|
529
|
+
return rest;
|
|
530
|
+
}
|
|
531
|
+
export function publicWebSession(session) {
|
|
532
|
+
const { tokenHash: _tokenHash, ...rest } = session;
|
|
533
|
+
return rest;
|
|
534
|
+
}
|
|
535
|
+
export function publicUserSnapshot(snapshot) {
|
|
536
|
+
return {
|
|
537
|
+
...snapshot,
|
|
538
|
+
users: snapshot.users.map((user) => {
|
|
539
|
+
const { passwordHash: _passwordHash, passwordSalt: _passwordSalt, ...rest } = user;
|
|
540
|
+
return rest;
|
|
541
|
+
}),
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function normalizePayload(payload) {
|
|
545
|
+
const now = new Date().toISOString();
|
|
546
|
+
const groupsById = new Map();
|
|
547
|
+
for (const group of BUILTIN_GROUPS) {
|
|
548
|
+
groupsById.set(group.id, {
|
|
549
|
+
...group,
|
|
550
|
+
permissions: group.id === ADMIN_GROUP_ID ? ALL_PERMISSIONS_SAFE() : group.permissions,
|
|
551
|
+
agentIds: [],
|
|
552
|
+
workspaceRoots: [],
|
|
553
|
+
telegramChatIds: [],
|
|
554
|
+
createdAt: now,
|
|
555
|
+
updatedAt: now,
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
for (const group of payload?.groups ?? []) {
|
|
559
|
+
if (!isGroupRecord(group))
|
|
560
|
+
continue;
|
|
561
|
+
groupsById.set(group.id, {
|
|
562
|
+
...group,
|
|
563
|
+
permissions: group.id === ADMIN_GROUP_ID ? ALL_PERMISSIONS_SAFE() : normalizePermissions(group.permissions),
|
|
564
|
+
system: BUILTIN_GROUPS.some((builtin) => builtin.id === group.id) || group.system,
|
|
565
|
+
agentIds: normalizeStringList(group.agentIds),
|
|
566
|
+
workspaceRoots: normalizeStringList(group.workspaceRoots),
|
|
567
|
+
telegramChatIds: normalizeNumberList(group.telegramChatIds),
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
const groups = Array.from(groupsById.values());
|
|
571
|
+
const groupIds = new Set(groups.map((group) => group.id));
|
|
572
|
+
const users = (payload?.users ?? []).filter(isUserRecord);
|
|
573
|
+
const userIds = new Set(users.map((user) => user.id));
|
|
574
|
+
return {
|
|
575
|
+
version: 1,
|
|
576
|
+
users,
|
|
577
|
+
groups,
|
|
578
|
+
userGroups: (payload?.userGroups ?? []).filter((item) => isUserGroupRecord(item) && userIds.has(item.userId) && groupIds.has(item.groupId)),
|
|
579
|
+
telegramIdentities: (payload?.telegramIdentities ?? []).filter((item) => isTelegramIdentityRecord(item) && userIds.has(item.userId)),
|
|
580
|
+
telegramChats: (payload?.telegramChats ?? []).filter(isTelegramChatAccessRecord).map((chat) => ({
|
|
581
|
+
...chat,
|
|
582
|
+
allowedGroupIds: chat.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
|
|
583
|
+
})),
|
|
584
|
+
webSessions: (payload?.webSessions ?? []).filter((item) => isWebSessionRecord(item) && userIds.has(item.userId)),
|
|
585
|
+
telegramLinkCodes: (payload?.telegramLinkCodes ?? []).filter((item) => isTelegramLinkCodeRecord(item) && userIds.has(item.userId)),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
function normalizeEmail(email) {
|
|
589
|
+
return email.trim().toLowerCase();
|
|
590
|
+
}
|
|
591
|
+
function normalizeGroupIds(payload, values, emptyFallback = READONLY_GROUP_ID) {
|
|
592
|
+
const available = new Set(payload.groups.map((group) => group.id));
|
|
593
|
+
const groupIds = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
594
|
+
for (const groupId of groupIds) {
|
|
595
|
+
if (!available.has(groupId)) {
|
|
596
|
+
throw new Error(`Unknown group: ${groupId}`);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
return groupIds.length > 0 ? groupIds : (emptyFallback ? [emptyFallback] : []);
|
|
600
|
+
}
|
|
601
|
+
function normalizePermissions(values, strict = false) {
|
|
602
|
+
const permissions = [];
|
|
603
|
+
for (const value of values ?? []) {
|
|
604
|
+
if (isPermission(value)) {
|
|
605
|
+
if (!permissions.includes(value)) {
|
|
606
|
+
permissions.push(value);
|
|
607
|
+
}
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (strict && value.trim()) {
|
|
611
|
+
throw new Error(`Unknown permission: ${value}`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return permissions;
|
|
615
|
+
}
|
|
616
|
+
function normalizeStringList(values) {
|
|
617
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)));
|
|
618
|
+
}
|
|
619
|
+
function normalizeNumberList(values) {
|
|
620
|
+
return Array.from(new Set((values ?? []).filter((value) => Number.isInteger(value))));
|
|
621
|
+
}
|
|
622
|
+
function assertActiveAdminExists(payload) {
|
|
623
|
+
const hasAdmin = payload.users.some((user) => user.active && payload.userGroups.some((item) => item.userId === user.id && item.groupId === ADMIN_GROUP_ID));
|
|
624
|
+
if (!hasAdmin) {
|
|
625
|
+
throw new Error("Cannot remove or disable the last active admin user.");
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
function normalizeWorkspacePath(value) {
|
|
629
|
+
return path.resolve(value);
|
|
630
|
+
}
|
|
631
|
+
function isPathInside(candidate, root) {
|
|
632
|
+
const relative = path.relative(root, candidate);
|
|
633
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
634
|
+
}
|
|
635
|
+
function sleepSync(ms) {
|
|
636
|
+
const end = Date.now() + ms;
|
|
637
|
+
while (Date.now() < end) {
|
|
638
|
+
// The lock is only held around tiny JSON mutations; a short spin keeps the implementation dependency-free.
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
function hashPassword(password) {
|
|
642
|
+
if (password.length < 8) {
|
|
643
|
+
throw new Error("Password must be at least 8 characters.");
|
|
644
|
+
}
|
|
645
|
+
const salt = randomBytes(16).toString("hex");
|
|
646
|
+
const hash = scryptSync(password, salt, PASSWORD_KEYLEN).toString("hex");
|
|
647
|
+
return { salt, hash };
|
|
648
|
+
}
|
|
649
|
+
function verifyPasswordHash(password, salt, expectedHash) {
|
|
650
|
+
const actual = scryptSync(password, salt, PASSWORD_KEYLEN);
|
|
651
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
652
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
653
|
+
}
|
|
654
|
+
function hashToken(token) {
|
|
655
|
+
return createHash("sha256").update(token).digest("hex");
|
|
656
|
+
}
|
|
657
|
+
function constantTimeStringEqual(left, right) {
|
|
658
|
+
const leftBuffer = Buffer.from(left);
|
|
659
|
+
const rightBuffer = Buffer.from(right);
|
|
660
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
661
|
+
}
|
|
662
|
+
function randomId() {
|
|
663
|
+
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
664
|
+
}
|
|
665
|
+
function randomLinkCode() {
|
|
666
|
+
return `NR-${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
667
|
+
}
|
|
668
|
+
function slugify(value) {
|
|
669
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
670
|
+
}
|
|
671
|
+
function ALL_PERMISSIONS_SAFE() {
|
|
672
|
+
return [...BUILTIN_GROUPS.find((group) => group.id === ADMIN_GROUP_ID).permissions];
|
|
673
|
+
}
|
|
674
|
+
function isUserRecord(value) {
|
|
675
|
+
const candidate = value;
|
|
676
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.email === "string" &&
|
|
677
|
+
typeof candidate.displayName === "string" && typeof candidate.passwordHash === "string" &&
|
|
678
|
+
typeof candidate.passwordSalt === "string" && typeof candidate.active === "boolean";
|
|
679
|
+
}
|
|
680
|
+
function isGroupRecord(value) {
|
|
681
|
+
const candidate = value;
|
|
682
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.name === "string" &&
|
|
683
|
+
Array.isArray(candidate.permissions);
|
|
684
|
+
}
|
|
685
|
+
function isUserGroupRecord(value) {
|
|
686
|
+
const candidate = value;
|
|
687
|
+
return Boolean(candidate) && typeof candidate.userId === "string" && typeof candidate.groupId === "string";
|
|
688
|
+
}
|
|
689
|
+
function isTelegramIdentityRecord(value) {
|
|
690
|
+
const candidate = value;
|
|
691
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
692
|
+
Number.isInteger(candidate.telegramUserId) && typeof candidate.active === "boolean";
|
|
693
|
+
}
|
|
694
|
+
function isTelegramChatAccessRecord(value) {
|
|
695
|
+
const candidate = value;
|
|
696
|
+
return Boolean(candidate) && typeof candidate.id === "string" && Number.isInteger(candidate.chatId) &&
|
|
697
|
+
typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
|
|
698
|
+
}
|
|
699
|
+
function isWebSessionRecord(value) {
|
|
700
|
+
const candidate = value;
|
|
701
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
702
|
+
typeof candidate.tokenHash === "string" && typeof candidate.expiresAt === "string";
|
|
703
|
+
}
|
|
704
|
+
function isTelegramLinkCodeRecord(value) {
|
|
705
|
+
const candidate = value;
|
|
706
|
+
return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
|
|
707
|
+
typeof candidate.expiresAt === "string";
|
|
708
|
+
}
|