@nordbyte/nordrelay 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,26 +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";
17
+ import { UserStore, publicUser, publicUserSnapshot } from "./user-management.js";
18
+ import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
15
19
  import { renderDashboardNav } from "./web-dashboard-ui.js";
16
20
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
17
21
  const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
18
22
  const options = parseOptions(process.argv.slice(2));
19
- const auth = resolveDashboardAuth(options.host);
20
- if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
21
- throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
22
- }
23
23
  const config = loadConfig();
24
24
  const runtime = new RelayRuntime(config);
25
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
+ }
26
31
  const server = createServer((req, res) => {
27
32
  void handleRequest(req, res).catch((error) => {
28
- sendJson(res, 500, { error: friendlyErrorText(error) });
33
+ sendJson(res, error instanceof AccessDeniedError ? 403 : 500, { error: friendlyErrorText(error) });
29
34
  });
30
35
  });
31
36
  await new Promise((resolve) => server.listen(options.port, options.host, resolve));
@@ -34,64 +39,109 @@ process.once("SIGINT", () => shutdown());
34
39
  process.once("SIGTERM", () => shutdown());
35
40
  async function handleRequest(req, res) {
36
41
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
37
- const queryToken = url.searchParams.get("token");
38
- if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
39
- setAuthCookie(res, queryToken);
40
- res.writeHead(302, { location: url.pathname || "/" });
41
- res.end();
42
- return;
43
- }
44
42
  if (url.pathname === "/api/auth" && req.method === "POST") {
45
43
  await handleLogin(req, res);
46
44
  return;
47
45
  }
48
- if (auth.required && !isAuthorizedRequest(req) && !isAuthorizedToken(queryToken ?? "")) {
49
- if (url.pathname === "/" || url.pathname === "/index.html") {
50
- 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() });
51
54
  return;
52
55
  }
53
- if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") {
54
- 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");
55
62
  return;
56
63
  }
57
- sendText(res, 401, "Authentication required\n", "text/plain; charset=utf-8");
64
+ sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
58
65
  return;
59
66
  }
60
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
+ }
61
72
  sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
62
73
  return;
63
74
  }
64
75
  if (url.pathname === "/" || url.pathname === "/index.html") {
65
- sendText(res, 200, renderDashboardApp({ authRequired: auth.required }), "text/html; charset=utf-8");
76
+ sendText(res, 200, renderDashboardApp(), "text/html; charset=utf-8");
77
+ return;
78
+ }
79
+ if (url.pathname === "/assets/dashboard.css") {
80
+ sendText(res, 200, dashboardCss(), "text/css; charset=utf-8");
81
+ return;
82
+ }
83
+ if (url.pathname === "/assets/dashboard.js") {
84
+ sendText(res, 200, dashboardJs(), "application/javascript; charset=utf-8");
66
85
  return;
67
86
  }
68
87
  if (url.pathname === "/api/events" && req.method === "GET") {
69
- handleEvents(req, res);
88
+ await handleEvents(req, res);
70
89
  return;
71
90
  }
72
91
  if (!url.pathname.startsWith("/api/")) {
73
92
  sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
74
93
  return;
75
94
  }
76
- await handleApi(req, res, url);
95
+ await handleApi(req, res, url, authenticated);
77
96
  }
78
- 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
+ }
79
125
  if (req.method === "GET" && url.pathname === "/api/bootstrap") {
126
+ await assertCurrentSessionScope(authUser);
80
127
  sendJson(res, 200, {
81
- auth: { required: auth.required, publicBind: auth.publicBind },
128
+ auth: currentUserDto(authUser),
82
129
  channels: listChannelDescriptors(),
83
- agentAdapters: listAgentAdapterDescriptors(),
84
- enabledAgents: enabledAgents(config),
85
- controls: await runtime.controlOptions(),
86
- status: await runtime.status(),
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()),
133
+ status: await runtime.bootstrapStatus(),
87
134
  });
88
135
  return;
89
136
  }
90
137
  if (req.method === "GET" && url.pathname === "/api/control-options") {
91
- 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)));
92
141
  return;
93
142
  }
94
143
  if (req.method === "GET" && url.pathname === "/api/health") {
144
+ await assertCurrentSessionScope(authUser);
95
145
  sendJson(res, 200, await runtime.status());
96
146
  return;
97
147
  }
@@ -103,16 +153,203 @@ async function handleApi(req, res, url) {
103
153
  sendJson(res, 202, runtime.updateConnector());
104
154
  return;
105
155
  }
156
+ if (req.method === "GET" && url.pathname === "/api/agent-updates") {
157
+ sendJson(res, 200, { jobs: runtime.agentUpdateJobs().filter((job) => users.canUseAgent(authUser, job.agentId)) });
158
+ return;
159
+ }
160
+ if (req.method === "POST" && url.pathname === "/api/agent-update") {
161
+ const body = await readJsonBody(req);
162
+ const agentId = parseAgentIdRequired(stringField(body, "agentId"));
163
+ assertScopedAgent(authUser, agentId);
164
+ sendJson(res, 202, { job: runtime.startAgentUpdate(agentId) });
165
+ return;
166
+ }
167
+ const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
168
+ if (req.method === "GET" && 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) });
178
+ return;
179
+ }
180
+ const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
181
+ if (req.method === "POST" && agentUpdateInputMatch?.[1]) {
182
+ const body = await readJsonBody(req);
183
+ const id = decodeURIComponent(agentUpdateInputMatch[1]);
184
+ assertAgentUpdateJobScope(authUser, id);
185
+ sendJson(res, 200, { job: runtime.sendAgentUpdateInput(id, stringField(body, "input")) });
186
+ return;
187
+ }
188
+ const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
189
+ if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
190
+ const id = decodeURIComponent(agentUpdateCancelMatch[1]);
191
+ assertAgentUpdateJobScope(authUser, id);
192
+ sendJson(res, 200, { job: runtime.cancelAgentUpdate(id) });
193
+ return;
194
+ }
106
195
  if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
107
- sendJson(res, 200, runtime.tasks());
196
+ sendJson(res, 200, await scopedTasks(authUser, runtime.tasks()));
108
197
  return;
109
198
  }
110
199
  if (req.method === "GET" && url.pathname === "/api/adapters/health") {
111
- sendJson(res, 200, { adapters: await runtime.adapterHealth() });
200
+ sendJson(res, 200, { adapters: (await runtime.adapterHealth()).filter((adapter) => users.canUseAgent(authUser, adapter.id)) });
112
201
  return;
113
202
  }
114
203
  if (req.method === "GET" && url.pathname === "/api/permissions") {
115
- 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 });
116
353
  return;
117
354
  }
118
355
  if (req.method === "GET" && url.pathname === "/api/audit") {
@@ -120,30 +357,39 @@ async function handleApi(req, res, url) {
120
357
  return;
121
358
  }
122
359
  if (req.method === "GET" && url.pathname === "/api/locks") {
360
+ await assertCurrentSessionScope(authUser);
123
361
  sendJson(res, 200, { locks: runtime.locks() });
124
362
  return;
125
363
  }
126
364
  if (req.method === "POST" && url.pathname === "/api/locks") {
127
365
  const body = await readJsonBody(req);
366
+ await assertCurrentSessionScope(authUser);
128
367
  sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
129
368
  return;
130
369
  }
131
370
  if (req.method === "DELETE" && url.pathname === "/api/locks") {
371
+ await assertCurrentSessionScope(authUser);
132
372
  sendJson(res, 200, runtime.unlockWebSession());
133
373
  return;
134
374
  }
135
375
  if (req.method === "GET" && url.pathname === "/api/auth/status") {
136
- 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));
137
379
  return;
138
380
  }
139
381
  if (req.method === "POST" && url.pathname === "/api/auth/login") {
140
382
  const body = await readJsonBody(req);
141
- 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));
142
386
  return;
143
387
  }
144
388
  if (req.method === "POST" && url.pathname === "/api/auth/logout") {
145
389
  const body = await readJsonBody(req);
146
- 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));
147
393
  return;
148
394
  }
149
395
  if (req.method === "GET" && url.pathname === "/api/settings") {
@@ -156,11 +402,20 @@ async function handleApi(req, res, url) {
156
402
  return;
157
403
  }
158
404
  if (req.method === "GET" && url.pathname === "/api/snapshot") {
405
+ await assertCurrentSessionScope(authUser);
159
406
  sendJson(res, 200, await runtime.snapshot());
160
407
  return;
161
408
  }
162
409
  if (req.method === "GET" && url.pathname === "/api/sessions") {
163
- sendJson(res, 200, await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? ""));
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));
164
419
  return;
165
420
  }
166
421
  if (req.method === "POST" && url.pathname === "/api/agent") {
@@ -169,15 +424,20 @@ async function handleApi(req, res, url) {
169
424
  if (!isAgentId(agentId)) {
170
425
  throw new Error(`Invalid agent: ${agentId}`);
171
426
  }
427
+ assertScopedAgent(authUser, agentId);
172
428
  sendJson(res, 200, { session: await runtime.setAgent(agentId) });
173
429
  return;
174
430
  }
175
431
  if (req.method === "POST" && url.pathname === "/api/sessions/new") {
176
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);
177
437
  sendJson(res, 200, {
178
438
  session: await runtime.newSession({
179
- agentId: parseAgentId(optionalStringField(body, "agentId")),
180
- workspace: optionalStringField(body, "workspace"),
439
+ agentId,
440
+ workspace,
181
441
  model: optionalStringField(body, "model"),
182
442
  reasoningEffort: optionalStringField(body, "reasoningEffort"),
183
443
  launchProfileId: optionalStringField(body, "launchProfileId"),
@@ -188,49 +448,68 @@ async function handleApi(req, res, url) {
188
448
  }
189
449
  if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
190
450
  const body = await readJsonBody(req);
191
- 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 });
192
459
  return;
193
460
  }
194
461
  if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
195
462
  const body = await readJsonBody(req);
196
- 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 });
197
466
  return;
198
467
  }
199
468
  if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
200
- 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);
201
473
  return;
202
474
  }
203
475
  if (req.method === "GET" && url.pathname === "/api/models") {
476
+ await assertCurrentSessionScope(authUser);
204
477
  sendJson(res, 200, { models: await runtime.listModels() });
205
478
  return;
206
479
  }
207
480
  if (req.method === "POST" && url.pathname === "/api/session/model") {
208
481
  const body = await readJsonBody(req);
482
+ await assertCurrentSessionScope(authUser);
209
483
  sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
210
484
  return;
211
485
  }
212
486
  if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
213
487
  const body = await readJsonBody(req);
488
+ await assertCurrentSessionScope(authUser);
214
489
  sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
215
490
  return;
216
491
  }
217
492
  if (req.method === "POST" && url.pathname === "/api/session/fast") {
218
493
  const body = await readJsonBody(req);
494
+ await assertCurrentSessionScope(authUser);
219
495
  sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
220
496
  return;
221
497
  }
222
498
  if (req.method === "POST" && url.pathname === "/api/session/launch") {
223
499
  const body = await readJsonBody(req);
500
+ await assertCurrentSessionScope(authUser);
224
501
  sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
225
502
  return;
226
503
  }
227
504
  if (req.method === "POST" && url.pathname === "/api/prompt") {
228
505
  const body = await readJsonBody(req);
506
+ await assertCurrentSessionScope(authUser);
229
507
  sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
230
508
  return;
231
509
  }
232
510
  if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
233
511
  const body = await readJsonBody(req);
512
+ await assertCurrentSessionScope(authUser);
234
513
  sendJson(res, 202, await runtime.sendUploadPrompt({
235
514
  text: optionalStringField(body, "text"),
236
515
  files: parseUploadFiles(body.files),
@@ -238,59 +517,70 @@ async function handleApi(req, res, url) {
238
517
  return;
239
518
  }
240
519
  if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
520
+ await assertCurrentSessionScope(authUser);
241
521
  await runtime.abort();
242
522
  sendJson(res, 200, { ok: true });
243
523
  return;
244
524
  }
245
525
  if (req.method === "POST" && url.pathname === "/api/handback") {
526
+ await assertCurrentSessionScope(authUser);
246
527
  sendJson(res, 200, await runtime.handback());
247
528
  return;
248
529
  }
249
530
  if (req.method === "POST" && url.pathname === "/api/retry") {
531
+ await assertCurrentSessionScope(authUser);
250
532
  sendJson(res, 202, await runtime.retry());
251
533
  return;
252
534
  }
253
535
  if (req.method === "POST" && url.pathname === "/api/sync") {
536
+ await assertCurrentSessionScope(authUser);
254
537
  sendJson(res, 200, await runtime.sync());
255
538
  return;
256
539
  }
257
540
  if (req.method === "GET" && url.pathname === "/api/queue") {
541
+ await assertCurrentSessionScope(authUser);
258
542
  sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
259
543
  return;
260
544
  }
261
545
  if (req.method === "POST" && url.pathname === "/api/queue") {
262
546
  const body = await readJsonBody(req);
547
+ await assertCurrentSessionScope(authUser);
263
548
  sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
264
549
  return;
265
550
  }
266
551
  if (req.method === "GET" && url.pathname === "/api/chat/history") {
552
+ await assertCurrentSessionScope(authUser);
267
553
  sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
268
554
  return;
269
555
  }
270
556
  if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
557
+ await assertCurrentSessionScope(authUser);
271
558
  sendJson(res, 200, await runtime.clearChatHistory());
272
559
  return;
273
560
  }
274
561
  if (req.method === "GET" && url.pathname === "/api/activity") {
275
562
  sendJson(res, 200, {
276
- events: runtime.activity({
563
+ events: filterActivityByScope(authUser, runtime.activity({
277
564
  limit: numberParam(url, "limit", 100),
278
565
  source: (url.searchParams.get("source") || "all"),
279
566
  status: (url.searchParams.get("status") || "all"),
280
- }),
567
+ })),
281
568
  });
282
569
  return;
283
570
  }
284
571
  if (req.method === "GET" && url.pathname === "/api/artifacts") {
572
+ await assertCurrentSessionScope(authUser);
285
573
  sendJson(res, 200, { reports: await runtime.artifacts() });
286
574
  return;
287
575
  }
288
576
  if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
577
+ await assertCurrentSessionScope(authUser);
289
578
  sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
290
579
  return;
291
580
  }
292
581
  if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
293
582
  const body = await readJsonBody(req);
583
+ await assertCurrentSessionScope(authUser);
294
584
  const action = stringField(body, "action");
295
585
  const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
296
586
  if (action !== "delete") {
@@ -306,6 +596,7 @@ async function handleApi(req, res, url) {
306
596
  return;
307
597
  }
308
598
  if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
599
+ await assertCurrentSessionScope(authUser);
309
600
  const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
310
601
  if (!bundle) {
311
602
  sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
@@ -315,6 +606,7 @@ async function handleApi(req, res, url) {
315
606
  return;
316
607
  }
317
608
  if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
609
+ await assertCurrentSessionScope(authUser);
318
610
  const turnId = requiredSearch(url, "turnId");
319
611
  const relativePath = requiredSearch(url, "path");
320
612
  const report = await runtime.artifact(turnId);
@@ -327,6 +619,7 @@ async function handleApi(req, res, url) {
327
619
  return;
328
620
  }
329
621
  if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
622
+ await assertCurrentSessionScope(authUser);
330
623
  const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
331
624
  if (!preview) {
332
625
  sendJson(res, 404, { error: "Artifact not found" });
@@ -336,10 +629,16 @@ async function handleApi(req, res, url) {
336
629
  return;
337
630
  }
338
631
  if (req.method === "GET" && url.pathname === "/api/logs") {
339
- sendJson(res, 200, await runtime.logs(url.searchParams.get("target") || "connector", numberParam(url, "lines", 120)));
632
+ sendJson(res, 200, await runtime.logs(parseLogTarget(url.searchParams.get("target") ?? undefined), numberParam(url, "lines", 120)));
633
+ return;
634
+ }
635
+ if (req.method === "POST" && url.pathname === "/api/logs/clear") {
636
+ const body = await readJsonBody(req);
637
+ sendJson(res, 200, runtime.clearLogs(parseLogTarget(optionalStringField(body, "target"))));
340
638
  return;
341
639
  }
342
640
  if (req.method === "GET" && url.pathname === "/api/diagnostics") {
641
+ await assertCurrentSessionScope(authUser);
343
642
  sendJson(res, 200, await runtime.diagnostics());
344
643
  return;
345
644
  }
@@ -349,21 +648,40 @@ async function handleApi(req, res, url) {
349
648
  }
350
649
  sendJson(res, 404, { error: "Unknown endpoint" });
351
650
  }
352
- function handleEvents(req, res) {
353
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
354
- const token = url.searchParams.get("token");
355
- if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
651
+ async function handleEvents(req, res) {
652
+ const authUser = authenticateRequest(req);
653
+ if (!authUser) {
356
654
  sendJson(res, 401, { error: "Authentication required" });
357
655
  return;
358
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);
359
662
  res.writeHead(200, {
360
663
  "content-type": "text/event-stream; charset=utf-8",
361
664
  "cache-control": "no-cache, no-transform",
362
665
  connection: "keep-alive",
363
666
  });
364
667
  const send = (event) => {
365
- res.write(`event: ${event.type}\n`);
366
- 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;
367
685
  };
368
686
  const unsubscribe = runtime.subscribe(send);
369
687
  const heartbeat = setInterval(() => {
@@ -377,20 +695,60 @@ function handleEvents(req, res) {
377
695
  }
378
696
  async function handleLogin(req, res) {
379
697
  const body = await readJsonBody(req);
380
- const token = optionalStringField(body, "token");
381
- const user = optionalStringField(body, "user");
698
+ const email = optionalStringField(body, "email");
382
699
  const password = optionalStringField(body, "password");
383
- if (token && isAuthorizedToken(token)) {
384
- setAuthCookie(res, token);
385
- 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 });
712
+ return;
713
+ }
714
+ if (!users.hasAdminUser()) {
715
+ sendJson(res, 503, { error: "No admin user exists. Run nordrelay user create-admin first." });
386
716
  return;
387
717
  }
388
- if (user && password && isAuthorizedBasic(user, password)) {
389
- setBasicCookie(res, user, password);
390
- sendJson(res, 200, { ok: true, mode: "basic" });
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" });
391
728
  return;
392
729
  }
393
- sendJson(res, 401, { error: "Invalid dashboard credentials" });
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 });
394
752
  }
395
753
  function parseOptions(argv) {
396
754
  let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
@@ -410,77 +768,177 @@ function parseOptions(argv) {
410
768
  }
411
769
  return { host, port, home };
412
770
  }
413
- function resolveDashboardAuth(host) {
414
- const token = optionalEnv("NORDRELAY_DASHBOARD_TOKEN");
415
- const user = optionalEnv("NORDRELAY_DASHBOARD_USER");
416
- const password = optionalEnv("NORDRELAY_DASHBOARD_PASSWORD");
417
- 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) {
418
782
  return {
419
- required: publicBind || Boolean(token || (user && password)),
420
- publicBind,
421
- token,
422
- user,
423
- password,
783
+ user: publicUser(authUser.user),
784
+ groups: authUser.groups,
785
+ permissions: authUser.permissions,
424
786
  };
425
787
  }
426
- function isPublicBindHost(host) {
427
- return host === "0.0.0.0" || host === "::" || host === "";
428
- }
429
- function isAuthorizedRequest(req) {
430
- if (!auth.required) {
431
- return true;
788
+ function audit(event) {
789
+ try {
790
+ auditLog.append(event);
432
791
  }
433
- const header = req.headers.authorization;
434
- if (header?.startsWith("Bearer ") && isAuthorizedToken(header.slice("Bearer ".length).trim())) {
435
- return true;
792
+ catch (error) {
793
+ console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
436
794
  }
437
- if (header?.startsWith("Basic ")) {
438
- const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8");
439
- const [user, ...passwordParts] = decoded.split(":");
440
- if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
441
- return true;
442
- }
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;
443
852
  }
444
- const cookies = parseCookies(req.headers.cookie ?? "");
445
- 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);
446
860
  return true;
447
861
  }
448
- if (cookies.nrdash_basic) {
449
- const decoded = Buffer.from(cookies.nrdash_basic, "base64").toString("utf8");
450
- const [user, ...passwordParts] = decoded.split(":");
451
- if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
452
- return true;
862
+ catch (error) {
863
+ if (error instanceof AccessDeniedError) {
864
+ return false;
453
865
  }
866
+ throw error;
454
867
  }
455
- return false;
456
868
  }
457
- function isAuthorizedToken(token) {
458
- 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);
877
+ }
878
+ function assertAgentUpdateJobScope(authUser, id) {
879
+ const job = runtime.agentUpdateJobs().find((candidate) => candidate.id === id);
880
+ if (job) {
881
+ assertScopedAgent(authUser, job.agentId);
882
+ }
459
883
  }
460
- function isAuthorizedBasic(user, password) {
461
- return Boolean(auth.user && auth.password && constantTimeEqual(user, auth.user) && constantTimeEqual(password, auth.password));
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;
894
+ }
895
+ throw new AccessDeniedError("Access denied: session is outside your group scope.");
462
896
  }
463
- function constantTimeEqual(left, right) {
464
- const leftBuffer = Buffer.from(left);
465
- const rightBuffer = Buffer.from(right);
466
- if (leftBuffer.length !== rightBuffer.length) {
467
- return false;
897
+ function assertScopedAgent(authUser, agentId) {
898
+ if (!users.canUseAgent(authUser, agentId)) {
899
+ throw new AccessDeniedError(`Access denied: agent ${agentId} is outside your group scope.`);
468
900
  }
469
- return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
470
901
  }
471
- function cryptoTimingSafeEqual(left, right) {
472
- let diff = 0;
473
- for (let index = 0; index < left.length; index += 1) {
474
- diff |= left[index] ^ right[index];
902
+ function assertScopedWorkspace(authUser, workspace) {
903
+ if (!users.canUseWorkspace(authUser, workspace)) {
904
+ throw new AccessDeniedError(`Access denied: workspace ${workspace} is outside your group scope.`);
475
905
  }
476
- return diff === 0;
477
906
  }
478
- function setAuthCookie(res, token) {
479
- res.setHeader("set-cookie", `nrdash=${encodeURIComponent(token)}; 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;
480
923
  }
481
- function setBasicCookie(res, user, password) {
482
- const value = Buffer.from(`${user}:${password}`).toString("base64");
483
- res.setHeader("set-cookie", `nrdash_basic=${encodeURIComponent(value)}; HttpOnly; SameSite=Strict; Path=/`);
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);
484
942
  }
485
943
  function parseCookies(cookieHeader) {
486
944
  const cookies = {};
@@ -532,15 +990,61 @@ function optionalBooleanField(value, key) {
532
990
  const field = value[key];
533
991
  return typeof field === "boolean" ? field : undefined;
534
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
+ }
535
1033
  function parseAgentId(value) {
536
1034
  if (!value) {
537
1035
  return undefined;
538
1036
  }
1037
+ return parseAgentIdRequired(value);
1038
+ }
1039
+ function parseAgentIdRequired(value) {
539
1040
  if (!isAgentId(value)) {
540
1041
  throw new Error(`Invalid agent: ${value}`);
541
1042
  }
542
1043
  return value;
543
1044
  }
1045
+ function parseLogTarget(value) {
1046
+ return value === "update" || value === "agent-updates" ? value : "connector";
1047
+ }
544
1048
  function objectRecord(value) {
545
1049
  if (!value || typeof value !== "object" || Array.isArray(value)) {
546
1050
  return {};
@@ -586,13 +1090,7 @@ function optionalEnv(key) {
586
1090
  }
587
1091
  function activeSettingsValues(current) {
588
1092
  return {
589
- TELEGRAM_ALLOW_ANY_CHAT: boolValue(current.telegramAllowAnyChat),
590
1093
  TELEGRAM_BOT_TOKEN: current.telegramBotToken,
591
- TELEGRAM_ADMIN_USER_IDS: current.telegramAdminUserIds.join(","),
592
- TELEGRAM_ALLOWED_USER_IDS: current.telegramAllowedUserIds.join(","),
593
- TELEGRAM_READONLY_USER_IDS: current.telegramReadOnlyUserIds.join(","),
594
- TELEGRAM_ALLOWED_CHAT_IDS: current.telegramAllowedChatIds.join(","),
595
- TELEGRAM_ROLE_POLICIES_JSON: optionalEnv("TELEGRAM_ROLE_POLICIES_JSON"),
596
1094
  TELEGRAM_TRANSPORT: current.telegramTransport,
597
1095
  TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
598
1096
  TELEGRAM_WEBHOOK_HOST: current.telegramWebhookHost,
@@ -699,7 +1197,7 @@ function shutdown() {
699
1197
  runtime.dispose();
700
1198
  server.close(() => process.exit(0));
701
1199
  }
702
- function renderLoginPage(currentAuth) {
1200
+ function renderLoginPage(options) {
703
1201
  return `<!doctype html>
704
1202
  <html lang="en">
705
1203
  <head>
@@ -720,18 +1218,17 @@ function renderLoginPage(currentAuth) {
720
1218
  <body>
721
1219
  <form id="login">
722
1220
  <h1>NordRelay Dashboard</h1>
723
- <p>${currentAuth.publicBind ? "Remote dashboard access requires authentication." : "Authentication required."}</p>
724
- ${currentAuth.token ? '<label>Token</label><input id="token" name="token" type="password" autocomplete="current-password">' : ""}
725
- ${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">' : ""}
726
- <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>
727
1225
  <div class="error" id="error"></div>
728
1226
  </form>
729
1227
  <script>
730
1228
  document.getElementById('login').addEventListener('submit', async (event) => {
731
1229
  event.preventDefault();
732
1230
  const payload = {
733
- token: document.getElementById('token')?.value || undefined,
734
- user: document.getElementById('user')?.value || undefined,
1231
+ email: document.getElementById('email')?.value || undefined,
735
1232
  password: document.getElementById('password')?.value || undefined,
736
1233
  };
737
1234
  const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
@@ -739,14 +1236,13 @@ function renderLoginPage(currentAuth) {
739
1236
  document.getElementById('error').textContent = 'Invalid credentials';
740
1237
  return;
741
1238
  }
742
- if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
743
1239
  location.href = '/';
744
1240
  });
745
1241
  </script>
746
1242
  </body>
747
1243
  </html>`;
748
1244
  }
749
- function renderDashboardApp(options) {
1245
+ function renderDashboardApp() {
750
1246
  return `<!doctype html>
751
1247
  <html lang="en">
752
1248
  <head>
@@ -754,7 +1250,7 @@ function renderDashboardApp(options) {
754
1250
  <meta name="viewport" content="width=device-width, initial-scale=1">
755
1251
  <title>NordRelay Dashboard</title>
756
1252
  <script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
757
- <style>${dashboardCss()}</style>
1253
+ <link rel="stylesheet" href="/assets/dashboard.css">
758
1254
  </head>
759
1255
  <body>
760
1256
  <div class="app">
@@ -776,6 +1272,7 @@ function renderDashboardApp(options) {
776
1272
  <select id="agentSelect"></select>
777
1273
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
778
1274
  <button id="refreshBtn">Refresh</button>
1275
+ <button id="logoutBtn" class="secondary">Logout</button>
779
1276
  </div>
780
1277
  </header>
781
1278
 
@@ -783,7 +1280,10 @@ function renderDashboardApp(options) {
783
1280
  <div class="metrics" id="metrics"></div>
784
1281
  <div class="stack">
785
1282
  <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
786
- <div class="panel"><h2>Adapters</h2><div id="adapters"></div></div>
1283
+ <div class="overview-adapter-grid">
1284
+ <div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
1285
+ <div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
1286
+ </div>
787
1287
  </div>
788
1288
  </section>
789
1289
 
@@ -855,8 +1355,8 @@ function renderDashboardApp(options) {
855
1355
  <section class="page" id="page-artifacts">
856
1356
  <div class="panel">
857
1357
  <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button><input id="artifactSearch" placeholder="Search artifacts"><select id="artifactKind"><option value="all">All files</option><option value="images">Images</option><option value="docs">Docs/code</option></select><button id="zipSelectedArtifactsBtn" class="secondary">ZIP selected</button><button id="deleteSelectedArtifactsBtn" class="danger">Delete selected</button></div>
858
- <div id="artifactList" class="list"></div>
859
1358
  <div id="artifactPreview" class="preview"></div>
1359
+ <div id="artifactList" class="list"></div>
860
1360
  </div>
861
1361
  </section>
862
1362
 
@@ -869,8 +1369,12 @@ function renderDashboardApp(options) {
869
1369
 
870
1370
  <section class="page" id="page-access">
871
1371
  <div class="panel">
872
- <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>
873
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>
874
1378
  <h2>Locks</h2>
875
1379
  <div id="locksList" class="list"></div>
876
1380
  <h2>Audit</h2>
@@ -881,8 +1385,10 @@ function renderDashboardApp(options) {
881
1385
 
882
1386
  <section class="page" id="page-version">
883
1387
  <div class="panel">
884
- <div class="row"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
1388
+ <div class="row version-actions"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
885
1389
  <div id="versionPanel" class="list"></div>
1390
+ <h2 class="version-update-title">Agent update jobs</h2>
1391
+ <div id="agentUpdateJobs" class="list"></div>
886
1392
  </div>
887
1393
  </section>
888
1394
 
@@ -896,8 +1402,8 @@ function renderDashboardApp(options) {
896
1402
 
897
1403
  <section class="page" id="page-logs">
898
1404
  <div class="panel">
899
- <div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">Update</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logSince" type="datetime-local" title="Show entries after this time"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><label class="checkbox"><input id="logFollow" type="checkbox"> Follow</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button></div>
900
- <pre id="logs"></pre>
1405
+ <div class="row"><select id="logTarget"><option value="connector">Connector</option><option value="update">NordRelay Update</option><option value="agent-updates">Agent Updates</option></select><select id="logLevel"><option value="all">All levels</option><option value="ERROR">Error</option><option value="WARN">Warn</option><option value="INFO">Info</option></select><input id="logSearch" placeholder="Search logs"><input id="logSince" type="datetime-local" title="Show entries after this time"><input id="logLines" type="number" value="120" min="1" max="300"><label class="checkbox"><input id="logAutoRefresh" type="checkbox"> Auto</label><label class="checkbox"><input id="logFollow" type="checkbox"> Follow</label><button id="loadLogsBtn">Load logs</button><button id="downloadLogsBtn" class="secondary">Download</button><button id="clearLogsBtn" class="danger">Clear</button></div>
1406
+ <pre id="logs" class="log-view"></pre>
901
1407
  </div>
902
1408
  </section>
903
1409
 
@@ -908,7 +1414,7 @@ function renderDashboardApp(options) {
908
1414
  <footer>
909
1415
  <span id="footerVersion">NordRelay</span>
910
1416
  <span id="footerHealth">Health: loading</span>
911
- <span>Dashboard bind: ${escapeHTML(options.authRequired ? "authenticated" : "local")}</span>
1417
+ <span id="footerUser">User: loading</span>
912
1418
  </footer>
913
1419
  </main>
914
1420
  </div>
@@ -931,242 +1437,16 @@ function renderDashboardApp(options) {
931
1437
  <div id="sessionDetail"></div>
932
1438
  <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
933
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>
1447
+ <div id="toolTooltip" class="tool-tooltip"></div>
934
1448
  <div id="toast"></div>
935
- <script>${dashboardJs()}</script>
1449
+ <script src="/assets/dashboard.js"></script>
936
1450
  </body>
937
1451
  </html>`;
938
1452
  }
939
- function dashboardCss() {
940
- return `
941
- :root{color-scheme:light;--bg:#f4f6f2;--surface:#ffffff;--surface-soft:#fbfcf8;--text:#18201b;--muted:#5d675f;--border:#dce3d9;--border-soft:#e7ede4;--sidebar:#17251d;--sidebar-text:#f4f8f2;--sidebar-muted:#aebcaf;--accent:#235c42;--accent-strong:#17452f;--accent-soft:#dff5e8;--warn:#fff7da;--danger:#9b1c1c;--pre:#111812;--pre-text:#f3f7ef;--shadow:0 8px 24px rgba(24,32,27,.04);--link:#1d6a4c}
942
- :root[data-theme="dark"]{color-scheme:dark;--bg:#101411;--surface:#171d19;--surface-soft:#1d251f;--text:#edf4ee;--muted:#a7b3aa;--border:#2d3830;--border-soft:#263128;--sidebar:#0c120f;--sidebar-text:#edf7ef;--sidebar-muted:#8da091;--accent:#4fa876;--accent-strong:#64bd89;--accent-soft:#173d2a;--warn:#3b3216;--danger:#cc4b4b;--pre:#070a08;--pre-text:#e8f1ea;--shadow:0 10px 28px rgba(0,0,0,.22);--link:#75c99a}
943
- .agent-settings-nav{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 12px;padding:10px;border:1px solid var(--border-soft);border-radius:8px;background:var(--surface)}.agent-settings-nav strong{font-size:13px;color:var(--muted);margin-right:4px}.agent-settings-nav button{background:var(--surface);color:var(--text);border-color:var(--border);height:32px}.agent-settings-nav button.active{background:var(--accent);color:white;border-color:var(--accent)}@media(max-width:560px){.agent-settings-nav{align-items:stretch}.agent-settings-nav button{width:100%}}
944
- .drop-active{outline:2px dashed var(--accent);outline-offset:-8px}.chip{display:inline-flex;align-items:center;border-radius:999px;border:1px solid var(--border);padding:2px 8px;font-size:12px;color:var(--muted);margin-right:6px}.chip.error{color:var(--danger);border-color:var(--danger)}.chip.warn{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.gallery{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:10px;margin-top:12px}.artifact-card{border:1px solid var(--border-soft);border-radius:8px;padding:8px;background:var(--surface-soft);min-width:0}.artifact-card img{width:100%;aspect-ratio:1.4;object-fit:cover;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.artifact-card small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.setting.dirty{border-color:var(--accent)}.setting-actions{display:flex;gap:8px;align-items:center;margin-top:8px}.setting-help{font-size:12px;color:var(--muted)}.restart-banner{border:1px solid #d9c27a;background:var(--warn);border-radius:8px;padding:10px;margin:0 0 12px}.task-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:10px}.log-line{display:block}.log-line.ERROR{color:var(--danger);font-weight:700}.log-line.WARN{color:#8a6a12;font-weight:700}.connection-ok{color:#1e754e;border-color:#8ed0aa}.connection-warn{color:#8a6a12;border-color:#d9c27a}.connection-error{color:var(--danger);border-color:var(--danger)}
945
- *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}.app{min-height:100vh;display:grid;grid-template-columns:260px 1fr}.sidebar{background:var(--sidebar);color:var(--sidebar-text);padding:18px;display:flex;flex-direction:column;gap:22px}.brand{display:flex;align-items:center;gap:12px}.mark{display:grid;place-items:center;width:38px;height:38px;border-radius:8px;background:#d7ffe5;color:#173d29;font-weight:800}.brand small{display:block;color:var(--sidebar-muted)}nav{display:flex;flex-direction:column;gap:6px}nav button,.menu{border:0;border-radius:6px;padding:10px 12px;background:transparent;color:inherit;text-align:left;font:inherit;cursor:pointer}nav button.active,nav button:hover{background:color-mix(in srgb,var(--accent) 35%,transparent)}main{min-width:0;display:flex;flex-direction:column}header{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:16px;align-items:center;padding:16px 22px;background:color-mix(in srgb,var(--surface) 92%,transparent);backdrop-filter:blur(12px);border-bottom:1px solid var(--border)}h1{font-size:24px;margin:0}h2{font-size:16px;margin:0 0 12px}p{margin:4px 0 0;color:var(--muted)}a{color:var(--link)}.header-actions,.row,.chat-toolbar,.attachment-row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.menu{display:none;background:var(--surface-soft);color:var(--text)}.page{display:none;padding:22px}.page.active{display:block}.stack{display:flex;flex-direction:column;gap:16px}.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:12px;margin-bottom:16px}.metric,.panel{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:16px;box-shadow:var(--shadow)}.metric .label{font-size:12px;text-transform:uppercase;color:var(--muted)}.metric .value{font-size:22px;font-weight:750;margin-top:4px;overflow:hidden;text-overflow:ellipsis}button,select,input,textarea{border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit}button{height:36px;padding:0 12px;background:var(--accent);color:white;border-color:var(--accent);cursor:pointer}button:hover{background:var(--accent-strong)}button.secondary{background:var(--surface);color:var(--text)}input,select{height:36px;padding:0 10px}textarea{width:100%;padding:10px;resize:vertical}.chat-layout{display:grid;grid-template-columns:minmax(0,1fr) 330px;gap:16px;align-items:start}.chat-panel{height:calc(100vh - 170px);min-height:520px;display:flex;flex-direction:column}.control-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:8px;margin:12px 0}.control-grid label,.form-grid label{display:grid;gap:5px;font-size:12px;color:var(--muted)}.messages{flex:1;min-height:0;overflow:auto;border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.message{margin:0 0 12px;padding:10px 12px;border-radius:8px;max-width:92%;white-space:pre-wrap;word-break:break-word}.message.user{margin-left:auto;background:var(--accent-soft)}.message.agent{background:color-mix(in srgb,var(--surface-soft) 80%,var(--border))}.message.system{background:var(--warn)}.composer{display:grid;grid-template-columns:1fr auto;gap:10px;margin-top:12px}.composer-fields{min-width:0}.composer button{height:auto;min-width:90px}.attachment-row{margin-top:8px;color:var(--muted);font-size:13px}.file-button{display:inline-flex;align-items:center;height:34px;padding:0 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);cursor:pointer}input[type=file]{display:none}.sessions-toolbar{display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap}.sessions-toolbar .search-row{flex:1 1 320px}.sessions-toolbar .attach-row{flex:1 1 360px;justify-content:flex-end;margin-left:auto}.sessions-toolbar input{min-width:220px}.copy-id{height:auto;padding:0;border:0;background:transparent;color:var(--link);font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:12px}.copy-id:hover{background:transparent;text-decoration:underline}.side-panel{max-height:calc(100vh - 126px);display:flex;flex-direction:column}.tool-stream{display:flex;flex-direction:column;gap:8px;overflow:auto;max-height:calc(100vh - 190px);padding-right:4px}.tool{border:1px solid var(--border-soft);border-radius:6px;padding:8px;background:var(--surface-soft);white-space:pre-wrap;word-break:break-word}.list{display:flex;flex-direction:column;gap:8px;margin-top:12px}.item{border:1px solid var(--border-soft);border-radius:8px;padding:12px;background:var(--surface-soft)}.item strong{display:block;overflow-wrap:anywhere}.item small{display:block;color:var(--muted);overflow-wrap:anywhere}.queue-item{cursor:grab}.queue-item.dragging{opacity:.55}.badge,.adapter-status{display:inline-flex;align-items:center;border:1px solid var(--border);border-radius:999px;padding:2px 8px;color:var(--muted);font-size:12px}.adapter-status{margin-left:6px;text-transform:capitalize}.adapter-status.enabled,.adapter-status.available{color:#1e754e;border-color:#8ed0aa;background:color-mix(in srgb,var(--accent-soft) 55%,transparent)}.adapter-status.disabled{color:var(--muted)}.adapter-status.planned{color:#8a6a12;border-color:#d9c27a;background:var(--warn)}.preview{margin-top:12px}.preview img{max-width:100%;border:1px solid var(--border);border-radius:8px;background:var(--surface)}.settings-grid{display:block}.setting{border:1px solid var(--border-soft);border-radius:8px;padding:12px;margin-bottom:10px;background:var(--surface-soft)}.setting label{display:block;font-size:13px;font-weight:700;margin-bottom:6px}.setting small{display:block;color:var(--muted);margin-top:6px}.setting input,.setting textarea,.setting select{width:100%}.setting-error{color:var(--danger);font-size:12px;margin-top:6px}.checkbox{display:inline-flex!important;grid-template-columns:auto 1fr!important;align-items:center;gap:8px}.checkbox input{height:auto;width:auto}.tabs{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.tabs button{background:var(--surface);color:var(--text);border-color:var(--border);height:34px}.tabs button.active{background:var(--accent);color:white;border-color:var(--accent)}.pager{display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px;color:var(--muted)}.pager-actions{display:flex;gap:8px}.pager button:disabled{opacity:.45;cursor:not-allowed}pre{white-space:pre-wrap;word-break:break-word;background:var(--pre);color:var(--pre-text);border-radius:8px;padding:14px;overflow:auto}footer{margin-top:auto;display:flex;gap:18px;flex-wrap:wrap;padding:14px 22px;border-top:1px solid var(--border);color:var(--muted);background:var(--surface)}dialog{border:1px solid var(--border);border-radius:8px;background:var(--surface);color:var(--text);width:min(720px,calc(100vw - 28px));padding:18px;box-shadow:0 18px 70px rgba(0,0,0,.22)}dialog::backdrop{background:rgba(0,0,0,.35)}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px}.dialog-actions{justify-content:flex-end;margin-top:16px}#toast{position:fixed;right:18px;bottom:18px;display:none;background:var(--accent);color:white;border-radius:8px;padding:12px 14px;max-width:360px}.danger{background:var(--danger);border-color:var(--danger);color:white}@media(max-width:860px){.app{display:block}.sidebar{position:fixed;inset:0 auto 0 0;width:270px;transform:translateX(-100%);transition:.18s transform;z-index:20}.sidebar.open{transform:translateX(0)}.menu{display:inline-block}.header-actions{justify-content:flex-end}.page{padding:14px}.chat-layout{grid-template-columns:1fr}.chat-panel{height:auto;min-height:0}.messages{max-height:55vh;min-height:300px}.composer{grid-template-columns:1fr}.composer button{height:40px}.side-panel{order:-1;max-height:360px}.tool-stream{max-height:300px}header{align-items:flex-start}.metrics{grid-template-columns:1fr 1fr}}@media(max-width:560px){.metrics{grid-template-columns:1fr}.row{align-items:stretch}.row>*{width:100%}header{display:grid;grid-template-columns:auto 1fr}.header-actions{grid-column:1/3}.message{max-width:100%}.pager{align-items:stretch}.pager-actions,.pager button{width:100%}.attachment-row>*,.sessions-toolbar,.sessions-toolbar .row,.sessions-toolbar input,.sessions-toolbar button{width:100%}.sessions-toolbar .attach-row{margin-left:0;justify-content:stretch}}
946
- `;
947
- }
948
- function dashboardJs() {
949
- return `
950
- const token = localStorage.getItem('nordrelayDashboardToken') || '';
951
- const state = { snapshot:null, controls:null, newSessionControls:null, enabledAgents:[], settings:[], currentPage:'overview', settingsGroup:null, logsPlain:'', logTimer:null, toastTimer:null, cliStatusActive:false, selectedArtifactTurns:new Set(), mediaRecorder:null, recordedChunks:[], events:null, reconnectTimer:null, notifications:false };
952
- const authHeaders = () => token ? { authorization: 'Bearer ' + token } : {};
953
- async function api(path, options={}) {
954
- const headers = { ...(options.body ? {'content-type':'application/json'} : {}), ...authHeaders(), ...(options.headers||{}) };
955
- const res = await fetch(path, { ...options, headers });
956
- if (res.status === 401) { location.reload(); return; }
957
- const text = await res.text();
958
- const data = text ? JSON.parse(text) : {};
959
- if (!res.ok) throw new Error(data.error || res.statusText);
960
- return data;
961
- }
962
- function toast(msg,options={}){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';if(state.toastTimer)clearTimeout(state.toastTimer);state.toastTimer=null;if(!options.sticky){state.toastTimer=setTimeout(()=>{el.style.display='none';state.toastTimer=null},options.duration||3500)}}
963
- function esc(s){return String(s??'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]))}
964
- function attr(s){return esc(s).replace(/"/g,'&quot;')}
965
- function cssEscape(s){return window.CSS&&CSS.escape?CSS.escape(s):String(s).replace(/[^a-zA-Z0-9_-]/g,'\\\\$&')}
966
- function short(s,max=250){const text=String(s??'');return text.length>max?text.slice(0,max-1)+'...':text}
967
- async function copyText(text,label='Copied'){if(!text)return;try{await navigator.clipboard.writeText(text)}catch{const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();document.execCommand('copy');area.remove()}toast(label)}
968
- function fmtDate(s){return s?new Date(s).toLocaleString(): '-'}
969
- function fmtDuration(ms){if(!ms&&ms!==0)return '-';const sec=Math.round(ms/1000);if(sec<60)return sec+'s';return Math.floor(sec/60)+'m '+(sec%60)+'s'}
970
- function fmtBytes(n){if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1).replace(/\\.0$/,'')+' KB';return (n/1048576).toFixed(1).replace(/\\.0$/,'')+' MB'}
971
- function compactNum(n){if(!n)return'';if(n>=1000000000)return Math.round(n/100000000)/10+'B';if(n>=1000000)return Math.round(n/100000)/10+'M';if(n>=1000)return Math.round(n/100)/10+'K';return String(n)}
972
- function modelLabel(m){const meta=[m.contextWindow?compactNum(m.contextWindow):'',m.supportsImages===true?'img':m.supportsImages===false?'text':'',m.supportsThinking===true?'think':''].filter(Boolean).join(' ');return (m.displayName||m.slug)+(meta?' · '+meta:'')}
973
- function fmtAge(ms){const sec=Math.max(0,Math.floor(ms/1000));if(sec<60)return sec+'s ago';const min=Math.floor(sec/60);if(min<60)return min+'m ago';return Math.floor(min/60)+'h ago'}
974
- function isCliRunningStatus(msg){return / CLI running\\b/.test(String(msg||''))}
975
- function isCliDoneStatus(msg){return / CLI task\\b/.test(String(msg||''))}
976
- function applyTheme(theme){document.documentElement.dataset.theme=theme;localStorage.setItem('nordrelayTheme',theme);document.getElementById('themeBtn').textContent=theme==='dark'?'Light':'Dark'}
977
- function toggleTheme(){applyTheme(document.documentElement.dataset.theme==='dark'?'light':'dark')}
978
- function page(name){state.currentPage=name;document.querySelectorAll('nav button').forEach(b=>b.classList.toggle('active',b.dataset.page===name));document.querySelectorAll('.page').forEach(p=>p.classList.toggle('active',p.id==='page-'+name));document.getElementById('pageTitle').textContent=name[0].toUpperCase()+name.slice(1);document.getElementById('sidebar').classList.remove('open'); if(name==='sessions') loadSessions(); if(name==='settings') loadSettings(); if(name==='logs') loadLogs(); if(name==='diagnostics') loadDiagnostics(); if(name==='artifacts') loadArtifacts(); if(name==='activity') loadActivity(); if(name==='tasks') loadTasks(); if(name==='adapters') loadAdapterHealth(); if(name==='access') loadAccess(); if(name==='version') loadVersion();}
979
- document.querySelectorAll('nav button').forEach(b=>b.onclick=()=>page(b.dataset.page));
980
- document.getElementById('menuBtn').onclick=()=>document.getElementById('sidebar').classList.toggle('open');
981
- document.getElementById('refreshBtn').onclick=()=>loadBootstrap();
982
- document.getElementById('themeBtn').onclick=toggleTheme;
983
- applyTheme(localStorage.getItem('nordrelayTheme') || 'light');
984
-
985
- function createPaginator(containerId, onChange, pageSize=50){
986
- const container=document.getElementById(containerId);
987
- return {
988
- page:1,
989
- pageSize,
990
- reset(){this.page=1},
991
- render(meta={}){
992
- const hasPrevious=Boolean(meta.hasPrevious);
993
- const hasNext=Boolean(meta.hasNext);
994
- container.innerHTML='<span>Page '+this.page+' / '+this.pageSize+' per page</span><div class="pager-actions"><button data-page-action="prev" '+(!hasPrevious?'disabled':'')+'>Previous</button><button data-page-action="next" '+(!hasNext?'disabled':'')+'>Next</button></div>';
995
- const prev=container.querySelector('[data-page-action="prev"]');
996
- const next=container.querySelector('[data-page-action="next"]');
997
- prev.onclick=()=>{if(hasPrevious){this.page-=1;onChange()}};
998
- next.onclick=()=>{if(hasNext){this.page+=1;onChange()}};
999
- }
1000
- };
1001
- }
1002
- const sessionsPager=createPaginator('sessionsPager',()=>loadSessions(false),50);
1003
-
1004
- async function loadBootstrap(){
1005
- const data = await api('/api/bootstrap');
1006
- state.snapshot = data.status.snapshot;
1007
- state.controls = data.controls;
1008
- state.enabledAgents = data.enabledAgents || [];
1009
- renderSnapshot(state.snapshot);
1010
- renderSessionControls();
1011
- populateNewSessionForm(data.enabledAgents);
1012
- renderAdapters(data.channels, data.agentAdapters);
1013
- document.getElementById('footerVersion').textContent='NordRelay '+(data.status.health?.version || '');
1014
- document.getElementById('footerHealth').textContent='Health: '+(data.status.health?.state?.status || 'unknown');
1015
- const agentSelect=document.getElementById('agentSelect');
1016
- agentSelect.innerHTML=data.enabledAgents.map(a=>'<option value="'+a+'">'+a+'</option>').join('');
1017
- agentSelect.value=state.snapshot.session.agentId;
1018
- agentSelect.onchange=()=>safe(async()=>{await api('/api/agent',{method:'POST',body:JSON.stringify({agentId:agentSelect.value})});toast('Agent switched');await loadBootstrap();await loadChatHistory()});
1019
- }
1020
- function renderSnapshot(s){
1021
- document.getElementById('sessionLine').textContent=(s.session.agentLabel||'Agent')+' / '+(s.session.model||'default')+' / '+(s.session.threadId||'not started');
1022
- document.getElementById('sessionText').textContent=s.sessionText||'';
1023
- document.getElementById('metrics').innerHTML=[
1024
- ['Status',s.processing?'working':'idle'],['Agent',s.session.agentLabel],['Queue',s.queue.length],['Workspace',s.session.workspace],['Thread',s.session.threadId||'not started'],['Reasoning',s.session.reasoningEffort||'default'],['Fast',s.session.capabilities&&s.session.capabilities.fastMode?(s.session.fastMode?'on':'off'):'n/a']
1025
- ].map(([k,v])=>'<div class="metric"><div class="label">'+esc(k)+'</div><div class="value">'+esc(v)+'</div></div>').join('');
1026
- renderQueue(s.queue,s.queuePaused);
1027
- }
1028
- function renderSessionControls(){
1029
- const c=state.controls||{};const s=state.snapshot?.session||{};const caps=c.capabilities||{};
1030
- const modelOptions=['<option value="">Default</option>'].concat((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'" '+(m.slug===s.model?'selected':'')+'>'+esc(modelLabel(m))+'</option>')).join('');
1031
- const reasoningOptions=(c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'" '+(v===s.reasoningEffort?'selected':'')+'>'+esc(v)+'</option>').join('');
1032
- const launchOptions=(c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'" '+(p.id===(s.nextLaunchProfileId||s.launchProfileId)?'selected':'')+'>'+esc(p.label+' - '+p.behavior+(p.unsafe?' - unsafe':''))+'</option>').join('');
1033
- document.getElementById('sessionControls').innerHTML=[
1034
- caps.modelSelection?'<label>Model<select id="controlModel">'+modelOptions+'</select></label>':'',
1035
- caps.reasoningSelection?'<label>'+esc(c.reasoningLabel||'Reasoning')+'<select id="controlReasoning">'+reasoningOptions+'</select></label>':'',
1036
- caps.launchProfiles?'<label>Launch<select id="controlLaunch">'+launchOptions+'</select></label>':'',
1037
- caps.fastMode?'<label class="checkbox"><input id="controlFast" type="checkbox" '+(s.fastMode?'checked':'')+'> Fast mode</label>':''
1038
- ].join('');
1039
- const model=document.getElementById('controlModel'); if(model) model.onchange=()=>safe(async()=>{if(model.value){await api('/api/session/model',{method:'POST',body:JSON.stringify({model:model.value})});toast('Model updated');loadBootstrap()}});
1040
- const reasoning=document.getElementById('controlReasoning'); if(reasoning) reasoning.onchange=()=>safe(async()=>{await api('/api/session/reasoning',{method:'POST',body:JSON.stringify({reasoning:reasoning.value})});toast((c.reasoningLabel||'Reasoning')+' updated');loadBootstrap()});
1041
- const launch=document.getElementById('controlLaunch'); if(launch) launch.onchange=()=>safe(async()=>{await api('/api/session/launch',{method:'POST',body:JSON.stringify({profileId:launch.value})});toast('Launch profile updated');loadBootstrap()});
1042
- const fast=document.getElementById('controlFast'); if(fast) fast.onchange=()=>safe(async()=>{await api('/api/session/fast',{method:'POST',body:JSON.stringify({enabled:fast.checked})});toast('Fast mode updated');loadBootstrap()});
1043
- }
1044
- function renderAdapters(channels, agents){
1045
- const channelCards=(channels||[]).map(c=>adapterCard(c.label,c.status,c.capabilities.join(', ')));
1046
- const agentCards=(agents||[]).map(a=>{const available=a.status==='available';const status=available?(state.enabledAgents.includes(a.id)?'enabled':'disabled'):(a.status||'planned');const detail=[available?'integrated':(a.status||'planned'),a.envFlag,a.notes].filter(Boolean).join(' / ');return adapterCard(a.label,status,detail)});
1047
- document.getElementById('adapters').innerHTML='<div class="list">'+channelCards.concat(agentCards).join('')+'</div>';
1048
- }
1049
- function adapterCard(label,status,detail){return '<div class="item"><strong>'+esc(label)+' <span class="adapter-status '+esc(status)+'">'+esc(status)+'</span></strong><small>'+esc(detail||'')+'</small></div>'}
1050
- function appendMessage(cls,text){const box=document.getElementById('messages');const div=document.createElement('div');div.className='message '+cls;div.textContent=text;box.appendChild(div);box.scrollTop=box.scrollHeight;return div}
1051
- function appendQueuedMessage(id){const div=appendMessage('system','Queued prompt '+id);const btn=document.createElement('button');btn.textContent='Cancel queued message';btn.className='danger';btn.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:'cancel',id})});renderQueue(r.queue,r.paused);div.textContent='Cancelled queued prompt '+id});div.appendChild(document.createElement('br'));div.appendChild(btn)}
1052
- function renderChatMessages(messages){state.chatMessages=messages||[];const box=document.getElementById('messages');box.innerHTML=(messages||[]).map(m=>'<div class="message '+esc(m.role)+'"><small>'+esc((m.source||'web')+' / '+fmtDate(m.timestamp))+'</small>\\n'+esc(m.text)+'</div>').join('');box.scrollTop=box.scrollHeight}
1053
- async function loadChatHistory(){const data=await api('/api/chat/history');renderChatMessages(data.messages||[])}
1054
- let currentAgentMessage=null;
1055
- function connectEvents(){
1056
- if(state.events) state.events.close();
1057
- const qs = token ? '?token='+encodeURIComponent(token) : '';
1058
- const events = new EventSource('/api/events'+qs);
1059
- state.events=events;
1060
- setConnection('Connecting','warn');
1061
- events.onopen=()=>{if(state.reconnectTimer){clearTimeout(state.reconnectTimer);state.reconnectTimer=null}setConnection('Live','ok')};
1062
- events.addEventListener('snapshot', e=>{const d=JSON.parse(e.data).data;state.snapshot=d;renderSnapshot(d);renderSessionControls()});
1063
- events.addEventListener('chat_history', e=>renderChatMessages(JSON.parse(e.data).messages||[]));
1064
- events.addEventListener('activity_update', e=>renderActivity(JSON.parse(e.data).events||[]));
1065
- events.addEventListener('session_update', e=>{loadBootstrap();loadChatHistory()});
1066
- events.addEventListener('queue_update', e=>{const d=JSON.parse(e.data);renderQueue(d.queue,d.paused)});
1067
- events.addEventListener('turn_start', e=>{const d=JSON.parse(e.data);appendMessage('user',d.prompt);currentAgentMessage=appendMessage('agent','');if(state.currentPage==='tasks')loadTasks()});
1068
- events.addEventListener('text_delta', e=>{const d=JSON.parse(e.data);if(!currentAgentMessage)currentAgentMessage=appendMessage('agent','');currentAgentMessage.textContent+=d.delta;currentAgentMessage.scrollIntoView({block:'end'});if(state.currentPage==='tasks')loadTasks()});
1069
- events.addEventListener('tool_start', e=>{const d=JSON.parse(e.data);tool('tool','Started '+d.toolName);if(state.currentPage==='tasks')loadTasks()});
1070
- events.addEventListener('tool_update', e=>{const d=JSON.parse(e.data);if(d.partialResult)tool('tool',d.partialResult.slice(-600))});
1071
- events.addEventListener('tool_end', e=>{const d=JSON.parse(e.data);tool(d.isError?'danger':'tool','Finished '+d.toolCallId+(d.isError?' with error':''))});
1072
- events.addEventListener('todo_update', e=>{const d=JSON.parse(e.data);tool('tool','Plan:\\n'+d.items.map(i=>(i.completed?'[x] ':'[ ] ')+i.text).join('\\n'))});
1073
- events.addEventListener('turn_error', e=>{const d=JSON.parse(e.data);appendMessage('system','Error: '+d.error);currentAgentMessage=null});
1074
- events.addEventListener('turn_complete', ()=>{currentAgentMessage=null;notify('NordRelay turn finished','The active task completed.');loadBootstrap();if(state.currentPage==='tasks')loadTasks()});
1075
- events.addEventListener('status', e=>{const d=JSON.parse(e.data);const msg=d.message||'';if(isCliRunningStatus(msg)){state.cliStatusActive=true;toast(msg,{sticky:true});return}if(isCliDoneStatus(msg))state.cliStatusActive=false;toast(msg)});
1076
- events.onerror=()=>{setConnection('Reconnecting','error');if(!state.reconnectTimer)state.reconnectTimer=setTimeout(()=>{state.reconnectTimer=null;connectEvents()},5000)};
1077
- }
1078
- function setConnection(text,kind){const el=document.getElementById('connectionStatus');el.textContent=text;el.className='badge connection-'+kind}
1079
- async function enableNotifications(){if(!('Notification' in window)){toast('Browser notifications are not supported');return}const permission=Notification.permission==='granted'?'granted':await Notification.requestPermission();state.notifications=permission==='granted';toast(state.notifications?'Browser notifications enabled':'Browser notifications denied')}
1080
- function notify(title,body){if(state.notifications&&'Notification' in window&&Notification.permission==='granted')new Notification(title,{body})}
1081
- function updateToolAgeTitles(){document.querySelectorAll('.tool[data-created-at]').forEach(el=>{const created=Number(el.dataset.createdAt||Date.now());el.title='Updated '+fmtAge(Date.now()-created)})}
1082
- function tool(cls,text){const div=document.createElement('div');div.className='tool '+(cls==='danger'?'danger':'');div.dataset.createdAt=String(Date.now());div.textContent=text;document.getElementById('toolStream').prepend(div);updateToolAgeTitles()}
1083
- setInterval(updateToolAgeTitles,30000);
1084
- let selectedFiles=[];
1085
- function renderSelectedFiles(){const summary=document.getElementById('fileSummary');if(selectedFiles.length===0){summary.textContent='No files selected';return}const names=selectedFiles.slice(0,3).map(f=>f.name || 'file').join(', ');const more=selectedFiles.length>3?' +'+(selectedFiles.length-3)+' more':'';const bytes=selectedFiles.reduce((sum,file)=>sum+file.size,0);summary.textContent=names+more+' ('+fmtBytes(bytes)+')'}
1086
- function addFiles(files){selectedFiles=selectedFiles.concat(Array.from(files||[]));renderSelectedFiles()}
1087
- async function filePayload(file){return {name:file.name || 'upload',mimeType:file.type || 'application/octet-stream',dataBase64:await fileToBase64(file)}}
1088
- async function fileToBase64(file){const buffer=await file.arrayBuffer();const bytes=new Uint8Array(buffer);let binary='';const chunk=0x8000;for(let i=0;i<bytes.length;i+=chunk){binary+=String.fromCharCode(...bytes.subarray(i,i+chunk))}return btoa(binary)}
1089
- document.getElementById('fileInput').onchange=e=>{addFiles(e.target.files)};
1090
- document.getElementById('clearFilesBtn').onclick=()=>{selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles()};
1091
- document.addEventListener('paste',e=>{const files=Array.from(e.clipboardData?.files||[]);if(files.length){addFiles(files);toast('Pasted '+files.length+' file(s)')}});
1092
- document.addEventListener('dragover',e=>{e.preventDefault();document.body.classList.add('drop-active')});
1093
- document.addEventListener('dragleave',()=>document.body.classList.remove('drop-active'));
1094
- document.addEventListener('drop',e=>{e.preventDefault();document.body.classList.remove('drop-active');const files=Array.from(e.dataTransfer?.files||[]);if(files.length){addFiles(files);toast('Added '+files.length+' dropped file(s)')}});
1095
- document.getElementById('promptForm').onsubmit=e=>safe(async()=>{e.preventDefault();const input=document.getElementById('promptInput');const text=input.value.trim();if(!text&&selectedFiles.length===0)return;const files=selectedFiles;input.value='';selectedFiles=[];document.getElementById('fileInput').value='';renderSelectedFiles();const payloadFiles=files.length?await Promise.all(files.map(filePayload)):[];const r=files.length?await api('/api/prompt/upload',{method:'POST',body:JSON.stringify({text,files:payloadFiles})}):await api('/api/prompt',{method:'POST',body:JSON.stringify({text})});if(r.transcribeOnly)appendMessage('system','Transcribed audio:\\n'+(r.transcript||'(empty)'));else if(r.queued)appendQueuedMessage(r.queueId)},e);
1096
- document.getElementById('newSessionBtn').onclick=()=>openNewSessionDialog();
1097
- document.getElementById('retryBtn').onclick=()=>safe(async()=>{const r=await api('/api/retry',{method:'POST'});toast(r.queued?'Retry queued '+r.queueId:'Retry started')});
1098
- document.getElementById('editLastBtn').onclick=()=>{const last=[...(state.chatMessages||[])].reverse().find(m=>m.role==='user');if(last){document.getElementById('promptInput').value=last.text;document.getElementById('promptInput').focus()}else toast('No user prompt found')};
1099
- document.getElementById('syncBtn').onclick=()=>safe(async()=>{const r=await api('/api/sync',{method:'POST'});toast(r.changed?'Synced: '+(r.changedFields||[]).join(', '):'Already in sync');loadBootstrap()});
1100
- document.getElementById('notifyBtn').onclick=()=>enableNotifications();
1101
- document.getElementById('clearChatBtn').onclick=()=>safe(async()=>{if(confirm('Clear chat history for the current thread?')){const r=await api('/api/chat/history',{method:'DELETE'});renderChatMessages(r.messages||[]);toast('Removed '+r.removed+' messages')}});
1102
- document.getElementById('abortBtn').onclick=()=>safe(async()=>{await api('/api/abort',{method:'POST'});toast('Abort sent')});
1103
- document.getElementById('handbackBtn').onclick=()=>safe(async()=>{const r=await api('/api/handback',{method:'POST'});appendMessage('system','Handback command:\\n'+(r.command||'No command available'))});
1104
- document.getElementById('recordBtn').onclick=()=>safe(async()=>{const btn=document.getElementById('recordBtn');if(state.mediaRecorder&&state.mediaRecorder.state==='recording'){state.mediaRecorder.stop();btn.textContent='Record voice';return}const stream=await navigator.mediaDevices.getUserMedia({audio:true});state.recordedChunks=[];state.mediaRecorder=new MediaRecorder(stream);state.mediaRecorder.ondataavailable=e=>{if(e.data.size>0)state.recordedChunks.push(e.data)};state.mediaRecorder.onstop=()=>{stream.getTracks().forEach(t=>t.stop());const blob=new Blob(state.recordedChunks,{type:'audio/webm'});addFiles([new File([blob],'voice-note.webm',{type:'audio/webm'})]);toast('Voice note attached')};state.mediaRecorder.start();btn.textContent='Stop recording'});
1105
- function renderNewSessionControls(c){const s=state.snapshot?.session||{};const caps=c.capabilities||{};document.getElementById('workspaceOptions').innerHTML=(c.workspaces||[]).map(w=>'<option value="'+attr(w)+'"></option>').join('');document.getElementById('newModel').innerHTML='<option value="">Default</option>'+((c.models||[]).map(m=>'<option value="'+attr(m.slug)+'">'+esc(modelLabel(m))+'</option>').join(''));document.getElementById('newModel').parentElement.style.display=caps.modelSelection?'grid':'none';const reasoningWrap=document.getElementById('newReasoningWrap');reasoningWrap.firstChild.nodeValue=(c.reasoningLabel||'Reasoning');reasoningWrap.style.display=caps.reasoningSelection?'grid':'none';document.getElementById('newReasoning').innerHTML='<option value="">Default</option>'+((c.reasoningOptions||[]).map(v=>'<option value="'+attr(v)+'">'+esc(v)+'</option>').join(''));document.getElementById('newLaunch').innerHTML='<option value="">Default</option>'+((c.launchProfiles||[]).map(p=>'<option value="'+attr(p.id)+'">'+esc(p.label+' - '+p.behavior)+'</option>').join(''));document.getElementById('newFast').checked=Boolean(s.fastMode&&caps.fastMode);document.getElementById('newLaunchWrap').style.display=caps.launchProfiles?'grid':'none';document.getElementById('newFastWrap').style.display=caps.fastMode?'inline-flex':'none'}
1106
- function populateNewSessionForm(agents){const s=state.snapshot?.session||{};const agentSelect=document.getElementById('newAgent');agentSelect.innerHTML=(agents||[]).map(a=>'<option value="'+attr(a)+'" '+(a===s.agentId?'selected':'')+'>'+esc(a)+'</option>').join('');agentSelect.value=s.agentId||agentSelect.value;document.getElementById('newWorkspace').value=s.workspace||'';state.newSessionControls=state.controls||{};renderNewSessionControls(state.newSessionControls);agentSelect.onchange=()=>safe(async()=>{state.newSessionControls=await api('/api/control-options?agent='+encodeURIComponent(agentSelect.value));renderNewSessionControls(state.newSessionControls)})}
1107
- function openNewSessionDialog(){populateNewSessionForm(state.enabledAgents);document.getElementById('newSessionDialog').showModal()}
1108
- document.getElementById('newSessionForm').onsubmit=e=>safe(async()=>{e.preventDefault();const payload={agentId:val('newAgent'),workspace:val('newWorkspace')||undefined,model:val('newModel')||undefined,reasoningEffort:val('newReasoning')||undefined,launchProfileId:val('newLaunch')||undefined,fastMode:document.getElementById('newFast').checked};await api('/api/sessions/new',{method:'POST',body:JSON.stringify(payload)});document.getElementById('newSessionDialog').close();toast('New session started');await loadBootstrap();await loadChatHistory()},e);
1109
- document.getElementById('cancelSessionBtn').onclick=()=>document.getElementById('newSessionDialog').close();
1110
- function val(id){return document.getElementById(id).value.trim()}
1111
- async function loadSessions(reset=true){if(reset)sessionsPager.reset();const q=document.getElementById('sessionSearch').value||'';const data=await api('/api/sessions?query='+encodeURIComponent(q)+'&page='+sessionsPager.page+'&limit='+sessionsPager.pageSize);document.getElementById('sessionsList').innerHTML=data.sessions.map(s=>'<div class="item"><strong title="'+attr(s.title||s.firstUserMessage||s.id)+'">'+esc(short(s.title||s.firstUserMessage||s.id))+'</strong><small><button type="button" class="copy-id" data-copy-id="'+attr(s.id)+'" title="Copy thread ID">'+esc(short(s.id,64))+'</button> / '+esc(short((s.cwd||'')+' / '+fmtDate(s.updatedAt)))+'</small><div class="row"><button data-switch="'+attr(s.id)+'">Switch</button><button class="secondary" data-session-detail="'+attr(s.id)+'">Details</button></div></div>').join('')||'<div class="item">No sessions found.</div>';sessionsPager.render(data.pagination||{});document.querySelectorAll('[data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||'','Thread ID copied'));document.querySelectorAll('[data-switch]').forEach(b=>b.onclick=()=>safe(async()=>{await api('/api/sessions/switch',{method:'POST',body:JSON.stringify({threadId:b.dataset.switch})});toast('Session switched');loadBootstrap()}));document.querySelectorAll('[data-session-detail]').forEach(b=>b.onclick=()=>safe(()=>loadSessionDetail(b.dataset.sessionDetail)))}
1112
- async function loadSessionDetail(threadId){const d=await api('/api/sessions/detail?threadId='+encodeURIComponent(threadId));const r=d.record||{};document.getElementById('sessionDetail').innerHTML='<h2>Session detail</h2>'+card('Metadata',[['Thread',threadId],['Agent',r.agentId],['Title',r.title],['Workspace',r.cwd],['Model',r.model],['Reasoning',r.reasoningEffort],['Updated',fmtDate(r.updatedAt)],['Path',r.sessionPath]])+'<h2>Recent messages</h2><div class="list">'+(d.messages||[]).slice(-20).map(m=>'<div class="item"><strong>'+esc(m.role+' / '+fmtDate(m.timestamp))+'</strong><small>'+esc(short(m.text,500))+'</small></div>').join('')+'</div><h2>Activity</h2><div class="list">'+(d.activity||[]).map(e=>'<div class="item"><strong>'+esc(e.status+' / '+e.type+' / '+fmtDate(e.timestamp))+'</strong><small>'+esc(short(e.prompt||e.detail||'',300))+'</small></div>').join('')+'</div>';document.getElementById('sessionDetailDialog').showModal()}
1113
- document.getElementById('closeSessionDetailBtn').onclick=()=>document.getElementById('sessionDetailDialog').close();
1114
- document.getElementById('sessionSearchBtn').onclick=()=>loadSessions(true);document.getElementById('sessionSearch').addEventListener('keydown',e=>{if(e.key==='Enter')loadSessions(true)});document.getElementById('attachBtn').onclick=async()=>{const threadId=document.getElementById('attachInput').value.trim();if(threadId){await api('/api/sessions/attach',{method:'POST',body:JSON.stringify({threadId})});toast('Session attached');loadBootstrap()}};
1115
- function renderQueue(queue,paused){document.getElementById('queueStatus').textContent=paused?'Paused':'Running';document.getElementById('queueList').innerHTML=(queue||[]).map((q,i)=>'<div class="item queue-item" draggable="true" data-queue-id="'+attr(q.id)+'"><strong>'+esc((i+1)+'. '+q.id+' - '+q.description)+'</strong><small>Created '+fmtDate(q.createdAt)+' / attempts '+q.attempts+(q.lastError?' / '+esc(q.lastError):'')+'</small><div class="row"><button data-q="run" data-id="'+q.id+'">Run</button><button data-q="top" data-id="'+q.id+'">Top</button><button data-q="up" data-id="'+q.id+'">Up</button><button data-q="down" data-id="'+q.id+'">Down</button><button data-q="cancel" data-id="'+q.id+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>';document.querySelectorAll('[data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused)}));let dragged=null;document.querySelectorAll('.queue-item').forEach(item=>{item.ondragstart=()=>{dragged=item.dataset.queueId;item.classList.add('dragging')};item.ondragend=()=>item.classList.remove('dragging');item.ondragover=e=>e.preventDefault();item.ondrop=()=>safe(async()=>{if(dragged&&dragged!==item.dataset.queueId){const ids=Array.from(document.querySelectorAll('.queue-item')).map(el=>el.dataset.queueId);const targetIndex=Math.max(0,ids.indexOf(item.dataset.queueId));await api('/api/queue',{method:'POST',body:JSON.stringify({action:'top',id:dragged})});for(let i=0;i<targetIndex;i++)await api('/api/queue',{method:'POST',body:JSON.stringify({action:'down',id:dragged})});const r=await api('/api/queue');renderQueue(r.queue,r.paused)}})})}
1116
- document.querySelectorAll('[data-queue]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.queue})});renderQueue(r.queue,r.paused)}));
1117
- async function loadTasks(){const d=await api('/api/tasks');renderTasks(d)}
1118
- function taskCard(t,title){if(!t)return '<div class="item"><strong>'+esc(title)+'</strong><small>Idle</small></div>';const tools=(t.tools||[]).map(x=>x.name+' x'+x.count).join(', ')||'-';return '<div class="item"><strong>'+esc(title+' · '+t.status)+'</strong><small>'+esc((t.agentLabel||t.agentId||t.source)+' / '+(t.threadId||'-'))+'</small><small>'+esc('Elapsed '+fmtDuration(t.durationMs)+' / current '+(t.currentTool||'-')+' / last '+(t.lastTool||'-'))+'</small><small>'+esc('Tools: '+tools+' / output chars '+(t.outputChars||0))+'</small><small>'+esc(t.prompt||t.detail||'')+'</small></div>'}
1119
- function renderTasks(d){document.getElementById('tasksList').innerHTML='<div class="task-grid">'+taskCard(d.current,'Current web turn')+taskCard(d.external,'External CLI turn')+'</div><h2>Queue</h2><div class="list">'+((d.queue||[]).map(q=>'<div class="item"><strong>'+esc(q.id+' · '+q.description)+'</strong><small>'+esc(fmtDate(q.createdAt)+' / attempts '+q.attempts)+'</small><div class="row"><button data-q="run" data-id="'+attr(q.id)+'">Run</button><button data-q="cancel" data-id="'+attr(q.id)+'" class="danger">Cancel</button></div></div>').join('')||'<div class="item">Queue is empty.</div>')+'</div><h2>Recent turns</h2><div class="list">'+((d.recent||[]).map(e=>'<div class="item"><strong>'+esc(e.status+' / '+e.source+' / '+e.type)+'</strong><small>'+esc(fmtDate(e.timestamp)+' / '+(e.threadId||'-'))+'</small><small>'+esc(short(e.prompt||e.detail||'',300))+'</small></div>').join('')||'<div class="item">No recent tasks.</div>')+'</div>';document.querySelectorAll('#tasksList [data-q]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/queue',{method:'POST',body:JSON.stringify({action:b.dataset.q,id:b.dataset.id})});renderQueue(r.queue,r.paused);loadTasks()}))}
1120
- document.getElementById('reloadTasksBtn').onclick=()=>loadTasks();
1121
- async function loadArtifacts(){const data=await api('/api/artifacts');state.artifactReports=data.reports||[];renderArtifacts()}
1122
- function artifactMatches(a,kind,query){const name=(a.name||a.relativePath||'').toLowerCase();if(query&&!name.includes(query))return false;if(kind==='images')return /\\.(png|jpe?g|gif|webp|svg)$/i.test(name);if(kind==='docs')return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);return true}
1123
- function renderArtifacts(){const query=(document.getElementById('artifactSearch').value||'').toLowerCase();const kind=document.getElementById('artifactKind').value;const reports=state.artifactReports||[];document.getElementById('artifactList').innerHTML=reports.map(r=>{const files=(r.artifacts||[]).filter(a=>artifactMatches(a,kind,query));if(files.length===0)return'';const gallery=files.map(a=>{const href='/api/artifacts/file?turnId='+encodeURIComponent(r.turnId)+'&path='+encodeURIComponent(a.relativePath)+(token?'&token='+encodeURIComponent(token):'');const img=/\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name)?'<img src="'+href+'">':'<pre>'+esc(a.name.split('.').pop()||'file')+'</pre>';return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="'+attr(r.turnId)+'" '+(state.selectedArtifactTurns.has(r.turnId)?'checked':'')+'> '+esc(short(a.name,32))+'</label>'+img+'<small>'+esc(fmtBytes(a.sizeBytes))+'</small><div class="row"><a href="'+href+'">Open</a><button class="secondary" data-preview-turn="'+attr(r.turnId)+'" data-preview-path="'+attr(a.relativePath)+'">Preview</button></div></div>'}).join('');return '<div class="item"><strong>'+esc(r.turnId)+' - '+files.length+'/'+r.fileCount+' files - '+fmtBytes(r.totalSizeBytes)+'</strong><small>'+fmtDate(r.updatedAt)+' / '+esc(r.source||'turn')+'</small><div class="row"><a href="/api/artifacts/zip?turnId='+encodeURIComponent(r.turnId)+(token?'&token='+encodeURIComponent(token):'')+'">Download ZIP</a><button data-del-art="'+esc(r.turnId)+'" class="danger">Delete</button></div><div class="gallery">'+gallery+'</div></div>'}).join('')||'<div class="item">No artifacts.</div>';document.querySelectorAll('[data-artifact-select]').forEach(c=>c.onchange=()=>{if(c.checked)state.selectedArtifactTurns.add(c.dataset.artifactSelect);else state.selectedArtifactTurns.delete(c.dataset.artifactSelect)});document.querySelectorAll('[data-del-art]').forEach(b=>b.onclick=()=>safe(async()=>{if(confirm('Delete artifact turn '+b.dataset.delArt+'?')){await api('/api/artifacts?turnId='+encodeURIComponent(b.dataset.delArt),{method:'DELETE'});state.selectedArtifactTurns.delete(b.dataset.delArt);loadArtifacts()}}));document.querySelectorAll('[data-preview-turn]').forEach(b=>b.onclick=()=>previewArtifact(b.dataset.previewTurn,b.dataset.previewPath))}
1124
- document.getElementById('reloadArtifactsBtn').onclick=loadArtifacts;
1125
- document.getElementById('artifactSearch').oninput=renderArtifacts;
1126
- document.getElementById('artifactKind').onchange=renderArtifacts;
1127
- document.getElementById('zipSelectedArtifactsBtn').onclick=()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}turnIds.forEach(turnId=>window.open('/api/artifacts/zip?turnId='+encodeURIComponent(turnId)+(token?'&token='+encodeURIComponent(token):''),'_blank'))};
1128
- document.getElementById('deleteSelectedArtifactsBtn').onclick=()=>safe(async()=>{const turnIds=[...state.selectedArtifactTurns];if(turnIds.length===0){toast('No artifact turns selected');return}if(confirm('Delete '+turnIds.length+' selected artifact turn(s)?')){const r=await api('/api/artifacts/bulk',{method:'POST',body:JSON.stringify({action:'delete',turnIds})});state.selectedArtifactTurns.clear();toast('Deleted '+(r.removed||[]).length+' artifact turn(s)');loadArtifacts()}});
1129
- function highlightCode(text){return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g,'<span class="chip">$1</span>')}
1130
- async function previewArtifact(turnId,path){const data=await api('/api/artifacts/preview?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path));const target=document.getElementById('artifactPreview');if(data.kind==='image'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><img src="/api/artifacts/file?turnId='+encodeURIComponent(turnId)+'&path='+encodeURIComponent(path)+(token?'&token='+encodeURIComponent(token):'')+'"></div>';return}if(data.kind==='text'){target.innerHTML='<div class="panel"><h2>'+esc(data.name)+' '+fmtBytes(data.sizeBytes)+'</h2><pre>'+highlightCode(data.text||'')+'</pre>'+(data.truncated?'<small>Preview truncated.</small>':'')+'</div>';return}target.innerHTML='<div class="panel"><h2>'+esc(data.name)+'</h2><p>'+esc(data.detail||'Preview unavailable')+'</p></div>'}
1131
- async function loadActivity(){const q='?source='+encodeURIComponent(val('activitySource'))+'&status='+encodeURIComponent(val('activityStatus'))+'&limit='+encodeURIComponent(val('activityLimit')||'100');const data=await api('/api/activity'+q);state.activityEvents=data.events||[];renderActivity(state.activityEvents)}
1132
- function renderActivity(events){const since=val('activitySince')?new Date(val('activitySince')).getTime():0;const filtered=(events||[]).filter(e=>!since||new Date(e.timestamp).getTime()>=since);document.getElementById('activityList').innerHTML=filtered.map(e=>'<div class="item"><strong><span class="chip '+(e.status==='failed'?'error':e.status==='queued'?'warn':'')+'">'+esc(e.status)+'</span>'+esc(fmtDate(e.timestamp)+' / '+e.source+' / '+e.type)+'</strong><small>'+esc(short(e.prompt||e.detail||'',220))+'</small><small><button type="button" class="copy-id" data-copy-id="'+attr(e.threadId||'')+'">'+esc(e.threadId||'-')+'</button> / '+esc((e.workspace||'-')+' / '+fmtDuration(e.durationMs))+'</small></div>').join('')||'<div class="item">No activity.</div>';document.querySelectorAll('#activityList [data-copy-id]').forEach(b=>b.onclick=()=>copyText(b.dataset.copyId||'','Thread ID copied'))}
1133
- document.getElementById('loadActivityBtn').onclick=()=>loadActivity();
1134
- document.getElementById('activitySince').onchange=()=>renderActivity(state.activityEvents||[]);
1135
- document.getElementById('exportActivityBtn').onclick=()=>{const rows=(state.activityEvents||[]).map(e=>[e.timestamp,e.source,e.status,e.type,e.threadId||'',e.prompt||e.detail||''].join('\\t')).join('\\n');const blob=new Blob([rows],{type:'text/tab-separated-values'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-activity.tsv';a.click();URL.revokeObjectURL(a.href)};
1136
- async function loadSettings(){const data=await api('/api/settings');state.settings=data.settings;renderSettings()}
1137
- const settingsGroupOrder=['Agents','Codex','Pi','Hermes','OpenClaw','Claude Code','Telegram','Operations','Artifacts','Workspace','Voice','Dashboard'];
1138
- const agentSettingGroups=['Codex','Pi','Hermes','OpenClaw','Claude Code'];
1139
- function orderedSettingsGroups(groups){const known=settingsGroupOrder.filter(name=>groups[name]);const extra=Object.keys(groups).filter(name=>!settingsGroupOrder.includes(name)).sort();return known.concat(extra)}
1140
- function agentSettingsNav(current){return '<div class="agent-settings-nav"><strong>Agent settings</strong>'+agentSettingGroups.map(name=>'<button type="button" data-setting-tab="'+attr(name)+'" class="'+(name===current?'active':'')+'">'+esc(name)+'</button>').join('')+'</div>'}
1141
- function renderSettings(){const groups={};state.settings.forEach(s=>(groups[s.group]??=[]).push(s));const names=orderedSettingsGroups(groups);if(!state.settingsGroup||!groups[state.settingsGroup])state.settingsGroup=groups.Agents?'Agents':names[0];document.getElementById('settingsTabs').innerHTML=names.map(name=>'<button data-setting-tab="'+attr(name)+'" class="'+(name===state.settingsGroup?'active':'')+'">'+esc(name)+' ('+groups[name].length+')</button>').join('');document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});const items=groups[state.settingsGroup]||[];const nav=(state.settingsGroup==='Agents'||agentSettingGroups.includes(state.settingsGroup))?agentSettingsNav(state.settingsGroup):'';document.getElementById('settingsForm').innerHTML='<div class="settings-section"><h2>'+esc(state.settingsGroup||'Settings')+'</h2><div id="settingsRestartBanner"></div>'+nav+items.map(s=>'<div class="setting" data-setting-box="'+attr(s.key)+'" data-restart-required="'+(s.restartRequired?'true':'false')+'"><label>'+esc(s.label)+'</label>'+settingInput(s)+'<small>'+esc(s.key)+' - '+esc(s.description)+(s.effectiveValue?' Active: '+esc(s.effectiveValue)+'.':'')+(s.restartRequired?' Restart required.':'')+(s.configured?' Configured.':' Inherited/default.')+'</small><div class="setting-actions"><button type="button" class="secondary" data-reset-setting="'+attr(s.key)+'">Use default</button>'+(s.kind==='secret'?'<button type="button" class="secondary" data-reveal-setting="'+attr(s.key)+'">Reveal/replace</button>':'')+'</div><div class="setting-error"></div></div>').join('')+'</div>';document.querySelectorAll('[data-setting-tab]').forEach(b=>b.onclick=()=>{state.settingsGroup=b.dataset.settingTab;renderSettings()});bindSettingsUx()}
1142
- function settingAttrs(s,original){return ' data-setting="'+attr(s.key)+'" data-original-value="'+attr(original)+'" data-configured="'+(s.configured?'true':'false')+'"'}
1143
- function settingInput(s){const display=s.configured?(s.value||''):(s.effectiveValue||''); if(s.options){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option>'+s.options.map(o=>'<option value="'+attr(o)+'" '+(s.configured&&s.value===o?'selected':'')+'>'+esc(o)+'</option>').join('')+'</select>'} if(s.kind==='boolean'){const blankLabel=s.effectiveValue?'Use active default ('+s.effectiveValue+')':'Use active default';return '<select'+settingAttrs(s,s.configured?s.value:'')+'><option value="" '+(!s.configured?'selected':'')+'>'+esc(blankLabel)+'</option><option value="true" '+(s.configured&&s.value==='true'?'selected':'')+'>true</option><option value="false" '+(s.configured&&s.value==='false'?'selected':'')+'>false</option></select>'} const value=esc(display); if(s.kind==='json')return '<textarea rows="4"'+settingAttrs(s,display)+'>'+value+'</textarea>'; return '<input'+settingAttrs(s,display)+' value="'+value+'" '+(s.kind==='secret'?'type="password"':'')+'>'}
1144
- function bindSettingsUx(){document.querySelectorAll('[data-setting]').forEach(el=>{el.oninput=markSettingDirty;el.onchange=markSettingDirty});document.querySelectorAll('[data-reset-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.resetSetting)+'"]');if(input){input.value='';markSettingDirty({target:input})}});document.querySelectorAll('[data-reveal-setting]').forEach(b=>b.onclick=()=>{const input=document.querySelector('[data-setting="'+cssEscape(b.dataset.revealSetting)+'"]');if(input){input.type=input.type==='password'?'text':'password';input.focus()}})}
1145
- function markSettingDirty(e){const el=e.target;const box=el.closest('.setting');const dirty=el.value!==(el.dataset.originalValue??'');box.classList.toggle('dirty',dirty);const dirtyInputs=Array.from(document.querySelectorAll('[data-setting]')).filter(x=>x.value!==(x.dataset.originalValue??''));const restart=dirtyInputs.some(x=>x.closest('.setting')?.dataset.restartRequired==='true');document.getElementById('settingsStatus').textContent=dirtyInputs.length?dirtyInputs.length+' unsaved change(s)':'';const banner=document.getElementById('settingsRestartBanner');if(banner)banner.innerHTML=restart?'<div class="restart-banner">Some changed settings require a NordRelay restart.</div>':''}
1146
- document.getElementById('saveSettingsBtn').onclick=()=>safe(async()=>{document.querySelectorAll('.setting-error').forEach(e=>e.textContent='');const patch={};document.querySelectorAll('[data-setting]').forEach(el=>{const original=el.dataset.originalValue??'';if(el.value!==original)patch[el.dataset.setting]=el.value});const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings:patch})});(r.errors||[]).forEach(err=>{const box=document.querySelector('[data-setting-box="'+cssEscape(err.key)+'"] .setting-error');if(box)box.textContent=err.message});document.getElementById('settingsStatus').textContent=(r.errors&&r.errors.length)?'Fix '+r.errors.length+' setting error(s)':(r.changedKeys.length?'Saved '+r.changedKeys.length+' setting(s)'+(r.restartRequired?' - restart required':''):'No changes');toast((r.errors&&r.errors.length)?'Settings need attention':'Settings saved');if(!(r.errors&&r.errors.length))await loadSettings()});
1147
- document.getElementById('restartBtn').onclick=()=>safe(async()=>{if(confirm('Restart NordRelay now?')){await api('/api/runtime/restart',{method:'POST'});toast('Restart requested')}});
1148
- async function loadAccess(){const d=await api('/api/permissions');document.getElementById('accessPanel').innerHTML=['TELEGRAM_ADMIN_USER_IDS','TELEGRAM_ALLOWED_USER_IDS','TELEGRAM_READONLY_USER_IDS','TELEGRAM_ALLOWED_CHAT_IDS','TELEGRAM_ALLOW_ANY_CHAT','TELEGRAM_ROLE_POLICIES_JSON'].map(key=>{const value=key==='TELEGRAM_ADMIN_USER_IDS'?d.telegramAdminUserIds.join(','):key==='TELEGRAM_ALLOWED_USER_IDS'?d.telegramAllowedUserIds.join(','):key==='TELEGRAM_READONLY_USER_IDS'?d.telegramReadOnlyUserIds.join(','):key==='TELEGRAM_ALLOWED_CHAT_IDS'?d.telegramAllowedChatIds.join(','):key==='TELEGRAM_ALLOW_ANY_CHAT'?String(d.telegramAllowAnyChat):JSON.stringify(d.telegramRolePolicies||{},null,2);return '<div class="setting"><label>'+esc(key)+'</label>'+(key.endsWith('_JSON')?'<textarea rows="5" data-access-setting="'+key+'">'+esc(value)+'</textarea>':'<input data-access-setting="'+key+'" value="'+esc(value)+'">')+'<small>Access control setting. Restart required after saving.</small></div>'}).join('');await loadLocks();await loadAudit()}
1149
- document.getElementById('loadAccessBtn').onclick=()=>loadAccess();
1150
- document.getElementById('saveAccessBtn').onclick=()=>safe(async()=>{const settings={};document.querySelectorAll('[data-access-setting]').forEach(el=>settings[el.dataset.accessSetting]=el.value);const r=await api('/api/settings',{method:'PATCH',body:JSON.stringify({settings})});toast((r.errors&&r.errors.length)?'Access settings need attention':'Access settings saved. Restart required.');if(r.errors&&r.errors.length)document.getElementById('accessPanel').insertAdjacentHTML('afterbegin','<div class="restart-banner">'+esc(r.errors.map(e=>e.key+': '+e.message).join(' / '))+'</div>')});
1151
- async function loadLocks(){const d=await api('/api/locks');document.getElementById('locksList').innerHTML=(d.locks||[]).map(l=>'<div class="item"><strong>'+esc(l.contextKey)+'</strong><small>'+esc((l.ownerName||'owner')+' / '+l.ownerId+' / expires '+fmtDate(l.expiresAt))+'</small></div>').join('')||'<div class="item">No active locks.</div>'}
1152
- document.getElementById('lockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'POST',body:JSON.stringify({ownerName:'Web dashboard'})});toast('Web session locked');loadLocks()});
1153
- document.getElementById('unlockSessionBtn').onclick=()=>safe(async()=>{await api('/api/locks',{method:'DELETE'});toast('Web session unlocked');loadLocks()});
1154
- async function loadAudit(){const d=await api('/api/audit?limit='+encodeURIComponent(val('auditLimit')||'50'));document.getElementById('auditList').innerHTML=(d.events||[]).map(e=>'<div class="item"><strong>'+esc(fmtDate(e.timestamp)+' / '+(e.channelId||'-')+' / '+e.status+' / '+e.action)+'</strong><small>'+esc((e.contextKey||'-')+' / '+(e.agentId||'-')+' / '+(e.threadId||'-'))+'</small><small>'+esc(e.description||e.detail||'')+'</small></div>').join('')||'<div class="item">No audit events.</div>'}
1155
- document.getElementById('loadAuditBtn').onclick=()=>loadAudit();
1156
- async function loadLogs(){const target=document.getElementById('logTarget').value;const lines=document.getElementById('logLines').value;const data=await api('/api/logs?target='+target+'&lines='+lines);state.logsPlain=data.plain||'';renderLogs();if(document.getElementById('logFollow').checked)document.getElementById('logs').scrollTop=document.getElementById('logs').scrollHeight}document.getElementById('loadLogsBtn').onclick=loadLogs;
1157
- function logLevelOf(line){if(line.includes(' ERROR '))return'ERROR';if(line.includes(' WARN '))return'WARN';if(line.includes(' INFO '))return'INFO';return''}
1158
- function logTimeOf(line){const m=line.match(/^(\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2})/);return m?new Date(m[1].replace(' ','T')).getTime():0}
1159
- function renderLogs(){const level=val('logLevel');const query=val('logSearch').toLowerCase();const since=val('logSince')?new Date(val('logSince')).getTime():0;const lines=state.logsPlain.split(/\\n/).filter(line=>(level==='all'||line.includes(level))&&(!query||line.toLowerCase().includes(query))&&(!since||!logTimeOf(line)||logTimeOf(line)>=since));document.getElementById('logs').innerHTML=lines.map(line=>'<span class="log-line '+logLevelOf(line)+'">'+esc(line)+'</span>').join('\\n')||'(empty)'}
1160
- document.getElementById('logLevel').onchange=renderLogs;document.getElementById('logSearch').oninput=renderLogs;document.getElementById('logSince').onchange=renderLogs;document.getElementById('logAutoRefresh').onchange=e=>{clearInterval(state.logTimer);state.logTimer=null;if(e.target.checked)state.logTimer=setInterval(loadLogs,5000)};document.getElementById('downloadLogsBtn').onclick=()=>{const blob=new Blob([state.logsPlain||''],{type:'text/plain'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='nordrelay-log.txt';a.click();URL.revokeObjectURL(a.href)};
1161
- async function loadAdapterHealth(){const d=await api('/api/adapters/health');document.getElementById('adapterHealth').innerHTML=(d.adapters||[]).map(a=>'<div class="item"><strong>'+esc(a.label)+' <span class="adapter-status '+esc(a.status)+'">'+esc(a.status)+'</span></strong><small>'+esc('CLI: '+(a.cli.label||'-')+' / path '+(a.cli.path||'-')+' / version '+(a.cli.version||'-'))+'</small><small>'+esc('Auth: '+(a.auth.supported?(a.auth.authenticated?'authenticated':'not authenticated'):'not managed')+' '+(a.auth.detail||''))+'</small><small>'+esc('Version: '+a.version.installed+' / latest '+(a.version.latest||'-')+' / '+a.version.status)+'</small><div class="row"><button data-auth-status="'+attr(a.id)+'">Auth status</button><button data-auth-login="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.login?'disabled':'')+'>Login</button><button data-auth-logout="'+attr(a.id)+'" class="secondary" '+(!a.capabilities.logout?'disabled':'')+'>Logout</button></div></div>').join('')||'<div class="item">No adapters.</div>';document.querySelectorAll('[data-auth-status]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/status?agent='+encodeURIComponent(b.dataset.authStatus));toast(r.agentLabel+': '+r.detail,{duration:6000})}));document.querySelectorAll('[data-auth-login]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/login',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogin})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}));document.querySelectorAll('[data-auth-logout]').forEach(b=>b.onclick=()=>safe(async()=>{const r=await api('/api/auth/logout',{method:'POST',body:JSON.stringify({agentId:b.dataset.authLogout})});toast((r.result?.message||r.detail),{duration:8000});loadAdapterHealth()}))}
1162
- document.getElementById('reloadAdaptersBtn').onclick=()=>loadAdapterHealth();
1163
- async function loadVersion(){const d=await api('/api/version');const checks=d.versionChecks||{};document.getElementById('versionPanel').innerHTML=Object.values(checks).map(v=>'<div class="item"><strong>'+esc((v.status==='current'?'OK ':v.status==='outdated'?'WARN ':'')+v.label)+'</strong><small>'+esc('Installed: '+(v.installedLabel||'-'))+'</small><small>'+esc('Latest: '+(v.latestVersion||'-')+' / '+v.status)+'</small><small>'+esc(v.detail||'')+'</small></div>').join('')+card('Runtime',[['Status',d.state?.status],['Version',d.health?.version],['Codex CLI',d.health?.codexCli],['Pi CLI',d.health?.piCli],['Hermes CLI',d.health?.hermesCli],['OpenClaw CLI',d.health?.openClawCli],['Claude Code CLI',d.health?.claudeCodeCli]])}
1164
- document.getElementById('loadVersionBtn').onclick=()=>loadVersion();
1165
- document.getElementById('updateBtn').onclick=()=>safe(async()=>{if(confirm('Start NordRelay self-update now?')){const r=await api('/api/update',{method:'POST'});toast('Update started via '+r.method+'. Log: '+r.logPath,{duration:8000});page('logs');document.getElementById('logTarget').value='update';loadLogs()}});
1166
- async function loadDiagnostics(){const data=await api('/api/diagnostics');document.getElementById('diagnostics').innerHTML=diagnosticsHtml(data)}
1167
- function diagnosticsHtml(d){const h=d.health||{};const s=d.snapshot?.session||{};const vc=d.versionChecks||{};const caps=s.capabilities||{};const agentDiag=d.runtime?.agentDiagnostics;return '<div class="list">'+card('Runtime',[['Status',h.state?.status],['PID',h.state?.pid],['App PID',h.state?.appPid],['State',h.stateFile],['Log',h.logFile],['State backend',d.runtime?.stateBackend],['Uptime',h.uptimeSeconds+'s']])+card('Agent',[['Agent',s.agentLabel],['Thread',s.threadId],['Workspace',s.workspace],['Model',s.model],['Reasoning',s.reasoningEffort],['Fast',caps.fastMode?(s.fastMode?'on':'off'):'n/a']])+card('Agent State',(agentDiag?.lines||[]).map(x=>[x.label,x.value]))+card('CLI Versions',Object.values(vc).map(v=>[v.label,(v.status==='current'?'OK ':'WARN ')+(v.installedLabel||'-')+' latest '+(v.latestVersion||'-')]))+card('External Mirror',d.runtime?.externalMirror?Object.entries(d.runtime.externalMirror):[['Status','idle']])+'</div>'}
1168
- function card(title,rows){return '<div class="item"><strong>'+esc(title)+'</strong>'+rows.map(r=>'<small>'+esc(r[0])+': '+esc(r[1]??'-')+'</small>').join('')+'</div>'}
1169
- function safe(fn,event){if(event&&event.preventDefault)event.preventDefault();Promise.resolve().then(fn).catch(err=>toast(err.message||String(err)))}
1170
- loadBootstrap().then(()=>{connectEvents();loadChatHistory();loadSessions();loadArtifacts();loadSettings();loadLogs();loadDiagnostics();loadActivity()}).catch(err=>toast(err.message));
1171
- `;
1172
- }