@nordbyte/nordrelay 0.4.1 → 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 +69 -59
- package/dist/access-control.js +124 -115
- package/dist/agent-updates.js +19 -1
- package/dist/bot-rendering.js +838 -0
- package/dist/bot.js +87 -1288
- 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/relay-runtime.js +36 -12
- 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 -2
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +595 -133
- 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 +227 -78
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
- package/dist/web-dashboard-client.js +0 -275
- package/dist/web-dashboard-style.js +0 -9
package/dist/web-dashboard.js
CHANGED
|
@@ -6,28 +6,31 @@ import { URL } from "node:url";
|
|
|
6
6
|
import { enabledAgents } from "./agent-factory.js";
|
|
7
7
|
import { listAgentAdapterDescriptors } from "./agent-adapter.js";
|
|
8
8
|
import { isAgentId } from "./agent.js";
|
|
9
|
+
import { AuditLogStore } from "./audit-log.js";
|
|
9
10
|
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
11
|
+
import { ALL_PERMISSIONS, permissionForWebRequest } from "./access-control.js";
|
|
10
12
|
import { loadConfig } from "./config.js";
|
|
11
13
|
import { friendlyErrorText } from "./error-messages.js";
|
|
12
14
|
import { escapeHTML } from "./format.js";
|
|
13
15
|
import { RelayRuntime } from "./relay-runtime.js";
|
|
14
16
|
import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
|
|
15
|
-
import {
|
|
16
|
-
import { dashboardCss } from "./web-dashboard-
|
|
17
|
+
import { UserStore, publicUser, publicUserSnapshot } from "./user-management.js";
|
|
18
|
+
import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
|
|
17
19
|
import { renderDashboardNav } from "./web-dashboard-ui.js";
|
|
18
20
|
const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
|
|
19
21
|
const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
20
22
|
const options = parseOptions(process.argv.slice(2));
|
|
21
|
-
const auth = resolveDashboardAuth(options.host);
|
|
22
|
-
if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
|
|
23
|
-
throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
|
|
24
|
-
}
|
|
25
23
|
const config = loadConfig();
|
|
26
24
|
const runtime = new RelayRuntime(config);
|
|
27
25
|
const settings = new SettingsService(resolveDashboardEnvPath(options.home));
|
|
26
|
+
const users = new UserStore(options.home);
|
|
27
|
+
const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
|
|
28
|
+
const loginAttempts = new Map();
|
|
29
|
+
class AccessDeniedError extends Error {
|
|
30
|
+
}
|
|
28
31
|
const server = createServer((req, res) => {
|
|
29
32
|
void handleRequest(req, res).catch((error) => {
|
|
30
|
-
sendJson(res, 500, { error: friendlyErrorText(error) });
|
|
33
|
+
sendJson(res, error instanceof AccessDeniedError ? 403 : 500, { error: friendlyErrorText(error) });
|
|
31
34
|
});
|
|
32
35
|
});
|
|
33
36
|
await new Promise((resolve) => server.listen(options.port, options.host, resolve));
|
|
@@ -36,35 +39,41 @@ process.once("SIGINT", () => shutdown());
|
|
|
36
39
|
process.once("SIGTERM", () => shutdown());
|
|
37
40
|
async function handleRequest(req, res) {
|
|
38
41
|
const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
|
|
39
|
-
const queryToken = url.searchParams.get("token");
|
|
40
|
-
if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
|
|
41
|
-
setAuthCookie(res, queryToken);
|
|
42
|
-
res.writeHead(302, { location: url.pathname || "/" });
|
|
43
|
-
res.end();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
42
|
if (url.pathname === "/api/auth" && req.method === "POST") {
|
|
47
43
|
await handleLogin(req, res);
|
|
48
44
|
return;
|
|
49
45
|
}
|
|
50
|
-
if (
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
if (url.pathname === "/api/dashboard/logout" && req.method === "POST") {
|
|
47
|
+
handleLogout(req, res);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const authenticated = authenticateRequest(req);
|
|
51
|
+
if (url.pathname === "/api/auth/me" && req.method === "GET") {
|
|
52
|
+
if (!authenticated) {
|
|
53
|
+
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
53
54
|
return;
|
|
54
55
|
}
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
sendJson(res, 200, currentUserDto(authenticated));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (!authenticated) {
|
|
60
|
+
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
61
|
+
sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
|
|
57
62
|
return;
|
|
58
63
|
}
|
|
59
|
-
|
|
64
|
+
sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
|
|
60
65
|
return;
|
|
61
66
|
}
|
|
62
67
|
if (url.pathname === "/healthz") {
|
|
68
|
+
if (!users.hasPermission(authenticated, "inspect")) {
|
|
69
|
+
sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
63
72
|
sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
|
|
64
73
|
return;
|
|
65
74
|
}
|
|
66
75
|
if (url.pathname === "/" || url.pathname === "/index.html") {
|
|
67
|
-
sendText(res, 200, renderDashboardApp(
|
|
76
|
+
sendText(res, 200, renderDashboardApp(), "text/html; charset=utf-8");
|
|
68
77
|
return;
|
|
69
78
|
}
|
|
70
79
|
if (url.pathname === "/assets/dashboard.css") {
|
|
@@ -76,32 +85,63 @@ async function handleRequest(req, res) {
|
|
|
76
85
|
return;
|
|
77
86
|
}
|
|
78
87
|
if (url.pathname === "/api/events" && req.method === "GET") {
|
|
79
|
-
handleEvents(req, res);
|
|
88
|
+
await handleEvents(req, res);
|
|
80
89
|
return;
|
|
81
90
|
}
|
|
82
91
|
if (!url.pathname.startsWith("/api/")) {
|
|
83
92
|
sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
|
|
84
93
|
return;
|
|
85
94
|
}
|
|
86
|
-
await handleApi(req, res, url);
|
|
95
|
+
await handleApi(req, res, url, authenticated);
|
|
87
96
|
}
|
|
88
|
-
async function handleApi(req, res, url) {
|
|
97
|
+
async function handleApi(req, res, url, authUser) {
|
|
98
|
+
const permission = permissionForWebRequest(req.method, url.pathname);
|
|
99
|
+
if (!permission) {
|
|
100
|
+
audit({
|
|
101
|
+
action: "permission_denied",
|
|
102
|
+
status: "denied",
|
|
103
|
+
channelId: "web",
|
|
104
|
+
contextKey: "web",
|
|
105
|
+
actorId: authUser.user.id,
|
|
106
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
107
|
+
description: `Denied unknown endpoint ${req.method ?? "GET"} ${url.pathname}`,
|
|
108
|
+
});
|
|
109
|
+
sendJson(res, 403, { error: "Access denied." });
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (!users.hasPermission(authUser, permission)) {
|
|
113
|
+
audit({
|
|
114
|
+
action: "permission_denied",
|
|
115
|
+
status: "denied",
|
|
116
|
+
channelId: "web",
|
|
117
|
+
contextKey: "web",
|
|
118
|
+
actorId: authUser.user.id,
|
|
119
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
120
|
+
description: `${permission} required for ${req.method ?? "GET"} ${url.pathname}`,
|
|
121
|
+
});
|
|
122
|
+
sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
89
125
|
if (req.method === "GET" && url.pathname === "/api/bootstrap") {
|
|
126
|
+
await assertCurrentSessionScope(authUser);
|
|
90
127
|
sendJson(res, 200, {
|
|
91
|
-
auth:
|
|
128
|
+
auth: currentUserDto(authUser),
|
|
92
129
|
channels: listChannelDescriptors(),
|
|
93
|
-
agentAdapters: listAgentAdapterDescriptors(),
|
|
94
|
-
enabledAgents: enabledAgents(config),
|
|
95
|
-
controls: await runtime.controlOptions(),
|
|
130
|
+
agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
|
|
131
|
+
enabledAgents: enabledAgents(config).filter((agentId) => users.canUseAgent(authUser, agentId)),
|
|
132
|
+
controls: scopedControlOptions(authUser, await runtime.controlOptions()),
|
|
96
133
|
status: await runtime.bootstrapStatus(),
|
|
97
134
|
});
|
|
98
135
|
return;
|
|
99
136
|
}
|
|
100
137
|
if (req.method === "GET" && url.pathname === "/api/control-options") {
|
|
101
|
-
|
|
138
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
139
|
+
assertScopedAgent(authUser, agentId);
|
|
140
|
+
sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
|
|
102
141
|
return;
|
|
103
142
|
}
|
|
104
143
|
if (req.method === "GET" && url.pathname === "/api/health") {
|
|
144
|
+
await assertCurrentSessionScope(authUser);
|
|
105
145
|
sendJson(res, 200, await runtime.status());
|
|
106
146
|
return;
|
|
107
147
|
}
|
|
@@ -114,40 +154,202 @@ async function handleApi(req, res, url) {
|
|
|
114
154
|
return;
|
|
115
155
|
}
|
|
116
156
|
if (req.method === "GET" && url.pathname === "/api/agent-updates") {
|
|
117
|
-
sendJson(res, 200, { jobs: runtime.agentUpdateJobs() });
|
|
157
|
+
sendJson(res, 200, { jobs: runtime.agentUpdateJobs().filter((job) => users.canUseAgent(authUser, job.agentId)) });
|
|
118
158
|
return;
|
|
119
159
|
}
|
|
120
160
|
if (req.method === "POST" && url.pathname === "/api/agent-update") {
|
|
121
161
|
const body = await readJsonBody(req);
|
|
122
|
-
|
|
162
|
+
const agentId = parseAgentIdRequired(stringField(body, "agentId"));
|
|
163
|
+
assertScopedAgent(authUser, agentId);
|
|
164
|
+
sendJson(res, 202, { job: runtime.startAgentUpdate(agentId) });
|
|
123
165
|
return;
|
|
124
166
|
}
|
|
125
167
|
const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
|
|
126
168
|
if (req.method === "GET" && agentUpdateLogMatch?.[1]) {
|
|
127
|
-
|
|
169
|
+
const id = decodeURIComponent(agentUpdateLogMatch[1]);
|
|
170
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
171
|
+
sendJson(res, 200, runtime.agentUpdateLog(id));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (req.method === "DELETE" && agentUpdateLogMatch?.[1]) {
|
|
175
|
+
const id = decodeURIComponent(agentUpdateLogMatch[1]);
|
|
176
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
177
|
+
sendJson(res, 200, { deletedId: id, job: runtime.deleteAgentUpdateLog(id) });
|
|
128
178
|
return;
|
|
129
179
|
}
|
|
130
180
|
const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
|
|
131
181
|
if (req.method === "POST" && agentUpdateInputMatch?.[1]) {
|
|
132
182
|
const body = await readJsonBody(req);
|
|
133
|
-
|
|
183
|
+
const id = decodeURIComponent(agentUpdateInputMatch[1]);
|
|
184
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
185
|
+
sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
|
|
134
186
|
return;
|
|
135
187
|
}
|
|
136
188
|
const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
|
|
137
189
|
if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
|
|
138
|
-
|
|
190
|
+
const id = decodeURIComponent(agentUpdateCancelMatch[1]);
|
|
191
|
+
assertAgentUpdateJobScope(authUser, id);
|
|
192
|
+
sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
|
|
139
193
|
return;
|
|
140
194
|
}
|
|
141
195
|
if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
|
|
142
|
-
sendJson(res, 200, runtime.tasks());
|
|
196
|
+
sendJson(res, 200, await scopedTasks(authUser, runtime.tasks()));
|
|
143
197
|
return;
|
|
144
198
|
}
|
|
145
199
|
if (req.method === "GET" && url.pathname === "/api/adapters/health") {
|
|
146
|
-
sendJson(res, 200, { adapters: await runtime.adapterHealth() });
|
|
200
|
+
sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
|
|
147
201
|
return;
|
|
148
202
|
}
|
|
149
203
|
if (req.method === "GET" && url.pathname === "/api/permissions") {
|
|
150
|
-
sendJson(res, 200,
|
|
204
|
+
sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (req.method === "GET" && url.pathname === "/api/users") {
|
|
208
|
+
sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (req.method === "POST" && url.pathname === "/api/users") {
|
|
212
|
+
const body = await readJsonBody(req);
|
|
213
|
+
const user = users.createUser({
|
|
214
|
+
email: stringField(body, "email"),
|
|
215
|
+
displayName: optionalStringField(body, "displayName") ?? stringField(body, "email"),
|
|
216
|
+
password: stringField(body, "password"),
|
|
217
|
+
groupIds: arrayStringField(body, "groupIds"),
|
|
218
|
+
active: optionalBooleanField(body, "active") ?? true,
|
|
219
|
+
telegramUserId: optionalNumberField(body, "telegramUserId"),
|
|
220
|
+
});
|
|
221
|
+
auditUserAction(authUser, "user_created", user.user.email);
|
|
222
|
+
sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const userMatch = url.pathname.match(/^\/api\/users\/([^/]+)$/);
|
|
226
|
+
if (userMatch?.[1] && req.method === "PATCH") {
|
|
227
|
+
const body = await readJsonBody(req);
|
|
228
|
+
const user = users.updateUser(decodeURIComponent(userMatch[1]), {
|
|
229
|
+
email: optionalStringField(body, "email"),
|
|
230
|
+
displayName: optionalStringField(body, "displayName"),
|
|
231
|
+
active: optionalBooleanField(body, "active"),
|
|
232
|
+
groupIds: body.groupIds === undefined ? undefined : arrayStringField(body, "groupIds"),
|
|
233
|
+
});
|
|
234
|
+
auditUserAction(authUser, "user_updated", user.user.email);
|
|
235
|
+
sendJson(res, 200, { user: publicUser(user.user), groups: user.groups });
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
const passwordMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/password$/);
|
|
239
|
+
if (passwordMatch?.[1] && req.method === "POST") {
|
|
240
|
+
const body = await readJsonBody(req);
|
|
241
|
+
const userId = decodeURIComponent(passwordMatch[1]);
|
|
242
|
+
users.setPassword(userId, stringField(body, "password"));
|
|
243
|
+
auditUserAction(authUser, "user_password_changed", userId);
|
|
244
|
+
sendJson(res, 200, { ok: true });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const userSessionsMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/sessions$/);
|
|
248
|
+
if (userSessionsMatch?.[1] && req.method === "GET") {
|
|
249
|
+
sendJson(res, 200, { sessions: users.listWebSessions(decodeURIComponent(userSessionsMatch[1])) });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (userSessionsMatch?.[1] && req.method === "DELETE") {
|
|
253
|
+
const userId = decodeURIComponent(userSessionsMatch[1]);
|
|
254
|
+
const revoked = users.revokeUserSessions(userId);
|
|
255
|
+
auditUserAction(authUser, "user_session_revoked", `${userId}: ${revoked} sessions`);
|
|
256
|
+
sendJson(res, 200, { revoked });
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const userSessionMatch = url.pathname.match(/^\/api\/users\/[^/]+\/sessions\/([^/]+)$/);
|
|
260
|
+
if (userSessionMatch?.[1] && req.method === "DELETE") {
|
|
261
|
+
const sessionId = decodeURIComponent(userSessionMatch[1]);
|
|
262
|
+
const revoked = users.revokeWebSession(sessionId);
|
|
263
|
+
auditUserAction(authUser, "user_session_revoked", sessionId);
|
|
264
|
+
sendJson(res, 200, { revoked });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
const telegramLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/telegram$/);
|
|
268
|
+
if (telegramLinkMatch?.[1] && req.method === "POST") {
|
|
269
|
+
const body = await readJsonBody(req);
|
|
270
|
+
if (body.createCode === true) {
|
|
271
|
+
const userId = decodeURIComponent(telegramLinkMatch[1]);
|
|
272
|
+
const linkCode = users.createTelegramLinkCode(userId);
|
|
273
|
+
auditUserAction(authUser, "telegram_link_created", userId);
|
|
274
|
+
sendJson(res, 201, { linkCode });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const identity = users.linkTelegramUser(decodeURIComponent(telegramLinkMatch[1]), {
|
|
278
|
+
telegramUserId: numberField(body, "telegramUserId"),
|
|
279
|
+
username: optionalStringField(body, "username"),
|
|
280
|
+
});
|
|
281
|
+
auditUserAction(authUser, "telegram_linked", String(identity.telegramUserId));
|
|
282
|
+
sendJson(res, 201, { identity });
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
const telegramUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/telegram\/([^/]+)$/);
|
|
286
|
+
if (telegramUnlinkMatch?.[1] && req.method === "DELETE") {
|
|
287
|
+
const identityId = decodeURIComponent(telegramUnlinkMatch[1]);
|
|
288
|
+
const removed = users.unlinkTelegramIdentity(identityId);
|
|
289
|
+
auditUserAction(authUser, "telegram_unlinked", identityId);
|
|
290
|
+
sendJson(res, 200, { removed });
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (req.method === "GET" && url.pathname === "/api/groups") {
|
|
294
|
+
sendJson(res, 200, { groups: users.listGroups() });
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (req.method === "POST" && url.pathname === "/api/groups") {
|
|
298
|
+
const body = await readJsonBody(req);
|
|
299
|
+
const group = users.createGroup({
|
|
300
|
+
name: stringField(body, "name"),
|
|
301
|
+
description: optionalStringField(body, "description"),
|
|
302
|
+
permissions: arrayStringField(body, "permissions"),
|
|
303
|
+
agentIds: arrayStringField(body, "agentIds"),
|
|
304
|
+
workspaceRoots: arrayStringField(body, "workspaceRoots"),
|
|
305
|
+
telegramChatIds: arrayNumberField(body, "telegramChatIds"),
|
|
306
|
+
});
|
|
307
|
+
auditUserAction(authUser, "group_created", group.id);
|
|
308
|
+
sendJson(res, 201, { group });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const groupMatch = url.pathname.match(/^\/api\/groups\/([^/]+)$/);
|
|
312
|
+
if (groupMatch?.[1] && req.method === "PATCH") {
|
|
313
|
+
const body = await readJsonBody(req);
|
|
314
|
+
const group = users.updateGroup(decodeURIComponent(groupMatch[1]), {
|
|
315
|
+
name: optionalStringField(body, "name"),
|
|
316
|
+
description: optionalStringField(body, "description"),
|
|
317
|
+
permissions: body.permissions === undefined ? undefined : arrayStringField(body, "permissions"),
|
|
318
|
+
agentIds: body.agentIds === undefined ? undefined : arrayStringField(body, "agentIds"),
|
|
319
|
+
workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
|
|
320
|
+
telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
|
|
321
|
+
});
|
|
322
|
+
auditUserAction(authUser, "group_updated", group.id);
|
|
323
|
+
sendJson(res, 200, { group });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === "GET" && url.pathname === "/api/telegram-chats") {
|
|
327
|
+
sendJson(res, 200, { chats: users.snapshot().telegramChats });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (req.method === "POST" && url.pathname === "/api/telegram-chats") {
|
|
331
|
+
const body = await readJsonBody(req);
|
|
332
|
+
const chat = users.registerTelegramChat({
|
|
333
|
+
chatId: numberField(body, "chatId"),
|
|
334
|
+
title: optionalStringField(body, "title"),
|
|
335
|
+
type: optionalStringField(body, "type"),
|
|
336
|
+
enabled: optionalBooleanField(body, "enabled") ?? true,
|
|
337
|
+
allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
|
|
338
|
+
});
|
|
339
|
+
auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
|
|
340
|
+
sendJson(res, 201, { chat });
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const chatMatch = url.pathname.match(/^\/api\/telegram-chats\/([^/]+)$/);
|
|
344
|
+
if (chatMatch?.[1] && req.method === "PATCH") {
|
|
345
|
+
const body = await readJsonBody(req);
|
|
346
|
+
const chat = users.updateTelegramChat(decodeURIComponent(chatMatch[1]), {
|
|
347
|
+
enabled: optionalBooleanField(body, "enabled"),
|
|
348
|
+
title: optionalStringField(body, "title"),
|
|
349
|
+
allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
|
|
350
|
+
});
|
|
351
|
+
auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
|
|
352
|
+
sendJson(res, 200, { chat });
|
|
151
353
|
return;
|
|
152
354
|
}
|
|
153
355
|
if (req.method === "GET" && url.pathname === "/api/audit") {
|
|
@@ -155,30 +357,39 @@ async function handleApi(req, res, url) {
|
|
|
155
357
|
return;
|
|
156
358
|
}
|
|
157
359
|
if (req.method === "GET" && url.pathname === "/api/locks") {
|
|
360
|
+
await assertCurrentSessionScope(authUser);
|
|
158
361
|
sendJson(res, 200, { locks: runtime.locks() });
|
|
159
362
|
return;
|
|
160
363
|
}
|
|
161
364
|
if (req.method === "POST" && url.pathname === "/api/locks") {
|
|
162
365
|
const body = await readJsonBody(req);
|
|
366
|
+
await assertCurrentSessionScope(authUser);
|
|
163
367
|
sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
|
|
164
368
|
return;
|
|
165
369
|
}
|
|
166
370
|
if (req.method === "DELETE" && url.pathname === "/api/locks") {
|
|
371
|
+
await assertCurrentSessionScope(authUser);
|
|
167
372
|
sendJson(res, 200, runtime.unlockWebSession());
|
|
168
373
|
return;
|
|
169
374
|
}
|
|
170
375
|
if (req.method === "GET" && url.pathname === "/api/auth/status") {
|
|
171
|
-
|
|
376
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
377
|
+
assertScopedAgent(authUser, agentId);
|
|
378
|
+
sendJson(res, 200, await runtime.authStatus(agentId));
|
|
172
379
|
return;
|
|
173
380
|
}
|
|
174
381
|
if (req.method === "POST" && url.pathname === "/api/auth/login") {
|
|
175
382
|
const body = await readJsonBody(req);
|
|
176
|
-
|
|
383
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
384
|
+
assertScopedAgent(authUser, agentId);
|
|
385
|
+
sendJson(res, 200, await runtime.login(agentId));
|
|
177
386
|
return;
|
|
178
387
|
}
|
|
179
388
|
if (req.method === "POST" && url.pathname === "/api/auth/logout") {
|
|
180
389
|
const body = await readJsonBody(req);
|
|
181
|
-
|
|
390
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
391
|
+
assertScopedAgent(authUser, agentId);
|
|
392
|
+
sendJson(res, 200, await runtime.logout(agentId));
|
|
182
393
|
return;
|
|
183
394
|
}
|
|
184
395
|
if (req.method === "GET" && url.pathname === "/api/settings") {
|
|
@@ -191,11 +402,20 @@ async function handleApi(req, res, url) {
|
|
|
191
402
|
return;
|
|
192
403
|
}
|
|
193
404
|
if (req.method === "GET" && url.pathname === "/api/snapshot") {
|
|
405
|
+
await assertCurrentSessionScope(authUser);
|
|
194
406
|
sendJson(res, 200, await runtime.snapshot());
|
|
195
407
|
return;
|
|
196
408
|
}
|
|
197
409
|
if (req.method === "GET" && url.pathname === "/api/sessions") {
|
|
198
|
-
|
|
410
|
+
const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
|
|
411
|
+
if (agentId) {
|
|
412
|
+
assertScopedAgent(authUser, agentId);
|
|
413
|
+
}
|
|
414
|
+
else {
|
|
415
|
+
await assertCurrentSessionScope(authUser);
|
|
416
|
+
}
|
|
417
|
+
const page = await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? "", agentId);
|
|
418
|
+
sendJson(res, 200, scopedSessionPage(authUser, page));
|
|
199
419
|
return;
|
|
200
420
|
}
|
|
201
421
|
if (req.method === "POST" && url.pathname === "/api/agent") {
|
|
@@ -204,15 +424,20 @@ async function handleApi(req, res, url) {
|
|
|
204
424
|
if (!isAgentId(agentId)) {
|
|
205
425
|
throw new Error(`Invalid agent: ${agentId}`);
|
|
206
426
|
}
|
|
427
|
+
assertScopedAgent(authUser, agentId);
|
|
207
428
|
sendJson(res, 200, { session: await runtime.setAgent(agentId) });
|
|
208
429
|
return;
|
|
209
430
|
}
|
|
210
431
|
if (req.method === "POST" && url.pathname === "/api/sessions/new") {
|
|
211
432
|
const body = await readJsonBody(req);
|
|
433
|
+
const agentId = parseAgentId(optionalStringField(body, "agentId"));
|
|
434
|
+
const workspace = optionalStringField(body, "workspace");
|
|
435
|
+
assertScopedAgent(authUser, agentId);
|
|
436
|
+
assertScopedWorkspace(authUser, workspace);
|
|
212
437
|
sendJson(res, 200, {
|
|
213
438
|
session: await runtime.newSession({
|
|
214
|
-
agentId
|
|
215
|
-
workspace
|
|
439
|
+
agentId,
|
|
440
|
+
workspace,
|
|
216
441
|
model: optionalStringField(body, "model"),
|
|
217
442
|
reasoningEffort: optionalStringField(body, "reasoningEffort"),
|
|
218
443
|
launchProfileId: optionalStringField(body, "launchProfileId"),
|
|
@@ -223,49 +448,68 @@ async function handleApi(req, res, url) {
|
|
|
223
448
|
}
|
|
224
449
|
if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
|
|
225
450
|
const body = await readJsonBody(req);
|
|
226
|
-
|
|
451
|
+
const threadId = stringField(body, "threadId");
|
|
452
|
+
const detail = await runtime.sessionDetail(threadId);
|
|
453
|
+
if (detail.record && typeof detail.record === "object") {
|
|
454
|
+
assertSessionScope(authUser, detail.record);
|
|
455
|
+
}
|
|
456
|
+
const session = await runtime.switchSession(threadId);
|
|
457
|
+
assertSessionScope(authUser, session);
|
|
458
|
+
sendJson(res, 200, { session });
|
|
227
459
|
return;
|
|
228
460
|
}
|
|
229
461
|
if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
|
|
230
462
|
const body = await readJsonBody(req);
|
|
231
|
-
|
|
463
|
+
const session = await runtime.attachSession(stringField(body, "threadId"));
|
|
464
|
+
assertSessionScope(authUser, session);
|
|
465
|
+
sendJson(res, 200, { session });
|
|
232
466
|
return;
|
|
233
467
|
}
|
|
234
468
|
if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
|
|
235
|
-
|
|
469
|
+
const threadId = requiredSearch(url, "threadId");
|
|
470
|
+
const detail = await runtime.sessionDetail(threadId);
|
|
471
|
+
assertSessionDetailScope(authUser, threadId, detail);
|
|
472
|
+
sendJson(res, 200, detail);
|
|
236
473
|
return;
|
|
237
474
|
}
|
|
238
475
|
if (req.method === "GET" && url.pathname === "/api/models") {
|
|
476
|
+
await assertCurrentSessionScope(authUser);
|
|
239
477
|
sendJson(res, 200, { models: await runtime.listModels() });
|
|
240
478
|
return;
|
|
241
479
|
}
|
|
242
480
|
if (req.method === "POST" && url.pathname === "/api/session/model") {
|
|
243
481
|
const body = await readJsonBody(req);
|
|
482
|
+
await assertCurrentSessionScope(authUser);
|
|
244
483
|
sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
|
|
245
484
|
return;
|
|
246
485
|
}
|
|
247
486
|
if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
|
|
248
487
|
const body = await readJsonBody(req);
|
|
488
|
+
await assertCurrentSessionScope(authUser);
|
|
249
489
|
sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
|
|
250
490
|
return;
|
|
251
491
|
}
|
|
252
492
|
if (req.method === "POST" && url.pathname === "/api/session/fast") {
|
|
253
493
|
const body = await readJsonBody(req);
|
|
494
|
+
await assertCurrentSessionScope(authUser);
|
|
254
495
|
sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
|
|
255
496
|
return;
|
|
256
497
|
}
|
|
257
498
|
if (req.method === "POST" && url.pathname === "/api/session/launch") {
|
|
258
499
|
const body = await readJsonBody(req);
|
|
500
|
+
await assertCurrentSessionScope(authUser);
|
|
259
501
|
sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
|
|
260
502
|
return;
|
|
261
503
|
}
|
|
262
504
|
if (req.method === "POST" && url.pathname === "/api/prompt") {
|
|
263
505
|
const body = await readJsonBody(req);
|
|
506
|
+
await assertCurrentSessionScope(authUser);
|
|
264
507
|
sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
|
|
265
508
|
return;
|
|
266
509
|
}
|
|
267
510
|
if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
|
|
268
511
|
const body = await readJsonBody(req);
|
|
512
|
+
await assertCurrentSessionScope(authUser);
|
|
269
513
|
sendJson(res, 202, await runtime.sendUploadPrompt({
|
|
270
514
|
text: optionalStringField(body, "text"),
|
|
271
515
|
files: parseUploadFiles(body.files),
|
|
@@ -273,59 +517,70 @@ async function handleApi(req, res, url) {
|
|
|
273
517
|
return;
|
|
274
518
|
}
|
|
275
519
|
if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
|
|
520
|
+
await assertCurrentSessionScope(authUser);
|
|
276
521
|
await runtime.abort();
|
|
277
522
|
sendJson(res, 200, { ok: true });
|
|
278
523
|
return;
|
|
279
524
|
}
|
|
280
525
|
if (req.method === "POST" && url.pathname === "/api/handback") {
|
|
526
|
+
await assertCurrentSessionScope(authUser);
|
|
281
527
|
sendJson(res, 200, await runtime.handback());
|
|
282
528
|
return;
|
|
283
529
|
}
|
|
284
530
|
if (req.method === "POST" && url.pathname === "/api/retry") {
|
|
531
|
+
await assertCurrentSessionScope(authUser);
|
|
285
532
|
sendJson(res, 202, await runtime.retry());
|
|
286
533
|
return;
|
|
287
534
|
}
|
|
288
535
|
if (req.method === "POST" && url.pathname === "/api/sync") {
|
|
536
|
+
await assertCurrentSessionScope(authUser);
|
|
289
537
|
sendJson(res, 200, await runtime.sync());
|
|
290
538
|
return;
|
|
291
539
|
}
|
|
292
540
|
if (req.method === "GET" && url.pathname === "/api/queue") {
|
|
541
|
+
await assertCurrentSessionScope(authUser);
|
|
293
542
|
sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
|
|
294
543
|
return;
|
|
295
544
|
}
|
|
296
545
|
if (req.method === "POST" && url.pathname === "/api/queue") {
|
|
297
546
|
const body = await readJsonBody(req);
|
|
547
|
+
await assertCurrentSessionScope(authUser);
|
|
298
548
|
sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
|
|
299
549
|
return;
|
|
300
550
|
}
|
|
301
551
|
if (req.method === "GET" && url.pathname === "/api/chat/history") {
|
|
552
|
+
await assertCurrentSessionScope(authUser);
|
|
302
553
|
sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
|
|
303
554
|
return;
|
|
304
555
|
}
|
|
305
556
|
if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
|
|
557
|
+
await assertCurrentSessionScope(authUser);
|
|
306
558
|
sendJson(res, 200, await runtime.clearChatHistory());
|
|
307
559
|
return;
|
|
308
560
|
}
|
|
309
561
|
if (req.method === "GET" && url.pathname === "/api/activity") {
|
|
310
562
|
sendJson(res, 200, {
|
|
311
|
-
events: runtime.activity({
|
|
563
|
+
events: filterActivityByScope(authUser, runtime.activity({
|
|
312
564
|
limit: numberParam(url, "limit", 100),
|
|
313
565
|
source: (url.searchParams.get("source") || "all"),
|
|
314
566
|
status: (url.searchParams.get("status") || "all"),
|
|
315
|
-
}),
|
|
567
|
+
})),
|
|
316
568
|
});
|
|
317
569
|
return;
|
|
318
570
|
}
|
|
319
571
|
if (req.method === "GET" && url.pathname === "/api/artifacts") {
|
|
572
|
+
await assertCurrentSessionScope(authUser);
|
|
320
573
|
sendJson(res, 200, { reports: await runtime.artifacts() });
|
|
321
574
|
return;
|
|
322
575
|
}
|
|
323
576
|
if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
|
|
577
|
+
await assertCurrentSessionScope(authUser);
|
|
324
578
|
sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
|
|
325
579
|
return;
|
|
326
580
|
}
|
|
327
581
|
if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
|
|
328
582
|
const body = await readJsonBody(req);
|
|
583
|
+
await assertCurrentSessionScope(authUser);
|
|
329
584
|
const action = stringField(body, "action");
|
|
330
585
|
const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
|
|
331
586
|
if (action !== "delete") {
|
|
@@ -341,6 +596,7 @@ async function handleApi(req, res, url) {
|
|
|
341
596
|
return;
|
|
342
597
|
}
|
|
343
598
|
if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
|
|
599
|
+
await assertCurrentSessionScope(authUser);
|
|
344
600
|
const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
|
|
345
601
|
if (!bundle) {
|
|
346
602
|
sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
|
|
@@ -350,6 +606,7 @@ async function handleApi(req, res, url) {
|
|
|
350
606
|
return;
|
|
351
607
|
}
|
|
352
608
|
if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
|
|
609
|
+
await assertCurrentSessionScope(authUser);
|
|
353
610
|
const turnId = requiredSearch(url, "turnId");
|
|
354
611
|
const relativePath = requiredSearch(url, "path");
|
|
355
612
|
const report = await runtime.artifact(turnId);
|
|
@@ -362,6 +619,7 @@ async function handleApi(req, res, url) {
|
|
|
362
619
|
return;
|
|
363
620
|
}
|
|
364
621
|
if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
|
|
622
|
+
await assertCurrentSessionScope(authUser);
|
|
365
623
|
const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
|
|
366
624
|
if (!preview) {
|
|
367
625
|
sendJson(res, 404, { error: "Artifact not found" });
|
|
@@ -380,6 +638,7 @@ async function handleApi(req, res, url) {
|
|
|
380
638
|
return;
|
|
381
639
|
}
|
|
382
640
|
if (req.method === "GET" && url.pathname === "/api/diagnostics") {
|
|
641
|
+
await assertCurrentSessionScope(authUser);
|
|
383
642
|
sendJson(res, 200, await runtime.diagnostics());
|
|
384
643
|
return;
|
|
385
644
|
}
|
|
@@ -389,21 +648,40 @@ async function handleApi(req, res, url) {
|
|
|
389
648
|
}
|
|
390
649
|
sendJson(res, 404, { error: "Unknown endpoint" });
|
|
391
650
|
}
|
|
392
|
-
function handleEvents(req, res) {
|
|
393
|
-
const
|
|
394
|
-
|
|
395
|
-
if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
|
|
651
|
+
async function handleEvents(req, res) {
|
|
652
|
+
const authUser = authenticateRequest(req);
|
|
653
|
+
if (!authUser) {
|
|
396
654
|
sendJson(res, 401, { error: "Authentication required" });
|
|
397
655
|
return;
|
|
398
656
|
}
|
|
657
|
+
if (!users.hasPermission(authUser, "sessions.read")) {
|
|
658
|
+
sendJson(res, 403, { error: "Access denied: sessions.read permission required." });
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
await assertCurrentSessionScope(authUser);
|
|
399
662
|
res.writeHead(200, {
|
|
400
663
|
"content-type": "text/event-stream; charset=utf-8",
|
|
401
664
|
"cache-control": "no-cache, no-transform",
|
|
402
665
|
connection: "keep-alive",
|
|
403
666
|
});
|
|
404
667
|
const send = (event) => {
|
|
405
|
-
|
|
406
|
-
|
|
668
|
+
void scopeRelayEvent(authUser, event, canUseCurrentSession).then((scopedEvent) => {
|
|
669
|
+
if (!scopedEvent || res.destroyed || res.writableEnded) {
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
res.write(`event: ${scopedEvent.type}\n`);
|
|
673
|
+
res.write(`data: ${JSON.stringify(scopedEvent)}\n\n`);
|
|
674
|
+
}).catch(() => { });
|
|
675
|
+
};
|
|
676
|
+
let currentScopeCache = null;
|
|
677
|
+
const canUseCurrentSession = async () => {
|
|
678
|
+
const now = Date.now();
|
|
679
|
+
if (currentScopeCache && currentScopeCache.expiresAt > now) {
|
|
680
|
+
return currentScopeCache.allowed;
|
|
681
|
+
}
|
|
682
|
+
const allowed = await canUseCurrentSessionScope(authUser);
|
|
683
|
+
currentScopeCache = { allowed, expiresAt: now + 1_000 };
|
|
684
|
+
return allowed;
|
|
407
685
|
};
|
|
408
686
|
const unsubscribe = runtime.subscribe(send);
|
|
409
687
|
const heartbeat = setInterval(() => {
|
|
@@ -417,20 +695,60 @@ function handleEvents(req, res) {
|
|
|
417
695
|
}
|
|
418
696
|
async function handleLogin(req, res) {
|
|
419
697
|
const body = await readJsonBody(req);
|
|
420
|
-
const
|
|
421
|
-
const user = optionalStringField(body, "user");
|
|
698
|
+
const email = optionalStringField(body, "email");
|
|
422
699
|
const password = optionalStringField(body, "password");
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
700
|
+
const rateLimitKey = `${req.socket.remoteAddress ?? "unknown"}:${email ?? "-"}`;
|
|
701
|
+
const limited = consumeRateLimit(loginAttempts, rateLimitKey, 5, 15 * 60 * 1000, 15 * 60 * 1000);
|
|
702
|
+
if (limited.limited) {
|
|
703
|
+
audit({
|
|
704
|
+
action: "auth_login_failed",
|
|
705
|
+
status: "denied",
|
|
706
|
+
channelId: "web",
|
|
707
|
+
contextKey: "web",
|
|
708
|
+
description: `Rate limited login attempt for ${email ?? "unknown"}`,
|
|
709
|
+
detail: `${Math.ceil((limited.retryAfterMs ?? 0) / 1000)}s retry-after`,
|
|
710
|
+
});
|
|
711
|
+
sendJson(res, 429, { error: "Too many login attempts. Try again later.", retryAfterMs: limited.retryAfterMs });
|
|
426
712
|
return;
|
|
427
713
|
}
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
sendJson(res, 200, { ok: true, mode: "basic" });
|
|
714
|
+
if (!users.hasAdminUser()) {
|
|
715
|
+
sendJson(res, 503, { error: "No admin user exists. Run nordrelay user create-admin first." });
|
|
431
716
|
return;
|
|
432
717
|
}
|
|
433
|
-
|
|
718
|
+
const authUser = email && password ? users.verifyPassword(email, password) : null;
|
|
719
|
+
if (!authUser) {
|
|
720
|
+
audit({
|
|
721
|
+
action: "auth_login_failed",
|
|
722
|
+
status: "failed",
|
|
723
|
+
channelId: "web",
|
|
724
|
+
contextKey: "web",
|
|
725
|
+
description: `Failed login for ${email ?? "unknown"}`,
|
|
726
|
+
});
|
|
727
|
+
sendJson(res, 401, { error: "Invalid credentials" });
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
resetRateLimit(loginAttempts, rateLimitKey);
|
|
731
|
+
const session = users.createWebSession(authUser.user.id);
|
|
732
|
+
audit({
|
|
733
|
+
action: "auth_login",
|
|
734
|
+
status: "ok",
|
|
735
|
+
channelId: "web",
|
|
736
|
+
contextKey: "web",
|
|
737
|
+
actorId: authUser.user.id,
|
|
738
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
739
|
+
description: `Login ${authUser.user.email}`,
|
|
740
|
+
});
|
|
741
|
+
setSessionCookie(res, session.token);
|
|
742
|
+
sendJson(res, 200, currentUserDto(authUser));
|
|
743
|
+
}
|
|
744
|
+
function handleLogout(req, res) {
|
|
745
|
+
const authUser = authenticateRequest(req);
|
|
746
|
+
users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
|
|
747
|
+
if (authUser) {
|
|
748
|
+
auditUserAction(authUser, "auth_logout", authUser.user.email);
|
|
749
|
+
}
|
|
750
|
+
clearSessionCookie(res);
|
|
751
|
+
sendJson(res, 200, { ok: true });
|
|
434
752
|
}
|
|
435
753
|
function parseOptions(argv) {
|
|
436
754
|
let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
|
|
@@ -450,77 +768,177 @@ function parseOptions(argv) {
|
|
|
450
768
|
}
|
|
451
769
|
return { host, port, home };
|
|
452
770
|
}
|
|
453
|
-
function
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
771
|
+
function authenticateRequest(req) {
|
|
772
|
+
const cookies = parseCookies(req.headers.cookie ?? "");
|
|
773
|
+
return users.resolveWebSession(cookies.nr_session);
|
|
774
|
+
}
|
|
775
|
+
function setSessionCookie(res, token) {
|
|
776
|
+
res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
|
|
777
|
+
}
|
|
778
|
+
function clearSessionCookie(res) {
|
|
779
|
+
res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
|
|
780
|
+
}
|
|
781
|
+
function currentUserDto(authUser) {
|
|
458
782
|
return {
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
user,
|
|
463
|
-
password,
|
|
783
|
+
user: publicUser(authUser.user),
|
|
784
|
+
groups: authUser.groups,
|
|
785
|
+
permissions: authUser.permissions,
|
|
464
786
|
};
|
|
465
787
|
}
|
|
466
|
-
function
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
function isAuthorizedRequest(req) {
|
|
470
|
-
if (!auth.required) {
|
|
471
|
-
return true;
|
|
788
|
+
function audit(event) {
|
|
789
|
+
try {
|
|
790
|
+
auditLog.append(event);
|
|
472
791
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
return true;
|
|
792
|
+
catch (error) {
|
|
793
|
+
console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
|
|
476
794
|
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
795
|
+
}
|
|
796
|
+
function auditUserAction(authUser, action, description) {
|
|
797
|
+
audit({
|
|
798
|
+
action,
|
|
799
|
+
status: "ok",
|
|
800
|
+
channelId: "web",
|
|
801
|
+
contextKey: "web",
|
|
802
|
+
actorId: authUser.user.id,
|
|
803
|
+
actorRole: authUser.groups.map((group) => group.name).join(", "),
|
|
804
|
+
description,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
function scopedControlOptions(authUser, options) {
|
|
808
|
+
return {
|
|
809
|
+
...options,
|
|
810
|
+
workspaces: options.workspaces.filter((workspace) => users.canUseWorkspace(authUser, workspace)),
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
function scopedSessionPage(authUser, page) {
|
|
814
|
+
return {
|
|
815
|
+
...page,
|
|
816
|
+
sessions: page.sessions.filter((session) => canUseSession(authUser, session)),
|
|
817
|
+
};
|
|
818
|
+
}
|
|
819
|
+
async function scopedTasks(authUser, tasks) {
|
|
820
|
+
const currentAllowed = await canUseCurrentSessionScope(authUser);
|
|
821
|
+
return {
|
|
822
|
+
...tasks,
|
|
823
|
+
current: tasks.current && canUseSession(authUser, tasks.current) ? tasks.current : null,
|
|
824
|
+
external: tasks.external && canUseSession(authUser, tasks.external) ? tasks.external : null,
|
|
825
|
+
queue: currentAllowed ? tasks.queue : [],
|
|
826
|
+
recent: filterActivityByScope(authUser, tasks.recent),
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => canUseCurrentSessionScope(authUser)) {
|
|
830
|
+
switch (event.type) {
|
|
831
|
+
case "snapshot":
|
|
832
|
+
return canUseSession(authUser, event.data.session) ? event : null;
|
|
833
|
+
case "session_update":
|
|
834
|
+
return canUseSession(authUser, event.session) ? event : null;
|
|
835
|
+
case "activity_update":
|
|
836
|
+
return { ...event, events: filterActivityByScope(authUser, event.events) };
|
|
837
|
+
case "agent_update":
|
|
838
|
+
return users.canUseAgent(authUser, event.job.agentId) ? event : null;
|
|
839
|
+
case "status":
|
|
840
|
+
return event;
|
|
841
|
+
case "chat_history":
|
|
842
|
+
case "queue_update":
|
|
843
|
+
case "turn_start":
|
|
844
|
+
case "text_delta":
|
|
845
|
+
case "tool_start":
|
|
846
|
+
case "tool_update":
|
|
847
|
+
case "tool_end":
|
|
848
|
+
case "todo_update":
|
|
849
|
+
case "turn_complete":
|
|
850
|
+
case "turn_error":
|
|
851
|
+
return await canUseCurrentSession() ? event : null;
|
|
483
852
|
}
|
|
484
|
-
|
|
485
|
-
|
|
853
|
+
}
|
|
854
|
+
function filterActivityByScope(authUser, events) {
|
|
855
|
+
return events.filter((event) => canUseSession(authUser, event));
|
|
856
|
+
}
|
|
857
|
+
async function canUseCurrentSessionScope(authUser) {
|
|
858
|
+
try {
|
|
859
|
+
await assertCurrentSessionScope(authUser);
|
|
486
860
|
return true;
|
|
487
861
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
|
|
492
|
-
return true;
|
|
862
|
+
catch (error) {
|
|
863
|
+
if (error instanceof AccessDeniedError) {
|
|
864
|
+
return false;
|
|
493
865
|
}
|
|
866
|
+
throw error;
|
|
494
867
|
}
|
|
495
|
-
return false;
|
|
496
868
|
}
|
|
497
|
-
function
|
|
498
|
-
|
|
869
|
+
function canUseSession(authUser, session) {
|
|
870
|
+
const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
|
|
871
|
+
const workspace = typeof session.workspace === "string"
|
|
872
|
+
? session.workspace
|
|
873
|
+
: typeof session.cwd === "string"
|
|
874
|
+
? session.cwd
|
|
875
|
+
: undefined;
|
|
876
|
+
return users.canUseAgent(authUser, agentId) && users.canUseWorkspace(authUser, workspace);
|
|
499
877
|
}
|
|
500
|
-
function
|
|
501
|
-
|
|
878
|
+
function assertAgentUpdateJobScope(authUser, id) {
|
|
879
|
+
const job = runtime.agentUpdateJobs().find((candidate) => candidate.id === id);
|
|
880
|
+
if (job) {
|
|
881
|
+
assertScopedAgent(authUser, job.agentId);
|
|
882
|
+
}
|
|
502
883
|
}
|
|
503
|
-
function
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
return
|
|
884
|
+
function assertSessionDetailScope(authUser, threadId, detail) {
|
|
885
|
+
const record = objectValue(detail.record);
|
|
886
|
+
if (record) {
|
|
887
|
+
assertSessionScope(authUser, record);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
const active = objectValue(detail.active);
|
|
891
|
+
if (active && active.threadId === threadId) {
|
|
892
|
+
assertSessionScope(authUser, active);
|
|
893
|
+
return;
|
|
508
894
|
}
|
|
509
|
-
|
|
895
|
+
throw new AccessDeniedError("Access denied: session is outside your group scope.");
|
|
510
896
|
}
|
|
511
|
-
function
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
diff |= left[index] ^ right[index];
|
|
897
|
+
function assertScopedAgent(authUser, agentId) {
|
|
898
|
+
if (!users.canUseAgent(authUser, agentId)) {
|
|
899
|
+
throw new AccessDeniedError(`Access denied: agent ${agentId} is outside your group scope.`);
|
|
515
900
|
}
|
|
516
|
-
return diff === 0;
|
|
517
901
|
}
|
|
518
|
-
function
|
|
519
|
-
|
|
902
|
+
function assertScopedWorkspace(authUser, workspace) {
|
|
903
|
+
if (!users.canUseWorkspace(authUser, workspace)) {
|
|
904
|
+
throw new AccessDeniedError(`Access denied: workspace ${workspace} is outside your group scope.`);
|
|
905
|
+
}
|
|
520
906
|
}
|
|
521
|
-
function
|
|
522
|
-
const
|
|
523
|
-
|
|
907
|
+
function assertSessionScope(authUser, session) {
|
|
908
|
+
const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
|
|
909
|
+
const workspace = typeof session.workspace === "string"
|
|
910
|
+
? session.workspace
|
|
911
|
+
: typeof session.cwd === "string"
|
|
912
|
+
? session.cwd
|
|
913
|
+
: undefined;
|
|
914
|
+
assertScopedAgent(authUser, agentId);
|
|
915
|
+
assertScopedWorkspace(authUser, workspace);
|
|
916
|
+
}
|
|
917
|
+
async function assertCurrentSessionScope(authUser) {
|
|
918
|
+
const snapshot = await runtime.snapshot();
|
|
919
|
+
assertSessionScope(authUser, snapshot.session);
|
|
920
|
+
}
|
|
921
|
+
function objectValue(value) {
|
|
922
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
923
|
+
}
|
|
924
|
+
function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
|
|
925
|
+
const now = Date.now();
|
|
926
|
+
const existing = buckets.get(key);
|
|
927
|
+
if (existing?.blockedUntil && existing.blockedUntil > now) {
|
|
928
|
+
return { limited: true, retryAfterMs: existing.blockedUntil - now };
|
|
929
|
+
}
|
|
930
|
+
const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
|
|
931
|
+
bucket.count += 1;
|
|
932
|
+
if (bucket.count > limit) {
|
|
933
|
+
bucket.blockedUntil = now + blockMs;
|
|
934
|
+
buckets.set(key, bucket);
|
|
935
|
+
return { limited: true, retryAfterMs: blockMs };
|
|
936
|
+
}
|
|
937
|
+
buckets.set(key, bucket);
|
|
938
|
+
return { limited: false };
|
|
939
|
+
}
|
|
940
|
+
function resetRateLimit(buckets, key) {
|
|
941
|
+
buckets.delete(key);
|
|
524
942
|
}
|
|
525
943
|
function parseCookies(cookieHeader) {
|
|
526
944
|
const cookies = {};
|
|
@@ -572,6 +990,46 @@ function optionalBooleanField(value, key) {
|
|
|
572
990
|
const field = value[key];
|
|
573
991
|
return typeof field === "boolean" ? field : undefined;
|
|
574
992
|
}
|
|
993
|
+
function numberField(value, key) {
|
|
994
|
+
const field = value[key];
|
|
995
|
+
const parsed = typeof field === "number" ? field : typeof field === "string" ? Number(field) : Number.NaN;
|
|
996
|
+
if (!Number.isInteger(parsed)) {
|
|
997
|
+
throw new Error(`${key} must be an integer`);
|
|
998
|
+
}
|
|
999
|
+
return parsed;
|
|
1000
|
+
}
|
|
1001
|
+
function optionalNumberField(value, key) {
|
|
1002
|
+
if (value[key] === undefined || value[key] === "") {
|
|
1003
|
+
return undefined;
|
|
1004
|
+
}
|
|
1005
|
+
return numberField(value, key);
|
|
1006
|
+
}
|
|
1007
|
+
function arrayStringField(value, key) {
|
|
1008
|
+
const field = value[key];
|
|
1009
|
+
if (field === undefined || field === null || field === "") {
|
|
1010
|
+
return [];
|
|
1011
|
+
}
|
|
1012
|
+
if (Array.isArray(field)) {
|
|
1013
|
+
return field.filter((item) => typeof item === "string");
|
|
1014
|
+
}
|
|
1015
|
+
if (typeof field === "string") {
|
|
1016
|
+
return field.split(",").map((item) => item.trim()).filter(Boolean);
|
|
1017
|
+
}
|
|
1018
|
+
throw new Error(`${key} must be a string list`);
|
|
1019
|
+
}
|
|
1020
|
+
function arrayNumberField(value, key) {
|
|
1021
|
+
const field = value[key];
|
|
1022
|
+
if (field === undefined || field === null || field === "") {
|
|
1023
|
+
return [];
|
|
1024
|
+
}
|
|
1025
|
+
if (Array.isArray(field)) {
|
|
1026
|
+
return field.map((item) => typeof item === "number" ? item : Number(item)).filter((item) => Number.isInteger(item));
|
|
1027
|
+
}
|
|
1028
|
+
if (typeof field === "string") {
|
|
1029
|
+
return field.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item));
|
|
1030
|
+
}
|
|
1031
|
+
throw new Error(`${key} must be a number list`);
|
|
1032
|
+
}
|
|
575
1033
|
function parseAgentId(value) {
|
|
576
1034
|
if (!value) {
|
|
577
1035
|
return undefined;
|
|
@@ -632,13 +1090,7 @@ function optionalEnv(key) {
|
|
|
632
1090
|
}
|
|
633
1091
|
function activeSettingsValues(current) {
|
|
634
1092
|
return {
|
|
635
|
-
TELEGRAM_ALLOW_ANY_CHAT: boolValue(current.telegramAllowAnyChat),
|
|
636
1093
|
TELEGRAM_BOT_TOKEN: current.telegramBotToken,
|
|
637
|
-
TELEGRAM_ADMIN_USER_IDS: current.telegramAdminUserIds.join(","),
|
|
638
|
-
TELEGRAM_ALLOWED_USER_IDS: current.telegramAllowedUserIds.join(","),
|
|
639
|
-
TELEGRAM_READONLY_USER_IDS: current.telegramReadOnlyUserIds.join(","),
|
|
640
|
-
TELEGRAM_ALLOWED_CHAT_IDS: current.telegramAllowedChatIds.join(","),
|
|
641
|
-
TELEGRAM_ROLE_POLICIES_JSON: optionalEnv("TELEGRAM_ROLE_POLICIES_JSON"),
|
|
642
1094
|
TELEGRAM_TRANSPORT: current.telegramTransport,
|
|
643
1095
|
TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
|
|
644
1096
|
TELEGRAM_WEBHOOK_HOST: current.telegramWebhookHost,
|
|
@@ -745,7 +1197,7 @@ function shutdown() {
|
|
|
745
1197
|
runtime.dispose();
|
|
746
1198
|
server.close(() => process.exit(0));
|
|
747
1199
|
}
|
|
748
|
-
function renderLoginPage(
|
|
1200
|
+
function renderLoginPage(options) {
|
|
749
1201
|
return `<!doctype html>
|
|
750
1202
|
<html lang="en">
|
|
751
1203
|
<head>
|
|
@@ -766,18 +1218,17 @@ function renderLoginPage(currentAuth) {
|
|
|
766
1218
|
<body>
|
|
767
1219
|
<form id="login">
|
|
768
1220
|
<h1>NordRelay Dashboard</h1>
|
|
769
|
-
<p>${
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
<button>Sign in</button>
|
|
1221
|
+
<p>${options.adminConfigured ? "Sign in with your NordRelay user account." : "No admin user exists. Run nordrelay user create-admin on this host first."}</p>
|
|
1222
|
+
<label>Email</label><input id="email" name="email" type="email" autocomplete="username" ${options.adminConfigured ? "" : "disabled"}>
|
|
1223
|
+
<label>Password</label><input id="password" name="password" type="password" autocomplete="current-password" ${options.adminConfigured ? "" : "disabled"}>
|
|
1224
|
+
<button ${options.adminConfigured ? "" : "disabled"}>Sign in</button>
|
|
773
1225
|
<div class="error" id="error"></div>
|
|
774
1226
|
</form>
|
|
775
1227
|
<script>
|
|
776
1228
|
document.getElementById('login').addEventListener('submit', async (event) => {
|
|
777
1229
|
event.preventDefault();
|
|
778
1230
|
const payload = {
|
|
779
|
-
|
|
780
|
-
user: document.getElementById('user')?.value || undefined,
|
|
1231
|
+
email: document.getElementById('email')?.value || undefined,
|
|
781
1232
|
password: document.getElementById('password')?.value || undefined,
|
|
782
1233
|
};
|
|
783
1234
|
const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
|
|
@@ -785,14 +1236,13 @@ function renderLoginPage(currentAuth) {
|
|
|
785
1236
|
document.getElementById('error').textContent = 'Invalid credentials';
|
|
786
1237
|
return;
|
|
787
1238
|
}
|
|
788
|
-
if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
|
|
789
1239
|
location.href = '/';
|
|
790
1240
|
});
|
|
791
1241
|
</script>
|
|
792
1242
|
</body>
|
|
793
1243
|
</html>`;
|
|
794
1244
|
}
|
|
795
|
-
function renderDashboardApp(
|
|
1245
|
+
function renderDashboardApp() {
|
|
796
1246
|
return `<!doctype html>
|
|
797
1247
|
<html lang="en">
|
|
798
1248
|
<head>
|
|
@@ -822,6 +1272,7 @@ function renderDashboardApp(options) {
|
|
|
822
1272
|
<select id="agentSelect"></select>
|
|
823
1273
|
<button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
|
|
824
1274
|
<button id="refreshBtn">Refresh</button>
|
|
1275
|
+
<button id="logoutBtn" class="secondary">Logout</button>
|
|
825
1276
|
</div>
|
|
826
1277
|
</header>
|
|
827
1278
|
|
|
@@ -918,8 +1369,12 @@ function renderDashboardApp(options) {
|
|
|
918
1369
|
|
|
919
1370
|
<section class="page" id="page-access">
|
|
920
1371
|
<div class="panel">
|
|
921
|
-
<div class="row"><button id="loadAccessBtn">Reload
|
|
1372
|
+
<div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
|
|
922
1373
|
<div id="accessPanel" class="settings-grid"></div>
|
|
1374
|
+
<h2>Groups</h2>
|
|
1375
|
+
<div id="groupsList" class="list"></div>
|
|
1376
|
+
<h2>Telegram chats</h2>
|
|
1377
|
+
<div id="telegramChatsList" class="list"></div>
|
|
923
1378
|
<h2>Locks</h2>
|
|
924
1379
|
<div id="locksList" class="list"></div>
|
|
925
1380
|
<h2>Audit</h2>
|
|
@@ -959,7 +1414,7 @@ function renderDashboardApp(options) {
|
|
|
959
1414
|
<footer>
|
|
960
1415
|
<span id="footerVersion">NordRelay</span>
|
|
961
1416
|
<span id="footerHealth">Health: loading</span>
|
|
962
|
-
<span
|
|
1417
|
+
<span id="footerUser">User: loading</span>
|
|
963
1418
|
</footer>
|
|
964
1419
|
</main>
|
|
965
1420
|
</div>
|
|
@@ -982,6 +1437,13 @@ function renderDashboardApp(options) {
|
|
|
982
1437
|
<div id="sessionDetail"></div>
|
|
983
1438
|
<div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
|
|
984
1439
|
</dialog>
|
|
1440
|
+
<dialog id="adminDialog">
|
|
1441
|
+
<form method="dialog" id="adminDialogForm">
|
|
1442
|
+
<h2 id="adminDialogTitle">Edit</h2>
|
|
1443
|
+
<div id="adminDialogBody" class="form-grid"></div>
|
|
1444
|
+
<div class="row dialog-actions"><button type="button" id="adminDialogCancel" class="secondary">Cancel</button><button id="adminDialogSubmit" value="default">Save</button></div>
|
|
1445
|
+
</form>
|
|
1446
|
+
</dialog>
|
|
985
1447
|
<div id="toolTooltip" class="tool-tooltip"></div>
|
|
986
1448
|
<div id="toast"></div>
|
|
987
1449
|
<script src="/assets/dashboard.js"></script>
|