@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.
- package/.env.example +155 -64
- package/README.md +81 -65
- package/dist/access-control.js +126 -115
- package/dist/agent-updates.js +62 -9
- package/dist/bot-rendering.js +838 -0
- package/dist/bot-ui.js +1 -0
- package/dist/bot.js +342 -2498
- package/dist/channel-actions.js +8 -8
- package/dist/channel-runtime.js +89 -0
- package/dist/config-metadata.js +238 -0
- package/dist/config.js +0 -58
- package/dist/index.js +8 -0
- package/dist/operations.js +63 -9
- package/dist/relay-artifact-service.js +126 -0
- package/dist/relay-external-activity-monitor.js +216 -0
- package/dist/relay-queue-service.js +66 -0
- package/dist/relay-runtime-types.js +1 -0
- package/dist/relay-runtime.js +96 -354
- package/dist/settings-service.js +2 -117
- package/dist/support-bundle.js +205 -0
- package/dist/telegram-access-commands.js +123 -0
- package/dist/telegram-access-middleware.js +129 -0
- package/dist/telegram-agent-commands.js +212 -0
- package/dist/telegram-artifact-commands.js +139 -0
- package/dist/telegram-channel-runtime.js +132 -0
- package/dist/telegram-command-menu.js +55 -0
- package/dist/telegram-command-types.js +1 -0
- package/dist/telegram-diagnostics-command.js +102 -0
- package/dist/telegram-general-commands.js +52 -0
- package/dist/telegram-operational-commands.js +153 -0
- package/dist/telegram-output.js +216 -0
- package/dist/telegram-preference-commands.js +198 -0
- package/dist/telegram-queue-commands.js +278 -0
- package/dist/telegram-support-command.js +53 -0
- package/dist/telegram-update-commands.js +93 -0
- package/dist/user-management.js +708 -0
- package/dist/web-api-contract.js +104 -0
- package/dist/web-api-types.js +1 -0
- package/dist/web-dashboard-access-routes.js +163 -0
- package/dist/web-dashboard-artifact-routes.js +65 -0
- package/dist/web-dashboard-assets.js +35 -2
- package/dist/web-dashboard-http.js +143 -0
- package/dist/web-dashboard-pages.js +257 -0
- package/dist/web-dashboard-runtime-routes.js +92 -0
- package/dist/web-dashboard-session-routes.js +209 -0
- package/dist/web-dashboard-ui.js +14 -14
- package/dist/web-dashboard.js +330 -707
- package/dist/webui-assets/dashboard.css +989 -0
- package/dist/webui-assets/dashboard.js +1750 -0
- package/dist/zip-writer.js +83 -0
- package/package.json +13 -4
- package/plugins/nordrelay/.codex-plugin/plugin.json +1 -1
- package/plugins/nordrelay/commands/remote.md +1 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +227 -78
- package/plugins/nordrelay/skills/telegram-remote/SKILL.md +1 -1
- package/dist/web-dashboard-client.js +0 -275
- package/dist/web-dashboard-style.js +0 -9
package/dist/web-dashboard.js
CHANGED
|
@@ -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 {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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 (
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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
|
-
|
|
102
|
-
|
|
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
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
394
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
421
|
-
const user = optionalStringField(body, "user");
|
|
244
|
+
const email = optionalStringField(body, "email");
|
|
422
245
|
const password = optionalStringField(body, "password");
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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 (
|
|
429
|
-
|
|
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
|
-
|
|
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
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
user,
|
|
463
|
-
password,
|
|
329
|
+
user: publicUser(authUser.user),
|
|
330
|
+
groups: authUser.groups,
|
|
331
|
+
permissions: authUser.permissions,
|
|
464
332
|
};
|
|
465
333
|
}
|
|
466
|
-
function
|
|
467
|
-
|
|
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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
485
|
-
|
|
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
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
|
498
|
-
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
430
|
+
function assertSessionDetailScope(authUser, threadId, detail) {
|
|
431
|
+
const record = objectValue(detail.record);
|
|
432
|
+
if (record) {
|
|
433
|
+
assertSessionScope(authUser, record);
|
|
434
|
+
return;
|
|
515
435
|
}
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
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
|
-
|
|
441
|
+
throw new AccessDeniedError("Access denied: session is outside your group scope.");
|
|
533
442
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
546
|
-
|
|
547
|
-
|
|
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
|
|
550
|
-
|
|
551
|
-
|
|
463
|
+
async function assertCurrentSessionScope(authUser) {
|
|
464
|
+
const snapshot = await runtime.snapshot();
|
|
465
|
+
assertSessionScope(authUser, snapshot.session);
|
|
552
466
|
}
|
|
553
|
-
function
|
|
554
|
-
|
|
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
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
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
|
|
572
|
-
|
|
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
|
-
}
|