@nordbyte/nordrelay 0.4.1 → 0.5.1

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.
Files changed (57) hide show
  1. package/.env.example +155 -64
  2. package/README.md +81 -65
  3. package/dist/access-control.js +126 -115
  4. package/dist/agent-updates.js +62 -9
  5. package/dist/bot-rendering.js +838 -0
  6. package/dist/bot-ui.js +1 -0
  7. package/dist/bot.js +342 -2498
  8. package/dist/channel-actions.js +8 -8
  9. package/dist/channel-runtime.js +89 -0
  10. package/dist/config-metadata.js +238 -0
  11. package/dist/config.js +0 -58
  12. package/dist/index.js +8 -0
  13. package/dist/operations.js +63 -9
  14. package/dist/relay-artifact-service.js +126 -0
  15. package/dist/relay-external-activity-monitor.js +216 -0
  16. package/dist/relay-queue-service.js +66 -0
  17. package/dist/relay-runtime-types.js +1 -0
  18. package/dist/relay-runtime.js +96 -354
  19. package/dist/settings-service.js +2 -117
  20. package/dist/support-bundle.js +205 -0
  21. package/dist/telegram-access-commands.js +123 -0
  22. package/dist/telegram-access-middleware.js +129 -0
  23. package/dist/telegram-agent-commands.js +212 -0
  24. package/dist/telegram-artifact-commands.js +139 -0
  25. package/dist/telegram-channel-runtime.js +132 -0
  26. package/dist/telegram-command-menu.js +55 -0
  27. package/dist/telegram-command-types.js +1 -0
  28. package/dist/telegram-diagnostics-command.js +102 -0
  29. package/dist/telegram-general-commands.js +52 -0
  30. package/dist/telegram-operational-commands.js +153 -0
  31. package/dist/telegram-output.js +216 -0
  32. package/dist/telegram-preference-commands.js +198 -0
  33. package/dist/telegram-queue-commands.js +278 -0
  34. package/dist/telegram-support-command.js +53 -0
  35. package/dist/telegram-update-commands.js +93 -0
  36. package/dist/user-management.js +708 -0
  37. package/dist/web-api-contract.js +104 -0
  38. package/dist/web-api-types.js +1 -0
  39. package/dist/web-dashboard-access-routes.js +163 -0
  40. package/dist/web-dashboard-artifact-routes.js +65 -0
  41. package/dist/web-dashboard-assets.js +35 -2
  42. package/dist/web-dashboard-http.js +143 -0
  43. package/dist/web-dashboard-pages.js +257 -0
  44. package/dist/web-dashboard-runtime-routes.js +92 -0
  45. package/dist/web-dashboard-session-routes.js +209 -0
  46. package/dist/web-dashboard-ui.js +14 -14
  47. package/dist/web-dashboard.js +330 -707
  48. package/dist/webui-assets/dashboard.css +989 -0
  49. package/dist/webui-assets/dashboard.js +1750 -0
  50. package/dist/zip-writer.js +83 -0
  51. package/package.json +13 -4
  52. package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
  53. package/plugins/nordrelay/commands/remote.md +1 -1
  54. package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
  55. package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
  56. package/dist/web-dashboard-client.js +0 -275
  57. package/dist/web-dashboard-style.js +0 -9
@@ -1,4 +1,3 @@
1
- import { createReadStream } from "node:fs";
2
1
  import { createServer } from "node:http";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
@@ -6,28 +5,34 @@ import { URL } from "node:url";
6
5
  import { enabledAgents } from "./agent-factory.js";
7
6
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
7
  import { isAgentId } from "./agent.js";
8
+ import { AuditLogStore } from "./audit-log.js";
9
9
  import { listChannelDescriptors } from "./channel-adapter.js";
10
+ import { permissionForWebRequest } from "./access-control.js";
10
11
  import { loadConfig } from "./config.js";
11
12
  import { friendlyErrorText } from "./error-messages.js";
12
- import { escapeHTML } from "./format.js";
13
13
  import { RelayRuntime } from "./relay-runtime.js";
14
14
  import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
15
- import { dashboardJs } from "./web-dashboard-client.js";
16
- import { dashboardCss } from "./web-dashboard-style.js";
17
- import { renderDashboardNav } from "./web-dashboard-ui.js";
15
+ import { UserStore, publicUser } from "./user-management.js";
16
+ import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
17
+ import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
18
+ import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
19
+ import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
20
+ import { renderDashboardApp, renderLoginPage } from "./web-dashboard-pages.js";
21
+ import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
22
+ import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
18
23
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
19
- const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
20
24
  const options = parseOptions(process.argv.slice(2));
21
- const auth = resolveDashboardAuth(options.host);
22
- if (auth.publicBind && !auth.token && !(auth.user && auth.password)) {
23
- throw new Error("Dashboard bound to 0.0.0.0 requires NORDRELAY_DASHBOARD_TOKEN or NORDRELAY_DASHBOARD_USER/PASSWORD.");
24
- }
25
25
  const config = loadConfig();
26
26
  const runtime = new RelayRuntime(config);
27
27
  const settings = new SettingsService(resolveDashboardEnvPath(options.home));
28
+ const users = new UserStore(options.home);
29
+ const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
30
+ const loginAttempts = new Map();
31
+ class AccessDeniedError extends Error {
32
+ }
28
33
  const server = createServer((req, res) => {
29
34
  void handleRequest(req, res).catch((error) => {
30
- sendJson(res, 500, { error: friendlyErrorText(error) });
35
+ sendJson(res, error instanceof AccessDeniedError ? 403 : 500, { error: friendlyErrorText(error) });
31
36
  });
32
37
  });
33
38
  await new Promise((resolve) => server.listen(options.port, options.host, resolve));
@@ -36,35 +41,41 @@ process.once("SIGINT", () => shutdown());
36
41
  process.once("SIGTERM", () => shutdown());
37
42
  async function handleRequest(req, res) {
38
43
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
39
- const queryToken = url.searchParams.get("token");
40
- if (queryToken && isAuthorizedToken(queryToken) && !url.pathname.startsWith("/api/")) {
41
- setAuthCookie(res, queryToken);
42
- res.writeHead(302, { location: url.pathname || "/" });
43
- res.end();
44
- return;
45
- }
46
44
  if (url.pathname === "/api/auth" && req.method === "POST") {
47
45
  await handleLogin(req, res);
48
46
  return;
49
47
  }
50
- if (auth.required && !isAuthorizedRequest(req) && !isAuthorizedToken(queryToken ?? "")) {
51
- if (url.pathname === "/" || url.pathname === "/index.html") {
52
- sendText(res, 200, renderLoginPage(auth), "text/html; charset=utf-8");
48
+ if (url.pathname === "/api/dashboard/logout" && req.method === "POST") {
49
+ handleLogout(req, res);
50
+ return;
51
+ }
52
+ const authenticated = authenticateRequest(req);
53
+ if (url.pathname === "/api/auth/me" && req.method === "GET") {
54
+ if (!authenticated) {
55
+ sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
53
56
  return;
54
57
  }
55
- if (url.pathname.startsWith("/api/") || url.pathname === "/healthz") {
56
- sendJson(res, 401, { error: "Authentication required" });
58
+ sendJson(res, 200, currentUserDto(authenticated));
59
+ return;
60
+ }
61
+ if (!authenticated) {
62
+ if (url.pathname === "/" || url.pathname === "/index.html") {
63
+ sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
57
64
  return;
58
65
  }
59
- sendText(res, 401, "Authentication required\n", "text/plain; charset=utf-8");
66
+ sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
60
67
  return;
61
68
  }
62
69
  if (url.pathname === "/healthz") {
70
+ if (!users.hasPermission(authenticated, "inspect")) {
71
+ sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
72
+ return;
73
+ }
63
74
  sendText(res, 200, "ok\n", "text/plain; charset=utf-8");
64
75
  return;
65
76
  }
66
77
  if (url.pathname === "/" || url.pathname === "/index.html") {
67
- sendText(res, 200, renderDashboardApp({ authRequired: auth.required }), "text/html; charset=utf-8");
78
+ sendText(res, 200, renderDashboardApp(), "text/html; charset=utf-8");
68
79
  return;
69
80
  }
70
81
  if (url.pathname === "/assets/dashboard.css") {
@@ -76,109 +87,79 @@ async function handleRequest(req, res) {
76
87
  return;
77
88
  }
78
89
  if (url.pathname === "/api/events" && req.method === "GET") {
79
- handleEvents(req, res);
90
+ await handleEvents(req, res);
80
91
  return;
81
92
  }
82
93
  if (!url.pathname.startsWith("/api/")) {
83
94
  sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
84
95
  return;
85
96
  }
86
- await handleApi(req, res, url);
97
+ await handleApi(req, res, url, authenticated);
87
98
  }
88
- async function handleApi(req, res, url) {
99
+ async function handleApi(req, res, url, authUser) {
100
+ const permission = permissionForWebRequest(req.method, url.pathname);
101
+ if (!permission) {
102
+ audit({
103
+ action: "permission_denied",
104
+ status: "denied",
105
+ channelId: "web",
106
+ contextKey: "web",
107
+ actorId: authUser.user.id,
108
+ actorRole: authUser.groups.map((group) => group.name).join(", "),
109
+ description: `Denied unknown endpoint ${req.method ?? "GET"} ${url.pathname}`,
110
+ });
111
+ sendJson(res, 403, { error: "Access denied." });
112
+ return;
113
+ }
114
+ if (!users.hasPermission(authUser, permission)) {
115
+ audit({
116
+ action: "permission_denied",
117
+ status: "denied",
118
+ channelId: "web",
119
+ contextKey: "web",
120
+ actorId: authUser.user.id,
121
+ actorRole: authUser.groups.map((group) => group.name).join(", "),
122
+ description: `${permission} required for ${req.method ?? "GET"} ${url.pathname}`,
123
+ });
124
+ sendJson(res, 403, { error: `Access denied: ${permission} permission required.` });
125
+ return;
126
+ }
127
+ if (await handleDashboardRuntimeRoute(req, res, url, {
128
+ runtime,
129
+ users,
130
+ authUser,
131
+ parseAgentIdRequired,
132
+ assertScopedAgent,
133
+ assertAgentUpdateJobScope,
134
+ assertCurrentSessionScope,
135
+ scopedTasks,
136
+ })) {
137
+ return;
138
+ }
89
139
  if (req.method === "GET" && url.pathname === "/api/bootstrap") {
140
+ await assertCurrentSessionScope(authUser);
90
141
  sendJson(res, 200, {
91
- auth: { required: auth.required, publicBind: auth.publicBind },
142
+ auth: currentUserDto(authUser),
92
143
  channels: listChannelDescriptors(),
93
- agentAdapters: listAgentAdapterDescriptors(),
94
- enabledAgents: enabledAgents(config),
95
- controls: await runtime.controlOptions(),
144
+ agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
145
+ enabledAgents: enabledAgents(config).filter((agentId) => users.canUseAgent(authUser, agentId)),
146
+ controls: scopedControlOptions(authUser, await runtime.controlOptions()),
96
147
  status: await runtime.bootstrapStatus(),
97
148
  });
98
149
  return;
99
150
  }
100
151
  if (req.method === "GET" && url.pathname === "/api/control-options") {
101
- sendJson(res, 200, await runtime.controlOptions(parseAgentId(url.searchParams.get("agent") ?? undefined)));
102
- return;
103
- }
104
- if (req.method === "GET" && url.pathname === "/api/health") {
105
- sendJson(res, 200, await runtime.status());
106
- return;
107
- }
108
- if (req.method === "GET" && url.pathname === "/api/version") {
109
- sendJson(res, 200, await runtime.version());
110
- return;
111
- }
112
- if (req.method === "POST" && url.pathname === "/api/update") {
113
- sendJson(res, 202, runtime.updateConnector());
114
- return;
115
- }
116
- if (req.method === "GET" && url.pathname === "/api/agent-updates") {
117
- sendJson(res, 200, { jobs: runtime.agentUpdateJobs() });
118
- return;
119
- }
120
- if (req.method === "POST" && url.pathname === "/api/agent-update") {
121
- const body = await readJsonBody(req);
122
- sendJson(res, 202, { job: runtime.startAgentUpdate(parseAgentIdRequired(stringField(body, "agentId"))) });
123
- return;
124
- }
125
- const agentUpdateLogMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/log$/);
126
- if (req.method === "GET" && agentUpdateLogMatch?.[1]) {
127
- sendJson(res, 200, runtime.agentUpdateLog(decodeURIComponent(agentUpdateLogMatch[1])));
128
- return;
129
- }
130
- const agentUpdateInputMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/input$/);
131
- if (req.method === "POST" && agentUpdateInputMatch?.[1]) {
132
- const body = await readJsonBody(req);
133
- sendJson(res, 200, { job: runtime.sendAgentUpdateInput(decodeURIComponent(agentUpdateInputMatch[1]), stringField(body, "input")) });
134
- return;
135
- }
136
- const agentUpdateCancelMatch = url.pathname.match(/^\/api\/agent-update\/([^/]+)\/cancel$/);
137
- if (req.method === "POST" && agentUpdateCancelMatch?.[1]) {
138
- sendJson(res, 200, { job: runtime.cancelAgentUpdate(decodeURIComponent(agentUpdateCancelMatch[1])) });
139
- return;
140
- }
141
- if (req.method === "GET" && (url.pathname === "/api/tasks" || url.pathname === "/api/progress")) {
142
- sendJson(res, 200, runtime.tasks());
143
- return;
144
- }
145
- if (req.method === "GET" && url.pathname === "/api/adapters/health") {
146
- sendJson(res, 200, { adapters: await runtime.adapterHealth() });
147
- return;
148
- }
149
- if (req.method === "GET" && url.pathname === "/api/permissions") {
150
- sendJson(res, 200, runtime.permissions());
151
- return;
152
- }
153
- if (req.method === "GET" && url.pathname === "/api/audit") {
154
- sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
155
- return;
156
- }
157
- if (req.method === "GET" && url.pathname === "/api/locks") {
158
- sendJson(res, 200, { locks: runtime.locks() });
159
- return;
160
- }
161
- if (req.method === "POST" && url.pathname === "/api/locks") {
162
- const body = await readJsonBody(req);
163
- sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
164
- return;
165
- }
166
- if (req.method === "DELETE" && url.pathname === "/api/locks") {
167
- sendJson(res, 200, runtime.unlockWebSession());
168
- return;
169
- }
170
- if (req.method === "GET" && url.pathname === "/api/auth/status") {
171
- sendJson(res, 200, await runtime.authStatus(parseAgentId(url.searchParams.get("agent") ?? undefined)));
152
+ const agentId = parseAgentId(url.searchParams.get("agent") ?? undefined);
153
+ assertScopedAgent(authUser, agentId);
154
+ sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
172
155
  return;
173
156
  }
174
- if (req.method === "POST" && url.pathname === "/api/auth/login") {
175
- const body = await readJsonBody(req);
176
- sendJson(res, 200, await runtime.login(parseAgentId(optionalStringField(body, "agentId"))));
177
- return;
178
- }
179
- if (req.method === "POST" && url.pathname === "/api/auth/logout") {
180
- const body = await readJsonBody(req);
181
- sendJson(res, 200, await runtime.logout(parseAgentId(optionalStringField(body, "agentId"))));
157
+ if (await handleDashboardAccessRoute(req, res, url, {
158
+ users,
159
+ runtime,
160
+ authUser,
161
+ auditUserAction,
162
+ })) {
182
163
  return;
183
164
  }
184
165
  if (req.method === "GET" && url.pathname === "/api/settings") {
@@ -190,220 +171,63 @@ async function handleApi(req, res, url) {
190
171
  sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
191
172
  return;
192
173
  }
193
- if (req.method === "GET" && url.pathname === "/api/snapshot") {
194
- sendJson(res, 200, await runtime.snapshot());
195
- return;
196
- }
197
- if (req.method === "GET" && url.pathname === "/api/sessions") {
198
- sendJson(res, 200, await runtime.listSessionsPage(numberParam(url, "page", 1), numberParam(url, "limit", 50), url.searchParams.get("query") ?? "", parseAgentId(url.searchParams.get("agent") ?? undefined)));
199
- return;
200
- }
201
- if (req.method === "POST" && url.pathname === "/api/agent") {
202
- const body = await readJsonBody(req);
203
- const agentId = stringField(body, "agentId");
204
- if (!isAgentId(agentId)) {
205
- throw new Error(`Invalid agent: ${agentId}`);
206
- }
207
- sendJson(res, 200, { session: await runtime.setAgent(agentId) });
208
- return;
209
- }
210
- if (req.method === "POST" && url.pathname === "/api/sessions/new") {
211
- const body = await readJsonBody(req);
212
- sendJson(res, 200, {
213
- session: await runtime.newSession({
214
- agentId: parseAgentId(optionalStringField(body, "agentId")),
215
- workspace: optionalStringField(body, "workspace"),
216
- model: optionalStringField(body, "model"),
217
- reasoningEffort: optionalStringField(body, "reasoningEffort"),
218
- launchProfileId: optionalStringField(body, "launchProfileId"),
219
- fastMode: optionalBooleanField(body, "fastMode"),
220
- }),
221
- });
222
- return;
223
- }
224
- if (req.method === "POST" && url.pathname === "/api/sessions/switch") {
225
- const body = await readJsonBody(req);
226
- sendJson(res, 200, { session: await runtime.switchSession(stringField(body, "threadId")) });
227
- return;
228
- }
229
- if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
230
- const body = await readJsonBody(req);
231
- sendJson(res, 200, { session: await runtime.attachSession(stringField(body, "threadId")) });
232
- return;
233
- }
234
- if (req.method === "GET" && url.pathname === "/api/sessions/detail") {
235
- sendJson(res, 200, await runtime.sessionDetail(requiredSearch(url, "threadId")));
236
- return;
237
- }
238
- if (req.method === "GET" && url.pathname === "/api/models") {
239
- sendJson(res, 200, { models: await runtime.listModels() });
240
- return;
241
- }
242
- if (req.method === "POST" && url.pathname === "/api/session/model") {
243
- const body = await readJsonBody(req);
244
- sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
245
- return;
246
- }
247
- if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
248
- const body = await readJsonBody(req);
249
- sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
250
- return;
251
- }
252
- if (req.method === "POST" && url.pathname === "/api/session/fast") {
253
- const body = await readJsonBody(req);
254
- sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
255
- return;
256
- }
257
- if (req.method === "POST" && url.pathname === "/api/session/launch") {
258
- const body = await readJsonBody(req);
259
- sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
260
- return;
261
- }
262
- if (req.method === "POST" && url.pathname === "/api/prompt") {
263
- const body = await readJsonBody(req);
264
- sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
265
- return;
266
- }
267
- if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
268
- const body = await readJsonBody(req);
269
- sendJson(res, 202, await runtime.sendUploadPrompt({
270
- text: optionalStringField(body, "text"),
271
- files: parseUploadFiles(body.files),
272
- }));
273
- return;
274
- }
275
- if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
276
- await runtime.abort();
277
- sendJson(res, 200, { ok: true });
278
- return;
279
- }
280
- if (req.method === "POST" && url.pathname === "/api/handback") {
281
- sendJson(res, 200, await runtime.handback());
282
- return;
283
- }
284
- if (req.method === "POST" && url.pathname === "/api/retry") {
285
- sendJson(res, 202, await runtime.retry());
286
- return;
287
- }
288
- if (req.method === "POST" && url.pathname === "/api/sync") {
289
- sendJson(res, 200, await runtime.sync());
174
+ if (await handleDashboardSessionRoute(req, res, url, {
175
+ runtime,
176
+ authUser,
177
+ parseAgentId,
178
+ assertScopedAgent,
179
+ assertScopedWorkspace,
180
+ assertCurrentSessionScope,
181
+ assertSessionScope,
182
+ assertSessionDetailScope,
183
+ scopedSessionPage,
184
+ filterActivityByScope,
185
+ })) {
290
186
  return;
291
187
  }
292
- if (req.method === "GET" && url.pathname === "/api/queue") {
293
- sendJson(res, 200, { queue: runtime.queue(), paused: runtime.queuePaused() });
294
- return;
295
- }
296
- if (req.method === "POST" && url.pathname === "/api/queue") {
297
- const body = await readJsonBody(req);
298
- sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
299
- return;
300
- }
301
- if (req.method === "GET" && url.pathname === "/api/chat/history") {
302
- sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
303
- return;
304
- }
305
- if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
306
- sendJson(res, 200, await runtime.clearChatHistory());
307
- return;
308
- }
309
- if (req.method === "GET" && url.pathname === "/api/activity") {
310
- sendJson(res, 200, {
311
- events: runtime.activity({
312
- limit: numberParam(url, "limit", 100),
313
- source: (url.searchParams.get("source") || "all"),
314
- status: (url.searchParams.get("status") || "all"),
315
- }),
316
- });
317
- return;
318
- }
319
- if (req.method === "GET" && url.pathname === "/api/artifacts") {
320
- sendJson(res, 200, { reports: await runtime.artifacts() });
321
- return;
322
- }
323
- if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
324
- sendJson(res, 200, { removed: await runtime.deleteArtifact(requiredSearch(url, "turnId")) });
325
- return;
326
- }
327
- if (req.method === "POST" && url.pathname === "/api/artifacts/bulk") {
328
- const body = await readJsonBody(req);
329
- const action = stringField(body, "action");
330
- const turnIds = Array.isArray(body.turnIds) ? body.turnIds.filter((item) => typeof item === "string") : [];
331
- if (action !== "delete") {
332
- throw new Error("Unsupported artifact bulk action.");
333
- }
334
- const removed = [];
335
- for (const turnId of turnIds) {
336
- if (await runtime.deleteArtifact(turnId)) {
337
- removed.push(turnId);
338
- }
339
- }
340
- sendJson(res, 200, { removed });
341
- return;
342
- }
343
- if (req.method === "GET" && url.pathname === "/api/artifacts/zip") {
344
- const bundle = await runtime.createArtifactZip(requiredSearch(url, "turnId"));
345
- if (!bundle) {
346
- sendJson(res, 404, { error: "Artifact turn not found or ZIP could not be created" });
347
- return;
348
- }
349
- sendFile(res, bundle.path, bundle.name);
350
- return;
351
- }
352
- if (req.method === "GET" && url.pathname === "/api/artifacts/file") {
353
- const turnId = requiredSearch(url, "turnId");
354
- const relativePath = requiredSearch(url, "path");
355
- const report = await runtime.artifact(turnId);
356
- const artifact = report?.artifacts.find((candidate) => candidate.relativePath === relativePath);
357
- if (!artifact) {
358
- sendJson(res, 404, { error: "Artifact not found" });
359
- return;
360
- }
361
- sendFile(res, artifact.localPath, artifact.name);
362
- return;
363
- }
364
- if (req.method === "GET" && url.pathname === "/api/artifacts/preview") {
365
- const preview = await runtime.artifactPreview(requiredSearch(url, "turnId"), requiredSearch(url, "path"));
366
- if (!preview) {
367
- sendJson(res, 404, { error: "Artifact not found" });
368
- return;
369
- }
370
- sendJson(res, 200, preview);
371
- return;
372
- }
373
- if (req.method === "GET" && url.pathname === "/api/logs") {
374
- sendJson(res, 200, await runtime.logs(parseLogTarget(url.searchParams.get("target") ?? undefined), numberParam(url, "lines", 120)));
375
- return;
376
- }
377
- if (req.method === "POST" && url.pathname === "/api/logs/clear") {
378
- const body = await readJsonBody(req);
379
- sendJson(res, 200, runtime.clearLogs(parseLogTarget(optionalStringField(body, "target"))));
380
- return;
381
- }
382
- if (req.method === "GET" && url.pathname === "/api/diagnostics") {
383
- sendJson(res, 200, await runtime.diagnostics());
384
- return;
385
- }
386
- if (req.method === "POST" && url.pathname === "/api/runtime/restart") {
387
- sendJson(res, 202, runtime.restartConnector());
188
+ if (await handleDashboardArtifactRoute(req, res, url, {
189
+ runtime,
190
+ authUser,
191
+ assertCurrentSessionScope,
192
+ })) {
388
193
  return;
389
194
  }
390
195
  sendJson(res, 404, { error: "Unknown endpoint" });
391
196
  }
392
- function handleEvents(req, res) {
393
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
394
- const token = url.searchParams.get("token");
395
- if (auth.required && !(isAuthorizedRequest(req) || (token && isAuthorizedToken(token)))) {
197
+ async function handleEvents(req, res) {
198
+ const authUser = authenticateRequest(req);
199
+ if (!authUser) {
396
200
  sendJson(res, 401, { error: "Authentication required" });
397
201
  return;
398
202
  }
203
+ if (!users.hasPermission(authUser, "sessions.read")) {
204
+ sendJson(res, 403, { error: "Access denied: sessions.read permission required." });
205
+ return;
206
+ }
207
+ await assertCurrentSessionScope(authUser);
399
208
  res.writeHead(200, {
400
209
  "content-type": "text/event-stream; charset=utf-8",
401
210
  "cache-control": "no-cache, no-transform",
402
211
  connection: "keep-alive",
403
212
  });
404
213
  const send = (event) => {
405
- res.write(`event: ${event.type}\n`);
406
- res.write(`data: ${JSON.stringify(event)}\n\n`);
214
+ void scopeRelayEvent(authUser, event, canUseCurrentSession).then((scopedEvent) => {
215
+ if (!scopedEvent || res.destroyed || res.writableEnded) {
216
+ return;
217
+ }
218
+ res.write(`event: ${scopedEvent.type}\n`);
219
+ res.write(`data: ${JSON.stringify(scopedEvent)}\n\n`);
220
+ }).catch(() => { });
221
+ };
222
+ let currentScopeCache = null;
223
+ const canUseCurrentSession = async () => {
224
+ const now = Date.now();
225
+ if (currentScopeCache && currentScopeCache.expiresAt > now) {
226
+ return currentScopeCache.allowed;
227
+ }
228
+ const allowed = await canUseCurrentSessionScope(authUser);
229
+ currentScopeCache = { allowed, expiresAt: now + 1_000 };
230
+ return allowed;
407
231
  };
408
232
  const unsubscribe = runtime.subscribe(send);
409
233
  const heartbeat = setInterval(() => {
@@ -417,20 +241,60 @@ function handleEvents(req, res) {
417
241
  }
418
242
  async function handleLogin(req, res) {
419
243
  const body = await readJsonBody(req);
420
- const token = optionalStringField(body, "token");
421
- const user = optionalStringField(body, "user");
244
+ const email = optionalStringField(body, "email");
422
245
  const password = optionalStringField(body, "password");
423
- if (token && isAuthorizedToken(token)) {
424
- setAuthCookie(res, token);
425
- sendJson(res, 200, { ok: true, mode: "token" });
246
+ const rateLimitKey = `${req.socket.remoteAddress ?? "unknown"}:${email ?? "-"}`;
247
+ const limited = consumeRateLimit(loginAttempts, rateLimitKey, 5, 15 * 60 * 1000, 15 * 60 * 1000);
248
+ if (limited.limited) {
249
+ audit({
250
+ action: "auth_login_failed",
251
+ status: "denied",
252
+ channelId: "web",
253
+ contextKey: "web",
254
+ description: `Rate limited login attempt for ${email ?? "unknown"}`,
255
+ detail: `${Math.ceil((limited.retryAfterMs ?? 0) / 1000)}s retry-after`,
256
+ });
257
+ sendJson(res, 429, { error: "Too many login attempts. Try again later.", retryAfterMs: limited.retryAfterMs });
426
258
  return;
427
259
  }
428
- if (user && password && isAuthorizedBasic(user, password)) {
429
- setBasicCookie(res, user, password);
430
- sendJson(res, 200, { ok: true, mode: "basic" });
260
+ if (!users.hasAdminUser()) {
261
+ sendJson(res, 503, { error: "No admin user exists. Run nordrelay user create-admin first." });
431
262
  return;
432
263
  }
433
- sendJson(res, 401, { error: "Invalid dashboard credentials" });
264
+ const authUser = email && password ? users.verifyPassword(email, password) : null;
265
+ if (!authUser) {
266
+ audit({
267
+ action: "auth_login_failed",
268
+ status: "failed",
269
+ channelId: "web",
270
+ contextKey: "web",
271
+ description: `Failed login for ${email ?? "unknown"}`,
272
+ });
273
+ sendJson(res, 401, { error: "Invalid credentials" });
274
+ return;
275
+ }
276
+ resetRateLimit(loginAttempts, rateLimitKey);
277
+ const session = users.createWebSession(authUser.user.id);
278
+ audit({
279
+ action: "auth_login",
280
+ status: "ok",
281
+ channelId: "web",
282
+ contextKey: "web",
283
+ actorId: authUser.user.id,
284
+ actorRole: authUser.groups.map((group) => group.name).join(", "),
285
+ description: `Login ${authUser.user.email}`,
286
+ });
287
+ setSessionCookie(res, session.token);
288
+ sendJson(res, 200, currentUserDto(authUser));
289
+ }
290
+ function handleLogout(req, res) {
291
+ const authUser = authenticateRequest(req);
292
+ users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
293
+ if (authUser) {
294
+ auditUserAction(authUser, "auth_logout", authUser.user.email);
295
+ }
296
+ clearSessionCookie(res);
297
+ sendJson(res, 200, { ok: true });
434
298
  }
435
299
  function parseOptions(argv) {
436
300
  let host = process.env.NORDRELAY_DASHBOARD_HOST || "127.0.0.1";
@@ -450,127 +314,177 @@ function parseOptions(argv) {
450
314
  }
451
315
  return { host, port, home };
452
316
  }
453
- function resolveDashboardAuth(host) {
454
- const token = optionalEnv("NORDRELAY_DASHBOARD_TOKEN");
455
- const user = optionalEnv("NORDRELAY_DASHBOARD_USER");
456
- const password = optionalEnv("NORDRELAY_DASHBOARD_PASSWORD");
457
- const publicBind = isPublicBindHost(host);
317
+ function authenticateRequest(req) {
318
+ const cookies = parseCookies(req.headers.cookie ?? "");
319
+ return users.resolveWebSession(cookies.nr_session);
320
+ }
321
+ function setSessionCookie(res, token) {
322
+ res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
323
+ }
324
+ function clearSessionCookie(res) {
325
+ res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
326
+ }
327
+ function currentUserDto(authUser) {
458
328
  return {
459
- required: publicBind || Boolean(token || (user && password)),
460
- publicBind,
461
- token,
462
- user,
463
- password,
329
+ user: publicUser(authUser.user),
330
+ groups: authUser.groups,
331
+ permissions: authUser.permissions,
464
332
  };
465
333
  }
466
- function isPublicBindHost(host) {
467
- return host === "0.0.0.0" || host === "::" || host === "";
468
- }
469
- function isAuthorizedRequest(req) {
470
- if (!auth.required) {
471
- return true;
334
+ function audit(event) {
335
+ try {
336
+ auditLog.append(event);
472
337
  }
473
- const header = req.headers.authorization;
474
- if (header?.startsWith("Bearer ") && isAuthorizedToken(header.slice("Bearer ".length).trim())) {
475
- return true;
476
- }
477
- if (header?.startsWith("Basic ")) {
478
- const decoded = Buffer.from(header.slice("Basic ".length), "base64").toString("utf8");
479
- const [user, ...passwordParts] = decoded.split(":");
480
- if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
481
- return true;
482
- }
338
+ catch (error) {
339
+ console.warn("Failed to write audit event:", error instanceof Error ? error.message : String(error));
483
340
  }
484
- const cookies = parseCookies(req.headers.cookie ?? "");
485
- if (cookies.nrdash && isAuthorizedToken(cookies.nrdash)) {
341
+ }
342
+ function auditUserAction(authUser, action, description) {
343
+ audit({
344
+ action,
345
+ status: "ok",
346
+ channelId: "web",
347
+ contextKey: "web",
348
+ actorId: authUser.user.id,
349
+ actorRole: authUser.groups.map((group) => group.name).join(", "),
350
+ description,
351
+ });
352
+ }
353
+ function scopedControlOptions(authUser, options) {
354
+ return {
355
+ ...options,
356
+ workspaces: options.workspaces.filter((workspace) => users.canUseWorkspace(authUser, workspace)),
357
+ };
358
+ }
359
+ function scopedSessionPage(authUser, page) {
360
+ return {
361
+ ...page,
362
+ sessions: page.sessions.filter((session) => canUseSession(authUser, session)),
363
+ };
364
+ }
365
+ async function scopedTasks(authUser, tasks) {
366
+ const currentAllowed = await canUseCurrentSessionScope(authUser);
367
+ return {
368
+ ...tasks,
369
+ current: tasks.current && canUseSession(authUser, tasks.current) ? tasks.current : null,
370
+ external: tasks.external && canUseSession(authUser, tasks.external) ? tasks.external : null,
371
+ queue: currentAllowed ? tasks.queue : [],
372
+ recent: filterActivityByScope(authUser, tasks.recent),
373
+ };
374
+ }
375
+ async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => canUseCurrentSessionScope(authUser)) {
376
+ switch (event.type) {
377
+ case "snapshot":
378
+ return canUseSession(authUser, event.data.session) ? event : null;
379
+ case "session_update":
380
+ return canUseSession(authUser, event.session) ? event : null;
381
+ case "activity_update":
382
+ return { ...event, events: filterActivityByScope(authUser, event.events) };
383
+ case "agent_update":
384
+ return users.canUseAgent(authUser, event.job.agentId) ? event : null;
385
+ case "status":
386
+ return event;
387
+ case "chat_history":
388
+ case "queue_update":
389
+ case "turn_start":
390
+ case "text_delta":
391
+ case "tool_start":
392
+ case "tool_update":
393
+ case "tool_end":
394
+ case "todo_update":
395
+ case "turn_complete":
396
+ case "turn_error":
397
+ return await canUseCurrentSession() ? event : null;
398
+ }
399
+ }
400
+ function filterActivityByScope(authUser, events) {
401
+ return events.filter((event) => canUseSession(authUser, event));
402
+ }
403
+ async function canUseCurrentSessionScope(authUser) {
404
+ try {
405
+ await assertCurrentSessionScope(authUser);
486
406
  return true;
487
407
  }
488
- if (cookies.nrdash_basic) {
489
- const decoded = Buffer.from(cookies.nrdash_basic, "base64").toString("utf8");
490
- const [user, ...passwordParts] = decoded.split(":");
491
- if (isAuthorizedBasic(user ?? "", passwordParts.join(":"))) {
492
- return true;
408
+ catch (error) {
409
+ if (error instanceof AccessDeniedError) {
410
+ return false;
493
411
  }
412
+ throw error;
494
413
  }
495
- return false;
496
414
  }
497
- function isAuthorizedToken(token) {
498
- return Boolean(auth.token && constantTimeEqual(token, auth.token));
415
+ function canUseSession(authUser, session) {
416
+ const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
417
+ const workspace = typeof session.workspace === "string"
418
+ ? session.workspace
419
+ : typeof session.cwd === "string"
420
+ ? session.cwd
421
+ : undefined;
422
+ return users.canUseAgent(authUser, agentId) && users.canUseWorkspace(authUser, workspace);
499
423
  }
500
- function isAuthorizedBasic(user, password) {
501
- return Boolean(auth.user && auth.password && constantTimeEqual(user, auth.user) && constantTimeEqual(password, auth.password));
502
- }
503
- function constantTimeEqual(left, right) {
504
- const leftBuffer = Buffer.from(left);
505
- const rightBuffer = Buffer.from(right);
506
- if (leftBuffer.length !== rightBuffer.length) {
507
- return false;
424
+ function assertAgentUpdateJobScope(authUser, id) {
425
+ const job = runtime.agentUpdateJobs().find((candidate) => candidate.id === id);
426
+ if (job) {
427
+ assertScopedAgent(authUser, job.agentId);
508
428
  }
509
- return cryptoTimingSafeEqual(leftBuffer, rightBuffer);
510
429
  }
511
- function cryptoTimingSafeEqual(left, right) {
512
- let diff = 0;
513
- for (let index = 0; index < left.length; index += 1) {
514
- diff |= left[index] ^ right[index];
430
+ function assertSessionDetailScope(authUser, threadId, detail) {
431
+ const record = objectValue(detail.record);
432
+ if (record) {
433
+ assertSessionScope(authUser, record);
434
+ return;
515
435
  }
516
- return diff === 0;
517
- }
518
- function setAuthCookie(res, token) {
519
- res.setHeader("set-cookie", `nrdash=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
520
- }
521
- function setBasicCookie(res, user, password) {
522
- const value = Buffer.from(`${user}:${password}`).toString("base64");
523
- res.setHeader("set-cookie", `nrdash_basic=${encodeURIComponent(value)}; HttpOnly; SameSite=Strict; Path=/`);
524
- }
525
- function parseCookies(cookieHeader) {
526
- const cookies = {};
527
- for (const part of cookieHeader.split(";")) {
528
- const [key, ...valueParts] = part.trim().split("=");
529
- if (key)
530
- cookies[key] = decodeURIComponent(valueParts.join("=") ?? "");
436
+ const active = objectValue(detail.active);
437
+ if (active && active.threadId === threadId) {
438
+ assertSessionScope(authUser, active);
439
+ return;
531
440
  }
532
- return cookies;
441
+ throw new AccessDeniedError("Access denied: session is outside your group scope.");
533
442
  }
534
- async function readJsonBody(req) {
535
- const chunks = [];
536
- for await (const chunk of req) {
537
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
443
+ function assertScopedAgent(authUser, agentId) {
444
+ if (!users.canUseAgent(authUser, agentId)) {
445
+ throw new AccessDeniedError(`Access denied: agent ${agentId} is outside your group scope.`);
538
446
  }
539
- const text = Buffer.concat(chunks).toString("utf8").trim();
540
- if (!text) {
541
- return {};
447
+ }
448
+ function assertScopedWorkspace(authUser, workspace) {
449
+ if (!users.canUseWorkspace(authUser, workspace)) {
450
+ throw new AccessDeniedError(`Access denied: workspace ${workspace} is outside your group scope.`);
542
451
  }
543
- return JSON.parse(text);
544
452
  }
545
- function sendJson(res, status, value) {
546
- res.writeHead(status, JSON_HEADERS);
547
- res.end(`${JSON.stringify(value)}\n`);
453
+ function assertSessionScope(authUser, session) {
454
+ const agentId = typeof session.agentId === "string" ? session.agentId : undefined;
455
+ const workspace = typeof session.workspace === "string"
456
+ ? session.workspace
457
+ : typeof session.cwd === "string"
458
+ ? session.cwd
459
+ : undefined;
460
+ assertScopedAgent(authUser, agentId);
461
+ assertScopedWorkspace(authUser, workspace);
548
462
  }
549
- function sendText(res, status, text, contentType) {
550
- res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
551
- res.end(text);
463
+ async function assertCurrentSessionScope(authUser) {
464
+ const snapshot = await runtime.snapshot();
465
+ assertSessionScope(authUser, snapshot.session);
552
466
  }
553
- function sendFile(res, filePath, filename) {
554
- res.writeHead(200, {
555
- "content-type": "application/octet-stream",
556
- "content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
557
- });
558
- createReadStream(filePath).pipe(res);
467
+ function objectValue(value) {
468
+ return value && typeof value === "object" && !Array.isArray(value) ? value : null;
559
469
  }
560
- function stringField(value, key) {
561
- const field = value[key];
562
- if (typeof field !== "string" || !field.trim()) {
563
- throw new Error(`${key} is required`);
470
+ function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
471
+ const now = Date.now();
472
+ const existing = buckets.get(key);
473
+ if (existing?.blockedUntil && existing.blockedUntil > now) {
474
+ return { limited: true, retryAfterMs: existing.blockedUntil - now };
564
475
  }
565
- return field.trim();
566
- }
567
- function optionalStringField(value, key) {
568
- const field = value[key];
569
- return typeof field === "string" && field.trim() ? field.trim() : undefined;
476
+ const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
477
+ bucket.count += 1;
478
+ if (bucket.count > limit) {
479
+ bucket.blockedUntil = now + blockMs;
480
+ buckets.set(key, bucket);
481
+ return { limited: true, retryAfterMs: blockMs };
482
+ }
483
+ buckets.set(key, bucket);
484
+ return { limited: false };
570
485
  }
571
- function optionalBooleanField(value, key) {
572
- const field = value[key];
573
- return typeof field === "boolean" ? field : undefined;
486
+ function resetRateLimit(buckets, key) {
487
+ buckets.delete(key);
574
488
  }
575
489
  function parseAgentId(value) {
576
490
  if (!value) {
@@ -584,61 +498,13 @@ function parseAgentIdRequired(value) {
584
498
  }
585
499
  return value;
586
500
  }
587
- function parseLogTarget(value) {
588
- return value === "update" || value === "agent-updates" ? value : "connector";
589
- }
590
- function objectRecord(value) {
591
- if (!value || typeof value !== "object" || Array.isArray(value)) {
592
- return {};
593
- }
594
- return value;
595
- }
596
- function parseUploadFiles(value) {
597
- if (!Array.isArray(value)) {
598
- return [];
599
- }
600
- return value.map((item, index) => {
601
- if (!item || typeof item !== "object" || Array.isArray(item)) {
602
- throw new Error(`files[${index}] must be an object`);
603
- }
604
- const record = item;
605
- const name = typeof record.name === "string" && record.name.trim() ? record.name.trim() : `upload-${index + 1}`;
606
- const mimeType = typeof record.mimeType === "string" ? record.mimeType.trim() : undefined;
607
- const dataBase64 = typeof record.dataBase64 === "string" ? record.dataBase64 : "";
608
- if (!dataBase64) {
609
- throw new Error(`files[${index}].dataBase64 is required`);
610
- }
611
- return { name, mimeType, data: Buffer.from(stripDataUrlPrefix(dataBase64), "base64") };
612
- });
613
- }
614
- function stripDataUrlPrefix(value) {
615
- const comma = value.indexOf(",");
616
- return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
617
- }
618
- function numberParam(url, key, fallback) {
619
- const value = Number(url.searchParams.get(key));
620
- return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
621
- }
622
- function requiredSearch(url, key) {
623
- const value = url.searchParams.get(key);
624
- if (!value) {
625
- throw new Error(`${key} is required`);
626
- }
627
- return value;
628
- }
629
501
  function optionalEnv(key) {
630
502
  const value = process.env[key]?.trim();
631
503
  return value || undefined;
632
504
  }
633
505
  function activeSettingsValues(current) {
634
506
  return {
635
- TELEGRAM_ALLOW_ANY_CHAT: boolValue(current.telegramAllowAnyChat),
636
507
  TELEGRAM_BOT_TOKEN: current.telegramBotToken,
637
- TELEGRAM_ADMIN_USER_IDS: current.telegramAdminUserIds.join(","),
638
- TELEGRAM_ALLOWED_USER_IDS: current.telegramAllowedUserIds.join(","),
639
- TELEGRAM_READONLY_USER_IDS: current.telegramReadOnlyUserIds.join(","),
640
- TELEGRAM_ALLOWED_CHAT_IDS: current.telegramAllowedChatIds.join(","),
641
- TELEGRAM_ROLE_POLICIES_JSON: optionalEnv("TELEGRAM_ROLE_POLICIES_JSON"),
642
508
  TELEGRAM_TRANSPORT: current.telegramTransport,
643
509
  TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
644
510
  TELEGRAM_WEBHOOK_HOST: current.telegramWebhookHost,
@@ -745,246 +611,3 @@ function shutdown() {
745
611
  runtime.dispose();
746
612
  server.close(() => process.exit(0));
747
613
  }
748
- function renderLoginPage(currentAuth) {
749
- return `<!doctype html>
750
- <html lang="en">
751
- <head>
752
- <meta charset="utf-8">
753
- <meta name="viewport" content="width=device-width, initial-scale=1">
754
- <title>NordRelay Login</title>
755
- <style>
756
- body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
757
- form{width:min(420px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
758
- h1{font-size:24px;margin:0 0 8px}
759
- p{color:#5d665d;margin:0 0 18px}
760
- label{display:block;font-size:13px;color:#4b544d;margin:14px 0 6px}
761
- input{box-sizing:border-box;width:100%;height:40px;border:1px solid #cfd6ce;border-radius:6px;padding:0 10px;font:inherit}
762
- button{margin-top:18px;width:100%;height:42px;border:0;border-radius:6px;background:#205c43;color:white;font-weight:650;cursor:pointer}
763
- .error{color:#9b1c1c;min-height:22px;margin-top:12px}
764
- </style>
765
- </head>
766
- <body>
767
- <form id="login">
768
- <h1>NordRelay Dashboard</h1>
769
- <p>${currentAuth.publicBind ? "Remote dashboard access requires authentication." : "Authentication required."}</p>
770
- ${currentAuth.token ? '<label>Token</label><input id="token" name="token" type="password" autocomplete="current-password">' : ""}
771
- ${currentAuth.user ? '<label>User</label><input id="user" name="user" autocomplete="username"><label>Password</label><input id="password" name="password" type="password" autocomplete="current-password">' : ""}
772
- <button>Sign in</button>
773
- <div class="error" id="error"></div>
774
- </form>
775
- <script>
776
- document.getElementById('login').addEventListener('submit', async (event) => {
777
- event.preventDefault();
778
- const payload = {
779
- token: document.getElementById('token')?.value || undefined,
780
- user: document.getElementById('user')?.value || undefined,
781
- password: document.getElementById('password')?.value || undefined,
782
- };
783
- const res = await fetch('/api/auth', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify(payload) });
784
- if (!res.ok) {
785
- document.getElementById('error').textContent = 'Invalid credentials';
786
- return;
787
- }
788
- if (payload.token) localStorage.setItem('nordrelayDashboardToken', payload.token);
789
- location.href = '/';
790
- });
791
- </script>
792
- </body>
793
- </html>`;
794
- }
795
- function renderDashboardApp(options) {
796
- return `<!doctype html>
797
- <html lang="en">
798
- <head>
799
- <meta charset="utf-8">
800
- <meta name="viewport" content="width=device-width, initial-scale=1">
801
- <title>NordRelay Dashboard</title>
802
- <script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
803
- <link rel="stylesheet" href="/assets/dashboard.css">
804
- </head>
805
- <body>
806
- <div class="app">
807
- <aside class="sidebar" id="sidebar">
808
- <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
809
- <nav>
810
- ${renderDashboardNav()}
811
- </nav>
812
- </aside>
813
- <main>
814
- <header>
815
- <button class="menu" id="menuBtn">Menu</button>
816
- <div>
817
- <h1 id="pageTitle">Overview</h1>
818
- <p id="sessionLine">Loading session...</p>
819
- </div>
820
- <div class="header-actions">
821
- <span id="connectionStatus" class="badge">Connecting</span>
822
- <select id="agentSelect"></select>
823
- <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
824
- <button id="refreshBtn">Refresh</button>
825
- </div>
826
- </header>
827
-
828
- <section class="page active" id="page-overview">
829
- <div class="metrics" id="metrics"></div>
830
- <div class="stack">
831
- <div class="panel"><h2>Current Session</h2><pre id="sessionText"></pre></div>
832
- <div class="overview-adapter-grid">
833
- <div class="panel"><h2>Agent Adapters</h2><div id="agentAdapters"></div></div>
834
- <div class="panel"><h2>Chat Adapters</h2><div id="chatAdapters"></div></div>
835
- </div>
836
- </div>
837
- </section>
838
-
839
- <section class="page" id="page-chat">
840
- <div class="chat-layout">
841
- <div class="panel chat-panel">
842
- <div class="chat-toolbar">
843
- <button id="newSessionBtn">New session</button>
844
- <button id="retryBtn" class="secondary">Retry</button>
845
- <button id="editLastBtn" class="secondary">Edit last</button>
846
- <button id="syncBtn" class="secondary">Sync</button>
847
- <button id="notifyBtn" class="secondary">Notify</button>
848
- <button id="clearChatBtn" class="secondary">Clear history</button>
849
- <button id="abortBtn">Abort</button>
850
- <button id="handbackBtn">Handback</button>
851
- </div>
852
- <div class="control-grid" id="sessionControls"></div>
853
- <div id="messages" class="messages"></div>
854
- <form id="promptForm" class="composer">
855
- <div class="composer-fields">
856
- <textarea id="promptInput" placeholder="Send a message to the active coding agent..." rows="3"></textarea>
857
- <div class="attachment-row">
858
- <label class="file-button" for="fileInput">Attach files</label>
859
- <input id="fileInput" type="file" multiple>
860
- <button type="button" id="recordBtn" class="secondary">Record voice</button>
861
- <span id="fileSummary">No files selected</span>
862
- <button type="button" id="clearFilesBtn" class="secondary">Clear</button>
863
- </div>
864
- </div>
865
- <button>Send</button>
866
- </form>
867
- </div>
868
- <div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
869
- </div>
870
- </section>
871
-
872
- <section class="page" id="page-tasks">
873
- <div class="panel">
874
- <div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
875
- <div id="tasksList" class="list"></div>
876
- </div>
877
- </section>
878
-
879
- <section class="page" id="page-sessions">
880
- <div class="panel">
881
- <div class="sessions-toolbar">
882
- <div class="row search-row"><input id="sessionSearch" placeholder="Search sessions"><button id="sessionSearchBtn">Search</button></div>
883
- <div class="row attach-row"><input id="attachInput" placeholder="Thread ID to attach/switch"><button id="attachBtn">Attach</button></div>
884
- </div>
885
- <div id="sessionsList" class="list"></div>
886
- <div id="sessionsPager" class="pager"></div>
887
- </div>
888
- </section>
889
-
890
- <section class="page" id="page-queue">
891
- <div class="panel">
892
- <div class="row"><button data-queue="pause">Pause</button><button data-queue="resume">Resume</button><button data-queue="clear" class="danger">Clear</button><span id="queueStatus"></span></div>
893
- <div id="queueList" class="list"></div>
894
- </div>
895
- </section>
896
-
897
- <section class="page" id="page-activity">
898
- <div class="panel">
899
- <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="cli">CLI</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
900
- <div id="activityList" class="list"></div>
901
- </div>
902
- </section>
903
-
904
- <section class="page" id="page-artifacts">
905
- <div class="panel">
906
- <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>
907
- <div id="artifactPreview" class="preview"></div>
908
- <div id="artifactList" class="list"></div>
909
- </div>
910
- </section>
911
-
912
- <section class="page" id="page-adapters">
913
- <div class="panel">
914
- <div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
915
- <div id="adapterHealth" class="list"></div>
916
- </div>
917
- </section>
918
-
919
- <section class="page" id="page-access">
920
- <div class="panel">
921
- <div class="row"><button id="loadAccessBtn">Reload access</button><button id="saveAccessBtn">Save access settings</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
922
- <div id="accessPanel" class="settings-grid"></div>
923
- <h2>Locks</h2>
924
- <div id="locksList" class="list"></div>
925
- <h2>Audit</h2>
926
- <div class="row"><input id="auditLimit" type="number" value="50" min="1" max="200"><button id="loadAuditBtn">Load audit</button></div>
927
- <div id="auditList" class="list"></div>
928
- </div>
929
- </section>
930
-
931
- <section class="page" id="page-version">
932
- <div class="panel">
933
- <div class="row version-actions"><button id="loadVersionBtn">Check versions</button><button id="updateBtn" class="secondary">Update NordRelay</button></div>
934
- <div id="versionPanel" class="list"></div>
935
- <h2 class="version-update-title">Agent update jobs</h2>
936
- <div id="agentUpdateJobs" class="list"></div>
937
- </div>
938
- </section>
939
-
940
- <section class="page" id="page-settings">
941
- <div class="panel">
942
- <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
943
- <div id="settingsTabs" class="tabs"></div>
944
- <div id="settingsForm" class="settings-grid"></div>
945
- </div>
946
- </section>
947
-
948
- <section class="page" id="page-logs">
949
- <div class="panel">
950
- <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>
951
- <pre id="logs" class="log-view"></pre>
952
- </div>
953
- </section>
954
-
955
- <section class="page" id="page-diagnostics">
956
- <div class="panel"><div id="diagnostics" class="list"></div></div>
957
- </section>
958
-
959
- <footer>
960
- <span id="footerVersion">NordRelay</span>
961
- <span id="footerHealth">Health: loading</span>
962
- <span>Dashboard bind: ${escapeHTML(options.authRequired ? "authenticated" : "local")}</span>
963
- </footer>
964
- </main>
965
- </div>
966
- <dialog id="newSessionDialog">
967
- <form method="dialog" id="newSessionForm">
968
- <h2>New Session</h2>
969
- <div class="form-grid">
970
- <label>Agent<select id="newAgent"></select></label>
971
- <label>Workspace<input id="newWorkspace" list="workspaceOptions" placeholder="Current workspace"></label>
972
- <label>Model<select id="newModel"></select></label>
973
- <label id="newReasoningWrap">Reasoning<select id="newReasoning"></select></label>
974
- <label id="newLaunchWrap">Launch profile<select id="newLaunch"></select></label>
975
- <label id="newFastWrap" class="checkbox"><input id="newFast" type="checkbox"> Fast mode</label>
976
- </div>
977
- <datalist id="workspaceOptions"></datalist>
978
- <div class="row dialog-actions"><button type="button" id="cancelSessionBtn" class="secondary">Cancel</button><button id="createSessionBtn" value="default">Create session</button></div>
979
- </form>
980
- </dialog>
981
- <dialog id="sessionDetailDialog">
982
- <div id="sessionDetail"></div>
983
- <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
984
- </dialog>
985
- <div id="toolTooltip" class="tool-tooltip"></div>
986
- <div id="toast"></div>
987
- <script src="/assets/dashboard.js"></script>
988
- </body>
989
- </html>`;
990
- }