@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.
@@ -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 { dashboardJs } from "./web-dashboard-client.js";
16
- import { dashboardCss } from "./web-dashboard-style.js";
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 (auth.required && !isAuthorizedRequest(req) && !isAuthorizedToken(queryToken ?? "")) {
51
- if (url.pathname === "/" || url.pathname === "/index.html") {
52
- sendText(res, 200, renderLoginPage(auth), "text/html; charset=utf-8");
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
- if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") {
56
- sendJson(res, 401, { error: "Authentication required" });
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
- sendText(res, 401, "Authentication required\n", "text/plain; charset=utf-8");
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({ authRequired: auth.required }), "text/html; charset=utf-8");
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: { required: auth.required, publicBind: auth.publicBind },
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
- sendJson(res, 200, await runtime.controlOptions(parseAgentId(url.searchParams.get("agent") ?? undefined)));
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
- sendJson(res, 202, { job: runtime.startAgentUpdate(parseAgentIdRequired(stringField(body, "agentId"))) });
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
- sendJson(res, 200, runtime.agentUpdateLog(decodeURIComponent(agentUpdateLogMatch[1])));
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
- sendJson(res, 200, { job: runtime.sendAgentUpdateInput(decodeURIComponent(agentUpdateInputMatch[1]), stringField(body, "input")) });
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
- sendJson(res, 200, { job: runtime.cancelAgentUpdate(decodeURIComponent(agentUpdateCancelMatch[1])) });
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, runtime.permissions());
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
- sendJson(res, 200, await runtime.authStatus(parseAgentId(url.searchParams.get("agent") ?? undefined)));
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
- sendJson(res, 200, await runtime.login(parseAgentId(optionalStringField(body, "agentId"))));
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
- sendJson(res, 200, await runtime.logout(parseAgentId(optionalStringField(body, "agentId"))));
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
- sendJson(res, 200, await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? "", parseAgentId(url.searchParams.get("agent") ?? undefined)));
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: parseAgentId(optionalStringField(body, "agentId")),
215
- workspace: optionalStringField(body, "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
- sendJson(res, 200, { session: await runtime.switchSession(stringField(body, "threadId")) });
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
- sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
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
- sendJson(res, 200, await runtime.sessionDetail(requiredSearch(url, "threadId")));
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 url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
394
- const token = url.searchParams.get("token");
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
- res.write(`event: ${event.type}\n`);
406
- res.write(`data: ${JSON.stringify(event)}\n\n`);
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 token = optionalStringField(body, "token");
421
- const user = optionalStringField(body, "user");
698
+ const email = optionalStringField(body, "email");
422
699
  const password = optionalStringField(body, "password");
423
- if (token && isAuthorizedToken(token)) {
424
- setAuthCookie(res, token);
425
- sendJson(res, 200, { ok: true, mode: "token" });
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 (user && password && isAuthorizedBasic(user, password)) {
429
- setBasicCookie(res, user, password);
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
- sendJson(res, 401, { error: "Invalid dashboard credentials" });
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 resolveDashboardAuth(host) {
454
- const token = optionalEnv("NORDRELAY_DASHBOARD_TOKEN");
455
- const user = optionalEnv("NORDRELAY_DASHBOARD_USER");
456
- const password = optionalEnv("NORDRELAY_DASHBOARD_PASSWORD");
457
- const publicBind = isPublicBindHost(host);
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
- required: publicBind || Boolean(token || (user && password)),
460
- publicBind,
461
- token,
462
- user,
463
- password,
783
+ user: publicUser(authUser.user),
784
+ groups: authUser.groups,
785
+ permissions: authUser.permissions,
464
786
  };
465
787
  }
466
- function isPublicBindHost(host) {
467
- return host === "0.0.0.0" || host === "::" || host === "";
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
- const header = req.headers.authorization;
474
- if (header?.startsWith("Bearer ") && isAuthorizedToken(header.slice("Bearer ".length).trim())) {
475
- return true;
792
+ catch (error) {
793
+ console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
476
794
  }
477
- if (header?.startsWith("Basic ")) {
478
- const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8");
479
- const [user, ...passwordParts] = decoded.split(":");
480
- if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
481
- return true;
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
- const cookies = parseCookies(req.headers.cookie ?? "");
485
- if (cookies.nrdash && isAuthorizedToken(cookies.nrdash)) {
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
- if (cookies.nrdash_basic) {
489
- const decoded = Buffer.from(cookies.nrdash_basic, "base64").toString("utf8");
490
- const [user, ...passwordParts] = decoded.split(":");
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 isAuthorizedToken(token) {
498
- return Boolean(auth.token && constantTimeEqual(token, auth.token));
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 isAuthorizedBasic(user, password) {
501
- return Boolean(auth.user && auth.password && constantTimeEqual(user, auth.user) && constantTimeEqual(password, auth.password));
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 constantTimeEqual(left, right) {
504
- const leftBuffer = Buffer.from(left);
505
- const rightBuffer = Buffer.from(right);
506
- if (leftBuffer.length !== rightBuffer.length) {
507
- return false;
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
- return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
895
+ throw new AccessDeniedError("Access denied: session is outside your group scope.");
510
896
  }
511
- function cryptoTimingSafeEqual(left, right) {
512
- let diff = 0;
513
- for (let index = 0; index < left.length; index += 1) {
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 setAuthCookie(res, token) {
519
- res.setHeader("set-cookie", `nrdash=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
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 setBasicCookie(res, user, password) {
522
- const value = Buffer.from(`${user}:${password}`).toString("base64");
523
- res.setHeader("set-cookie", `nrdash_basic=${encodeURIComponent(value)}; HttpOnly; SameSite=Strict; Path=/`);
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(currentAuth) {
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>${currentAuth.publicBind ? "Remote dashboard access requires authentication." : "Authentication required."}</p>
770
- ${currentAuth.token ? '<label>Token</label><input id="token" name="token" type="password" autocomplete="current-password">' : ""}
771
- ${currentAuth.user ? '<label>User</label><input id="user" name="user" autocomplete="username"><label>Password</label><input id="password" name="password" type="password" autocomplete="current-password">' : ""}
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
- token: document.getElementById('token')?.value || undefined,
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(options) {
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 access</button><button id="saveAccessBtn">Save access settings</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
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>Dashboard bind: ${escapeHTML(options.authRequired ? "authenticated" : "local")}</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>