@nordbyte/nordrelay 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -20,6 +20,17 @@
20
20
  { re: /^\/api\/agent-update\/[^\/]+\/input$/, methods: ["POST"] },
21
21
  { re: /^\/api\/agent-update\/[^\/]+\/cancel$/, methods: ["POST"] },
22
22
  { path: "/api/adapters/health", methods: ["GET"] },
23
+ { path: "/api/adapters/conformance", methods: ["GET"] },
24
+ { path: "/api/peers", methods: ["GET", "POST"] },
25
+ { path: "/api/peers/invite", methods: ["POST"] },
26
+ { path: "/api/peers/pair", methods: ["POST"] },
27
+ { path: "/api/peers/probe", methods: ["POST"] },
28
+ { path: "/api/peers/global-sessions", methods: ["GET"] },
29
+ { re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
30
+ { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
31
+ { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
32
+ { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
33
+ { re: /^\/api\/peers\/[^\/]+\/events$/, methods: ["GET"] },
23
34
  { path: "/api/permissions", methods: ["GET"] },
24
35
  { path: "/api/users", methods: ["GET", "POST"] },
25
36
  { re: /^\/api\/users\/[^\/]+$/, methods: ["PATCH"] },
@@ -30,18 +41,23 @@
30
41
  { re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
31
42
  { re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
32
43
  { re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
44
+ { re: /^\/api\/users\/[^\/]+\/slack$/, methods: ["POST"] },
45
+ { re: /^\/api\/users\/[^\/]+\/slack\/[^\/]+$/, methods: ["DELETE"] },
33
46
  { path: "/api/groups", methods: ["GET", "POST"] },
34
47
  { re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
35
48
  { path: "/api/telegram-chats", methods: ["GET", "POST"] },
36
49
  { re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
37
50
  { path: "/api/discord-channels", methods: ["GET", "POST"] },
38
51
  { re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
52
+ { path: "/api/slack-channels", methods: ["GET", "POST"] },
53
+ { re: /^\/api\/slack-channels\/[^\/]+$/, methods: ["PATCH"] },
39
54
  { path: "/api/audit", methods: ["GET"] },
40
55
  { path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
41
56
  { path: "/api/auth/status", methods: ["GET"] },
42
57
  { path: "/api/auth/login", methods: ["POST"] },
43
58
  { path: "/api/auth/logout", methods: ["POST"] },
44
59
  { path: "/api/settings", methods: ["GET", "PATCH"] },
60
+ { path: "/api/settings/wizard/test", methods: ["POST"] },
45
61
  { path: "/api/control-options", methods: ["GET"] },
46
62
  { path: "/api/sessions", methods: ["GET"] },
47
63
  { path: "/api/sessions/new", methods: ["POST"] },
@@ -84,6 +100,32 @@
84
100
  const method = normalizeMethod(options.method, options.body);
85
101
  const url = apiUrl(path, options.query);
86
102
  assertApiRoute(url.pathname, method);
103
+ if (!options.local && shouldProxyApi(url.pathname)) {
104
+ const peerId = selectedPeerTarget();
105
+ const proxyBody = JSON.stringify({
106
+ method,
107
+ path: url.pathname,
108
+ query: queryObject(url),
109
+ body: bodyObject(options.body),
110
+ contextKey: "web:dashboard"
111
+ });
112
+ const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
113
+ method: "POST",
114
+ headers: { "content-type": "application/json" },
115
+ body: proxyBody
116
+ });
117
+ if (res2.status === 401) {
118
+ location.reload();
119
+ return (
120
+ /** @type {never} */
121
+ void 0
122
+ );
123
+ }
124
+ const text2 = await res2.text();
125
+ const data2 = text2 ? JSON.parse(text2) : {};
126
+ if (!res2.ok) throw new Error(data2.error || res2.statusText);
127
+ return data2;
128
+ }
87
129
  const body = normalizeBody(options.body);
88
130
  const headers = {
89
131
  ...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
@@ -102,6 +144,46 @@
102
144
  if (!res.ok) throw new Error(data.error || res.statusText);
103
145
  return data;
104
146
  }
147
+ function shouldProxyApi(path) {
148
+ const peerId = selectedPeerTarget();
149
+ if (!peerId || peerId === "local") return false;
150
+ if (!path.startsWith("/api/")) return false;
151
+ return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || path === "/api/peers/probe" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
152
+ }
153
+ function isLocalAdminApi(path) {
154
+ return path === "/api/permissions" || path === "/api/settings" || path === "/api/audit" || path === "/api/locks" || path === "/api/users" || path === "/api/groups" || path === "/api/telegram-chats" || path === "/api/discord-channels" || path === "/api/slack-channels" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(path) || /^\/api\/slack-channels\//.test(path);
155
+ }
156
+ function selectedPeerTarget() {
157
+ const runtimeState = (
158
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { selectedPeer?: string } }} */
159
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE
160
+ );
161
+ return runtimeState?.selectedPeer || "local";
162
+ }
163
+ function queryObject(url) {
164
+ const result = {};
165
+ for (const [key, value] of url.searchParams.entries()) {
166
+ if (result[key] === void 0) result[key] = value;
167
+ else if (Array.isArray(result[key])) result[key].push(value);
168
+ else result[key] = [result[key], value];
169
+ }
170
+ return result;
171
+ }
172
+ function bodyObject(body) {
173
+ if (body === void 0 || body === null) return {};
174
+ if (typeof body === "string") {
175
+ try {
176
+ return body ? JSON.parse(body) : {};
177
+ } catch {
178
+ return { value: body };
179
+ }
180
+ }
181
+ if (isNativeBody(body)) return {};
182
+ return (
183
+ /** @type {Record<string, unknown>} */
184
+ body
185
+ );
186
+ }
105
187
  function apiUrl(path, query) {
106
188
  const url = new URL(path, location.origin);
107
189
  if (query) {
@@ -148,7 +230,8 @@
148
230
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
149
231
  }
150
232
  }
151
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessionsTimer: null };
233
+ const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, settingsWizard: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessions: null, peers: null, peerInviteSecrets: {}, peerProbeResult: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
234
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
152
235
  function toast(msg, options = {}) {
153
236
  const el = document.getElementById("toast");
154
237
  el.textContent = msg;
@@ -245,17 +328,20 @@
245
328
  ["#abortBtn", "prompt.abort"],
246
329
  ["#clearChatBtn", "sessions.write"],
247
330
  ["#saveSettingsBtn", "settings.write"],
331
+ ["#settingsWizardBtn", "settings.write"],
248
332
  ["#restartBtn", "system.restart"],
249
333
  ["#updateBtn", "updates.run"],
250
334
  ["#clearLogsBtn", "logs.clear"],
251
- ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
335
+ ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn,#createSlackChannelBtn", "users.write"],
336
+ ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke],[data-peer-invite-delete]", "peers.write"],
337
+ ["#checkPeerReachabilityBtn,[data-peer-probe]", "peers.connect"],
252
338
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
253
339
  ["[data-switch]", "sessions.write"],
254
340
  ["[data-queue],[data-q]", "queue.write"],
255
341
  ["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
256
342
  ["[data-auth-login],[data-auth-logout]", "auth.manage"],
257
343
  ["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
258
- ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle]", "users.write"]
344
+ ["[data-user-edit],[data-user-toggle],[data-user-code],[data-user-link],[data-user-discord-code],[data-user-discord-link],[data-user-slack-code],[data-user-slack-link],[data-user-password],[data-user-revoke],[data-telegram-unlink],[data-discord-unlink],[data-slack-unlink],[data-group-edit],[data-chat-edit],[data-chat-toggle],[data-discord-channel-edit],[data-discord-channel-toggle],[data-slack-channel-edit],[data-slack-channel-toggle]", "users.write"]
259
345
  ];
260
346
  disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
261
347
  el.disabled = !can(permission);
@@ -311,6 +397,7 @@
311
397
  if (name === "tasks") await loadTasks();
312
398
  if (name === "metrics") await loadMetrics();
313
399
  if (name === "adapters") await loadAdapterHealth();
400
+ if (name === "peers") await loadPeers();
314
401
  if (name === "access") await loadAccess();
315
402
  if (name === "version") await loadVersion();
316
403
  }
@@ -354,9 +441,11 @@
354
441
  }
355
442
  const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
356
443
  async function loadBootstrap() {
357
- const data = await api("/api/bootstrap");
358
- state.auth = data.auth || null;
359
- state.permissions = data.auth?.permissions || [];
444
+ const local = await api("/api/bootstrap", { local: true });
445
+ state.auth = local.auth || null;
446
+ state.permissions = local.auth?.permissions || [];
447
+ await loadPeerSelector();
448
+ const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
360
449
  state.snapshot = data.status.snapshot;
361
450
  state.controls = data.controls;
362
451
  state.enabledAgents = data.enabledAgents || [];
@@ -368,7 +457,7 @@
368
457
  renderAdapters(data.channels, data.agentAdapters);
369
458
  document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
370
459
  document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
371
- document.getElementById("footerUser").textContent = "User: " + (data.auth?.user?.email || "-");
460
+ document.getElementById("footerUser").textContent = "User: " + (local.auth?.user?.email || "-") + (state.selectedPeer && state.selectedPeer !== "local" ? " / target peer" : "");
372
461
  const agentSelect = document.getElementById("agentSelect");
373
462
  agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
374
463
  agentSelect.value = state.snapshot.session.agentId;
@@ -385,6 +474,35 @@
385
474
  });
386
475
  applyPermissions();
387
476
  }
477
+ async function loadPeerSelector() {
478
+ const peerSelect = document.getElementById("peerSelect");
479
+ if (!peerSelect) return;
480
+ if (!can("peers.read")) {
481
+ peerSelect.innerHTML = '<option value="local">Local</option>';
482
+ peerSelect.value = "local";
483
+ state.selectedPeer = "local";
484
+ return;
485
+ }
486
+ try {
487
+ const peers = await api("/api/peers", { local: true });
488
+ state.peers = peers;
489
+ const available = (peers.peers || []).filter((p) => p.enabled && p.url);
490
+ peerSelect.innerHTML = '<option value="local">Local node</option>' + available.map((p) => '<option value="' + attr(p.id) + '">' + esc(p.name) + "</option>").join("");
491
+ if (state.selectedPeer !== "local" && !available.some((p) => p.id === state.selectedPeer)) state.selectedPeer = "local";
492
+ peerSelect.value = state.selectedPeer;
493
+ peerSelect.onchange = () => safe(async () => {
494
+ state.selectedPeer = peerSelect.value || "local";
495
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
496
+ connectEvents();
497
+ toast(state.selectedPeer === "local" ? "Target: local" : "Target: " + peerSelect.options[peerSelect.selectedIndex].text);
498
+ await loadBootstrap();
499
+ await reloadCurrentPage();
500
+ });
501
+ } catch {
502
+ peerSelect.innerHTML = '<option value="local">Local node</option>';
503
+ peerSelect.value = "local";
504
+ }
505
+ }
388
506
  function renderSnapshot(s) {
389
507
  document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
390
508
  document.getElementById("metrics").innerHTML = [
@@ -409,6 +527,7 @@
409
527
  renderActiveSessions(data.sessions || []);
410
528
  }
411
529
  function renderActiveSessions(items) {
530
+ state.activeSessions = { sessions: items || [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
412
531
  const box = document.getElementById("activeSessions");
413
532
  if (!box) return;
414
533
  box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
@@ -456,6 +575,7 @@
456
575
  if (source === "cli") return "CLI";
457
576
  if (source === "telegram") return "Telegram";
458
577
  if (source === "discord") return "Discord";
578
+ if (source === "slack") return "Slack";
459
579
  if (source === "web") return "WebUI";
460
580
  return source || "-";
461
581
  }
@@ -552,9 +672,6 @@
552
672
  if (status === "running") return "planned";
553
673
  return "disabled";
554
674
  }
555
- state.activeSessionsTimer = setInterval(() => {
556
- if (state.currentPage === "overview") safe(loadActiveSessions);
557
- }, 5e3);
558
675
  function scrollChatToBottom() {
559
676
  const box = document.getElementById("messages");
560
677
  if (!box) return;
@@ -600,7 +717,8 @@
600
717
  let currentAgentMessage = null;
601
718
  function connectEvents() {
602
719
  if (state.events) state.events.close();
603
- const events = new EventSource("/api/events");
720
+ const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
721
+ const events = new EventSource(eventsUrl);
604
722
  state.events = events;
605
723
  setConnection("Connecting", "warn");
606
724
  events.onopen = () => {
@@ -618,6 +736,11 @@
618
736
  });
619
737
  events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
620
738
  events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
739
+ events.addEventListener("active_sessions_update", (e) => {
740
+ const d = JSON.parse(e.data);
741
+ state.activeSessions = d.active || null;
742
+ if (state.currentPage === "overview") renderActiveSessions(state.activeSessions?.sessions || []);
743
+ });
621
744
  events.addEventListener("session_update", (e) => {
622
745
  loadBootstrap();
623
746
  loadChatHistory();
@@ -1129,6 +1252,50 @@
1129
1252
  if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
1130
1253
  return true;
1131
1254
  }
1255
+ document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
1256
+ document.getElementById("artifactSearch").oninput = renderArtifacts;
1257
+ document.getElementById("artifactKind").onchange = renderArtifacts;
1258
+ document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
1259
+ if (!can("files.write")) {
1260
+ toast("Permission required: files.write");
1261
+ return;
1262
+ }
1263
+ const turnIds = [...state.selectedArtifactTurns];
1264
+ if (turnIds.length === 0) {
1265
+ toast("No artifact turns selected");
1266
+ return;
1267
+ }
1268
+ if (confirm("Delete " + turnIds.length + " selected artifact turn(s)?")) {
1269
+ const r = await api("/api/artifacts/bulk", { method: "POST", body: JSON.stringify({ action: "delete", turnIds }) });
1270
+ state.selectedArtifactTurns.clear();
1271
+ toast("Deleted " + (r.removed || []).length + " artifact turn(s)");
1272
+ loadArtifacts();
1273
+ }
1274
+ });
1275
+ function highlightCode(text) {
1276
+ return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
1277
+ }
1278
+ function isRemotePeerTarget() {
1279
+ return Boolean(state.selectedPeer && state.selectedPeer !== "local");
1280
+ }
1281
+ function artifactFileHref(turnId, path) {
1282
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1283
+ }
1284
+ function artifactZipHref(turnId) {
1285
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/zip?turnId=" + encodeURIComponent(turnId);
1286
+ }
1287
+ async function downloadArtifactFile(turnId, path) {
1288
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1289
+ downloadBase64(file.name || path, file.dataBase64 || "", file.mimeType || "application/octet-stream");
1290
+ }
1291
+ async function downloadArtifactZip(turnId) {
1292
+ const file = await api("/api/artifacts/zip", { query: { turnId } });
1293
+ downloadBase64(file.name || "nordrelay-artifacts-" + turnId + ".zip", file.dataBase64 || "", file.mimeType || "application/zip");
1294
+ }
1295
+ async function artifactDataUrl(turnId, path) {
1296
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1297
+ return "data:" + (file.mimeType || "application/octet-stream") + ";base64," + (file.dataBase64 || "");
1298
+ }
1132
1299
  function renderArtifacts() {
1133
1300
  const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
1134
1301
  const kind = document.getElementById("artifactKind").value;
@@ -1137,16 +1304,27 @@
1137
1304
  const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
1138
1305
  if (files.length === 0) return "";
1139
1306
  const gallery = files.map((a) => {
1140
- const href = "/api/artifacts/file?turnId=" + encodeURIComponent(r.turnId) + "&path=" + encodeURIComponent(a.relativePath);
1141
- const img = /\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name) ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
1142
- return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
1307
+ const href = artifactFileHref(r.turnId, a.relativePath);
1308
+ const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(a.name);
1309
+ const img = isImage && !isRemotePeerTarget() ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
1310
+ return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '" data-open-artifact="' + attr(r.turnId) + '" data-open-path="' + attr(a.relativePath) + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
1143
1311
  }).join("");
1144
- return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="/api/artifacts/zip?turnId=' + encodeURIComponent(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
1312
+ return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="' + artifactZipHref(r.turnId) + '" data-zip-artifact="' + attr(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
1145
1313
  }).join("") || '<div class="item">No artifacts.</div>';
1146
1314
  document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
1147
1315
  if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
1148
1316
  else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
1149
1317
  });
1318
+ document.querySelectorAll("[data-open-artifact]").forEach((a) => a.onclick = (e) => {
1319
+ if (!isRemotePeerTarget()) return;
1320
+ e.preventDefault();
1321
+ safe(() => downloadArtifactFile(a.dataset.openArtifact, a.dataset.openPath));
1322
+ });
1323
+ document.querySelectorAll("[data-zip-artifact]").forEach((a) => a.onclick = (e) => {
1324
+ if (!isRemotePeerTarget()) return;
1325
+ e.preventDefault();
1326
+ safe(() => downloadArtifactZip(a.dataset.zipArtifact));
1327
+ });
1150
1328
  document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
1151
1329
  if (!can("files.write")) {
1152
1330
  toast("Permission required: files.write");
@@ -1161,37 +1339,14 @@
1161
1339
  document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
1162
1340
  applyPermissions();
1163
1341
  }
1164
- document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
1165
- document.getElementById("artifactSearch").oninput = renderArtifacts;
1166
- document.getElementById("artifactKind").onchange = renderArtifacts;
1167
1342
  document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
1168
1343
  const turnIds = [...state.selectedArtifactTurns];
1169
1344
  if (turnIds.length === 0) {
1170
1345
  toast("No artifact turns selected");
1171
1346
  return;
1172
1347
  }
1173
- turnIds.forEach((turnId) => window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
1348
+ turnIds.forEach((turnId) => isRemotePeerTarget() ? safe(() => downloadArtifactZip(turnId)) : window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
1174
1349
  };
1175
- document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
1176
- if (!can("files.write")) {
1177
- toast("Permission required: files.write");
1178
- return;
1179
- }
1180
- const turnIds = [...state.selectedArtifactTurns];
1181
- if (turnIds.length === 0) {
1182
- toast("No artifact turns selected");
1183
- return;
1184
- }
1185
- if (confirm("Delete " + turnIds.length + " selected artifact turn(s)?")) {
1186
- const r = await api("/api/artifacts/bulk", { method: "POST", body: JSON.stringify({ action: "delete", turnIds }) });
1187
- state.selectedArtifactTurns.clear();
1188
- toast("Deleted " + (r.removed || []).length + " artifact turn(s)");
1189
- loadArtifacts();
1190
- }
1191
- });
1192
- function highlightCode(text) {
1193
- return esc(text).replace(/\\b(import|export|const|let|function|return|if|else|for|while|class|interface|type|async|await)\\b/g, '<span class="chip">$1</span>');
1194
- }
1195
1350
  async function previewArtifact(turnId, path) {
1196
1351
  const target = document.getElementById("artifactPreview");
1197
1352
  target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
@@ -1199,7 +1354,8 @@
1199
1354
  try {
1200
1355
  const data = await api("/api/artifacts/preview", { query: { turnId, path } });
1201
1356
  if (data.kind === "image") {
1202
- target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="/api/artifacts/file?turnId=' + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path) + '"></div>';
1357
+ const src = isRemotePeerTarget() ? await artifactDataUrl(turnId, path) : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1358
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="' + src + '"></div>';
1203
1359
  return;
1204
1360
  }
1205
1361
  if (data.kind === "text") {
@@ -1296,6 +1452,29 @@
1296
1452
  ["Aborted", jobs.aborted]
1297
1453
  ];
1298
1454
  }
1455
+ function metricProcessRows(d) {
1456
+ const p = d.process || {};
1457
+ const memory = p.memory || {};
1458
+ const cpu = p.cpu || {};
1459
+ const loop = p.eventLoop || {};
1460
+ return [
1461
+ ["PID", p.pid],
1462
+ ["Node", p.nodeVersion],
1463
+ ["Platform", [p.platform, p.arch].filter(Boolean).join(" ")],
1464
+ ["Uptime", fmtDuration(p.uptimeMs)],
1465
+ ["Started", fmtDate(p.startedAt)],
1466
+ ["RSS", fmtBytes(memory.rssBytes || 0)],
1467
+ ["Heap used", fmtBytes(memory.heapUsedBytes || 0)],
1468
+ ["Heap total", fmtBytes(memory.heapTotalBytes || 0)],
1469
+ ["CPU total", fmtDuration(cpu.totalMs)],
1470
+ ["CPU avg", cpu.percentSinceStart === null || cpu.percentSinceStart === void 0 ? "-" : cpu.percentSinceStart + "%"],
1471
+ ["Event loop p95", formatMs(loop.delayP95Ms)],
1472
+ ["Event loop max", formatMs(loop.delayMaxMs)]
1473
+ ];
1474
+ }
1475
+ function formatMs(value) {
1476
+ return value === null || value === void 0 ? "-" : value + "ms";
1477
+ }
1299
1478
  function rateRows(name, rate) {
1300
1479
  return [
1301
1480
  ["Queued", rate?.queued ?? 0],
@@ -1311,7 +1490,8 @@
1311
1490
  }
1312
1491
  function renderMetrics(d) {
1313
1492
  const adapters = d.adapters || {};
1314
- document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
1493
+ const adapterCards = Object.entries(adapters).map(([name, rate]) => card(name.charAt(0).toUpperCase() + name.slice(1) + " rate limits", rateRows("", rate).map(([k, v]) => [String(k).trim(), v]))).join("");
1494
+ document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(d)) + card("Jobs", metricJobRows(d)) + adapterCards + "</div>";
1315
1495
  }
1316
1496
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1317
1497
  function activityQuery() {
@@ -1329,7 +1509,7 @@
1329
1509
  }
1330
1510
  function activityActorText(e) {
1331
1511
  const a = e.actor || {};
1332
- return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", cli: "CLI", system: "System" }[a.channel] || "System");
1512
+ return a.label || a.username || a.id || ({ web: "Web user", telegram: "Telegram user", discord: "Discord user", slack: "Slack user", cli: "CLI", system: "System" }[a.channel] || "System");
1333
1513
  }
1334
1514
  function activityMetaHtml(e) {
1335
1515
  const workspace = activityWorkspace(e);
@@ -1361,12 +1541,14 @@
1361
1541
  URL.revokeObjectURL(a.href);
1362
1542
  };
1363
1543
  async function loadSettings() {
1544
+ state.settingsWizard = null;
1545
+ document.getElementById("settingsTabs").style.display = "";
1364
1546
  setLoading("settingsForm", "Loading settings...");
1365
1547
  const data = await api("/api/settings");
1366
1548
  state.settings = data.settings;
1367
1549
  renderSettings();
1368
1550
  }
1369
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
1551
+ const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1370
1552
  const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1371
1553
  function orderedSettingsGroups(groups) {
1372
1554
  const known = settingsGroupOrder.filter((name) => groups[name]);
@@ -1505,7 +1687,7 @@
1505
1687
  return '<div class="permission-grid full-span">' + (state.userManagement?.permissions || []).map((p) => '<label class="checkbox"><input type="checkbox" data-permission-choice="' + attr(p) + '" ' + (selectedSet.has(p) ? "checked" : "") + "> " + esc(p) + "</label>").join("") + "</div>";
1506
1688
  }
1507
1689
  function selectedValues(selector) {
1508
- return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.value).filter(Boolean);
1690
+ return Array.from(document.querySelectorAll(selector + ":checked")).map((x) => x.dataset.groupChoice || x.dataset.permissionChoice || x.dataset.discordChannelChoice || x.dataset.slackChannelChoice || x.value).filter(Boolean);
1509
1691
  }
1510
1692
  function csv(values = []) {
1511
1693
  return (values || []).join(", ");
@@ -1514,6 +1696,7 @@
1514
1696
  const dialog = document.getElementById("adminDialog");
1515
1697
  document.getElementById("adminDialogTitle").textContent = title;
1516
1698
  document.getElementById("adminDialogBody").innerHTML = body;
1699
+ document.getElementById("adminDialogSubmit").textContent = "Save";
1517
1700
  document.getElementById("adminDialogCancel").onclick = () => dialog.close();
1518
1701
  document.getElementById("adminDialogForm").onsubmit = (e) => safe(async () => {
1519
1702
  e.preventDefault();
@@ -1534,10 +1717,15 @@
1534
1717
  }
1535
1718
  function bindAccessTabs() {
1536
1719
  document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
1537
- const search = document.getElementById("discordChannelSearch");
1538
- if (search && !search.dataset.bound) {
1539
- search.dataset.bound = "true";
1540
- search.oninput = () => renderDiscordChannels();
1720
+ const discordSearch = document.getElementById("discordChannelSearch");
1721
+ if (discordSearch && !discordSearch.dataset.bound) {
1722
+ discordSearch.dataset.bound = "true";
1723
+ discordSearch.oninput = () => renderDiscordChannels();
1724
+ }
1725
+ const slackSearch = document.getElementById("slackChannelSearch");
1726
+ if (slackSearch && !slackSearch.dataset.bound) {
1727
+ slackSearch.dataset.bound = "true";
1728
+ slackSearch.oninput = () => renderSlackChannels();
1541
1729
  }
1542
1730
  }
1543
1731
  function switchAccessTab(tab) {
@@ -1575,16 +1763,46 @@
1575
1763
  bindAccessCopyButtons();
1576
1764
  applyPermissions();
1577
1765
  }
1766
+ function slackChannelLabel(channel) {
1767
+ return channel.title ? channel.title + " (" + channel.channelId + ")" : channel.channelId;
1768
+ }
1769
+ function slackScopeLabel(ids = []) {
1770
+ const channels = state.userManagement?.slackChannels || [];
1771
+ return (ids || []).map((id) => slackChannelLabel(channels.find((c) => c.channelId === id) || { channelId: id })).join(", ");
1772
+ }
1773
+ function slackChannelOptions(selected = []) {
1774
+ const selectedSet = new Set(selected || []);
1775
+ const channels = state.userManagement?.slackChannels || [];
1776
+ return '<div class="permission-grid full-span">' + (channels.map((c) => '<label class="checkbox"><input type="checkbox" data-slack-channel-choice="' + attr(c.channelId) + '" ' + (selectedSet.has(c.channelId) ? "checked" : "") + "> " + esc(slackChannelLabel(c) + " / " + (c.teamId || "team default")) + "</label>").join("") || "<small>No Slack channels registered.</small>") + "</div>";
1777
+ }
1778
+ function slackIdentityHtml(user, identity) {
1779
+ const id = String(identity.slackUserId || "");
1780
+ const team = identity.teamId ? " <span>" + esc(identity.teamId) + "</span>" : "";
1781
+ return '<span class="access-id-row">' + accessCopyButton(id, "Slack user ID copied") + team + (identity.username ? " <span>@" + esc(identity.username) + "</span>" : "") + ' <button class="secondary mini-button" data-slack-user="' + attr(user.id) + '" data-slack-unlink="' + attr(identity.id) + '"' + disabledAttr("users.write") + ">Unlink</button></span>";
1782
+ }
1783
+ function renderSlackChannels(channels = state.userManagement?.slackChannels || []) {
1784
+ const target = document.getElementById("slackChannelsList");
1785
+ if (!target) return;
1786
+ const query = (document.getElementById("slackChannelSearch")?.value || "").toLowerCase();
1787
+ const filtered = (channels || []).filter((c) => !query || [c.title, c.channelId, c.teamId, c.type, groupNames(c.allowedGroupIds)].filter(Boolean).join(" ").toLowerCase().includes(query));
1788
+ target.innerHTML = filtered.map((c) => '<div class="item"><strong>' + esc(c.title || String(c.channelId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + '</span></strong><small class="access-id-row">Channel ID: ' + accessCopyButton(c.channelId, "Slack channel ID copied") + '</small><small class="access-id-row">Team ID: ' + accessCopyButton(c.teamId || "", "Slack team ID copied") + "</small><small>" + esc("Type: " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-slack-channel-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-slack-channel-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Slack channels registered.</div>';
1789
+ bindSlackChannelButtons();
1790
+ bindAccessCopyButtons();
1791
+ applyPermissions();
1792
+ }
1578
1793
  function renderUserManagement(d) {
1579
1794
  document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
1580
1795
  const telegram = (u.telegramIdentities || []).map((t) => t.telegramUserId + (t.username ? " @" + t.username : "") + ' <button class="secondary mini-button" data-telegram-user="' + attr(u.id) + '" data-telegram-unlink="' + attr(t.id) + '"' + disabledAttr("users.write") + ">Unlink</button>").join(" ");
1581
1796
  const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
1582
- return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1797
+ const slack = (u.slackIdentities || []).map((identity) => slackIdentityHtml(u, identity)).join(" ");
1798
+ return '<div class="item"><strong>' + esc(u.displayName) + ' <span class="adapter-status ' + (u.active ? "enabled" : "disabled") + '">' + (u.active ? "active" : "disabled") + "</span></strong><small>" + esc(u.email + " / " + u.id) + "</small><small>Groups: " + esc((u.groups || []).map((g) => g.name).join(", ") || "-") + "</small><small>Telegram: " + (telegram || "-") + "</small><small>Discord: " + (discord || "-") + "</small><small>Slack: " + (slack || "-") + "</small><small>Web sessions: " + esc(String((u.webSessions || []).length)) + '</small><div class="row"><button data-user-edit="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-user-toggle="' + attr(u.id) + '"' + disabledAttr("users.write") + ">" + (u.active ? "Disable" : "Enable") + '</button><button class="secondary" data-user-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Telegram code</button><button class="secondary" data-user-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Telegram ID</button><button class="secondary" data-user-discord-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Discord code</button><button class="secondary" data-user-discord-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Discord ID</button><button class="secondary" data-user-slack-code="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Slack code</button><button class="secondary" data-user-slack-link="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Link Slack ID</button><button class="secondary" data-user-password="' + attr(u.id) + '"' + disabledAttr("users.write") + '>Set password</button><button class="danger" data-user-revoke="' + attr(u.id) + '"' + disabledAttr("users.write") + ">Revoke sessions</button></div></div>";
1583
1799
  }).join("") || '<div class="item">No users.</div>';
1584
- document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1800
+ document.getElementById("groupsList").innerHTML = (d.groups || []).map((g) => '<div class="item"><strong>' + esc(g.name) + " " + (g.system ? '<span class="chip">system</span>' : "") + "</strong><small>" + esc(g.description || "") + "</small><small>Permissions: " + esc((g.permissions || []).join(", ") || "-") + "</small><small>Agent scope: " + esc(csv(g.agentIds) || "all") + "</small><small>Workspace scope: " + esc(csv(g.workspaceRoots) || "all") + "</small><small>Telegram chat scope: " + esc(csv(g.telegramChatIds) || "all") + "</small><small>Discord channel scope: " + esc(discordScopeLabel(g.discordChannelIds) || "all") + "</small><small>Slack channel scope: " + esc(slackScopeLabel(g.slackChannelIds) || "all") + '</small><div class="row"><button class="secondary" data-group-edit="' + attr(g.id) + '"' + disabledAttr("users.write") + ">Edit group</button></div></div>").join("") || '<div class="item">No groups.</div>';
1585
1801
  document.getElementById("telegramChatsList").innerHTML = (d.telegramChats || []).map((c) => '<div class="item"><strong>' + esc(c.title || String(c.chatId)) + ' <span class="adapter-status ' + (c.enabled ? "enabled" : "disabled") + '">' + (c.enabled ? "enabled" : "disabled") + "</span></strong><small>" + esc("Chat ID: " + c.chatId + " / " + (c.type || "-")) + "</small><small>Groups: " + esc(groupNames(c.allowedGroupIds) || "all groups") + '</small><div class="row"><button data-chat-edit="' + attr(c.id) + '"' + disabledAttr("users.write") + '>Edit</button><button class="secondary" data-chat-toggle="' + attr(c.id) + '"' + disabledAttr("users.write") + ">" + (c.enabled ? "Disable" : "Enable") + "</button></div></div>").join("") || '<div class="item">No Telegram group chats registered.</div>';
1586
1802
  renderDiscordChannels(d.discordChannels || []);
1803
+ renderSlackChannels(d.slackChannels || []);
1587
1804
  bindUserButtons();
1805
+ bindSlackUserButtons();
1588
1806
  bindAccessCopyButtons();
1589
1807
  applyPermissions();
1590
1808
  }
@@ -1672,6 +1890,33 @@
1672
1890
  loadAccess();
1673
1891
  }));
1674
1892
  }
1893
+ function bindSlackUserButtons() {
1894
+ document.querySelectorAll("[data-user-slack-code]").forEach((b) => b.onclick = () => safe(async () => {
1895
+ const r = await api("/api/users/" + encodeURIComponent(b.dataset.userSlackCode) + "/slack", { method: "POST", body: JSON.stringify({ createCode: true }) });
1896
+ toast("Slack link code: " + r.linkCode.code + " (expires " + fmtDate(r.linkCode.expiresAt) + ")", { duration: 15e3 });
1897
+ }));
1898
+ document.querySelectorAll("[data-user-slack-link]").forEach((b) => b.onclick = () => openSlackLinkDialog(b.dataset.userSlackLink));
1899
+ document.querySelectorAll("[data-slack-unlink]").forEach((b) => b.onclick = () => safe(async () => {
1900
+ if (confirm("Unlink this Slack identity?")) {
1901
+ await api("/api/users/" + encodeURIComponent(b.dataset.slackUser) + "/slack/" + encodeURIComponent(b.dataset.slackUnlink), { method: "DELETE" });
1902
+ toast("Slack unlinked");
1903
+ loadAccess();
1904
+ }
1905
+ }));
1906
+ }
1907
+ function bindSlackChannelButtons() {
1908
+ document.querySelectorAll("[data-slack-channel-edit]").forEach((b) => b.onclick = () => {
1909
+ const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelEdit);
1910
+ if (c) openSlackChannelDialog(c);
1911
+ });
1912
+ document.querySelectorAll("[data-slack-channel-toggle]").forEach((b) => b.onclick = () => safe(async () => {
1913
+ const c = (state.userManagement?.slackChannels || []).find((x) => x.id === b.dataset.slackChannelToggle);
1914
+ if (!c) return;
1915
+ await api("/api/slack-channels/" + encodeURIComponent(c.id), { method: "PATCH", body: JSON.stringify({ enabled: !c.enabled }) });
1916
+ toast("Slack channel updated");
1917
+ loadAccess();
1918
+ }));
1919
+ }
1675
1920
  function openUserDialog(u) {
1676
1921
  adminDialog(u ? "Edit user" : "Create user", '<label>Email<input id="dlgEmail" value="' + attr(u?.email || "") + '"></label><label>Display name<input id="dlgName" value="' + attr(u?.displayName || "") + '"></label>' + (!u ? '<label>Password<input id="dlgPassword" type="password" autocomplete="new-password"></label>' : "") + '<label class="checkbox"><input id="dlgActive" type="checkbox" ' + (!u || u.active ? "checked" : "") + '> Active</label><div class="full-span"><strong>Groups</strong><div class="row">' + groupOptions(u ? userGroups(u) : ["user"]) + "</div></div>", async () => {
1677
1922
  const payload = { email: val("dlgEmail"), displayName: val("dlgName"), active: document.getElementById("dlgActive").checked, groupIds: selectedValues("[data-group-choice]") };
@@ -1699,6 +1944,19 @@
1699
1944
  toast("Discord linked");
1700
1945
  });
1701
1946
  }
1947
+ function assertSlackId(value, label, required = true) {
1948
+ const text = String(value || "").trim();
1949
+ if (!text && !required) return "";
1950
+ if (!text) throw new Error(label + " is required");
1951
+ if (!/^[A-Z0-9]{2,64}$/.test(text)) throw new Error(label + " must look like a Slack ID");
1952
+ return text;
1953
+ }
1954
+ function openSlackLinkDialog(id) {
1955
+ adminDialog("Link Slack ID", '<label>Slack user ID<input id="dlgSlackId" placeholder="U..."></label><label>Team ID<input id="dlgSlackTeamId" placeholder="T..."></label><label>Username<input id="dlgSlackUsername"></label>', async () => {
1956
+ await api("/api/users/" + encodeURIComponent(id) + "/slack", { method: "POST", body: JSON.stringify({ slackUserId: assertSlackId(val("dlgSlackId"), "Slack user ID"), teamId: assertSlackId(val("dlgSlackTeamId"), "Slack team ID", false) || void 0, username: val("dlgSlackUsername") || void 0 }) });
1957
+ toast("Slack linked");
1958
+ });
1959
+ }
1702
1960
  function openTelegramLinkDialog(id) {
1703
1961
  adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
1704
1962
  await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
@@ -1706,8 +1964,8 @@
1706
1964
  });
1707
1965
  }
1708
1966
  function openGroupDialog(g) {
1709
- adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1710
- const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]") };
1967
+ adminDialog(g ? "Edit group" : "Create group", '<label>Name<input id="dlgGroupName" value="' + attr(g?.name || "") + '" ' + (g?.system ? "disabled" : "") + '></label><label>Description<input id="dlgGroupDescription" value="' + attr(g?.description || "") + '"></label><label class="full-span">Agent scope, comma-separated<input id="dlgAgentIds" value="' + attr(csv(g?.agentIds)) + '" placeholder="empty means all"></label><label class="full-span">Workspace scope, comma-separated<input id="dlgWorkspaceRoots" value="' + attr(csv(g?.workspaceRoots)) + '" placeholder="empty means all"></label><label class="full-span">Telegram chat scope, comma-separated<input id="dlgTelegramChatIds" value="' + attr(csv(g?.telegramChatIds)) + '" placeholder="empty means all"></label><div class="full-span"><strong>Discord channel scope</strong>' + discordChannelOptions(g?.discordChannelIds || []) + '<small>Leave empty to allow every registered Discord channel.</small></div><div class="full-span"><strong>Slack channel scope</strong>' + slackChannelOptions(g?.slackChannelIds || []) + '<small>Leave empty to allow every registered Slack channel.</small></div><strong class="full-span">Permissions</strong>' + permissionOptions(g?.permissions || ["inspect", "sessions.read"]), async () => {
1968
+ const payload = { name: val("dlgGroupName"), description: val("dlgGroupDescription"), permissions: selectedValues("[data-permission-choice]"), agentIds: csvToList(val("dlgAgentIds")), workspaceRoots: csvToList(val("dlgWorkspaceRoots")), telegramChatIds: csvToList(val("dlgTelegramChatIds")).map(Number).filter(Number.isInteger), discordChannelIds: selectedValues("[data-discord-channel-choice]"), slackChannelIds: selectedValues("[data-slack-channel-choice]") };
1711
1969
  await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
1712
1970
  toast(g ? "Group updated" : "Group created");
1713
1971
  });
@@ -1720,6 +1978,13 @@
1720
1978
  toast(c ? "Discord channel updated" : "Discord channel added");
1721
1979
  });
1722
1980
  }
1981
+ function openSlackChannelDialog(c) {
1982
+ adminDialog(c ? "Edit Slack channel" : "Add Slack channel", '<label>Team ID<input id="dlgSlackTeamId" value="' + attr(c?.teamId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="T..."></label><label>Channel ID<input id="dlgSlackChannelId" value="' + attr(c?.channelId || "") + '" ' + (c ? "disabled" : "") + ' placeholder="C..."></label><label>Title<input id="dlgSlackChannelTitle" value="' + attr(c?.title || "") + '"></label><label>Type<select id="dlgSlackChannelType"><option value="channel" ' + ((c?.type || "channel") === "channel" ? "selected" : "") + '>channel</option><option value="thread" ' + (c?.type === "thread" ? "selected" : "") + '>thread</option><option value="dm" ' + (c?.type === "dm" ? "selected" : "") + '>dm</option></select></label><label class="checkbox"><input id="dlgSlackChannelEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1983
+ const payload = { teamId: assertSlackId(val("dlgSlackTeamId"), "Team ID", false) || void 0, channelId: assertSlackId(val("dlgSlackChannelId"), "Channel ID"), title: val("dlgSlackChannelTitle") || void 0, type: val("dlgSlackChannelType") || "channel", enabled: document.getElementById("dlgSlackChannelEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
1984
+ await api(c ? "/api/slack-channels/" + encodeURIComponent(c.id) : "/api/slack-channels", { method: c ? "PATCH" : "POST", body: JSON.stringify(payload) });
1985
+ toast(c ? "Slack channel updated" : "Slack channel added");
1986
+ });
1987
+ }
1723
1988
  function openChatDialog(c) {
1724
1989
  adminDialog(c ? "Edit Telegram chat" : "Add Telegram chat", '<label>Chat ID<input id="dlgChatId" type="number" value="' + attr(c?.chatId || "") + '" ' + (c ? "disabled" : "") + '></label><label>Title<input id="dlgChatTitle" value="' + attr(c?.title || "") + '"></label><label>Type<input id="dlgChatType" value="' + attr(c?.type || "supergroup") + '"></label><label class="checkbox"><input id="dlgChatEnabled" type="checkbox" ' + (!c || c.enabled ? "checked" : "") + '> Enabled</label><div class="full-span"><strong>Allowed groups</strong><div class="row">' + groupOptions(c?.allowedGroupIds || []) + "</div><small>Leave empty to allow every group.</small></div>", async () => {
1725
1990
  const payload = { chatId: Number(val("dlgChatId")), title: val("dlgChatTitle") || void 0, type: val("dlgChatType") || void 0, enabled: document.getElementById("dlgChatEnabled").checked, allowedGroupIds: selectedValues("[data-group-choice]") };
@@ -1758,6 +2023,13 @@
1758
2023
  }
1759
2024
  openDiscordChannelDialog(null);
1760
2025
  };
2026
+ document.getElementById("createSlackChannelBtn").onclick = () => {
2027
+ if (!can("users.write")) {
2028
+ toast("Permission required: users.write");
2029
+ return;
2030
+ }
2031
+ openSlackChannelDialog(null);
2032
+ };
1761
2033
  async function loadLocks() {
1762
2034
  if (!can("sessions.read")) {
1763
2035
  document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
@@ -1873,10 +2145,55 @@
1873
2145
  toast("Cleared " + target + " log");
1874
2146
  }
1875
2147
  });
2148
+ async function loadPeers() {
2149
+ if (!can("peers.read")) {
2150
+ document.getElementById("peersList").innerHTML = '<div class="item">Permission required: peers.read</div>';
2151
+ return;
2152
+ }
2153
+ setLoading("peersList", "Loading peers...");
2154
+ const d = await api("/api/peers", { local: true });
2155
+ state.peers = d;
2156
+ const inviteIds = new Set((d.invitations || []).map((i) => i.id));
2157
+ Object.keys(state.peerInviteSecrets || {}).forEach((id) => {
2158
+ if (!inviteIds.has(id)) delete state.peerInviteSecrets[id];
2159
+ });
2160
+ document.getElementById("peerStatus").innerHTML = peerStatusHtml(d);
2161
+ document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
2162
+ document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
2163
+ ensureGlobalPeerSessionsPanel();
2164
+ bindPeerButtons();
2165
+ await loadPeerSelector();
2166
+ applyPermissions();
2167
+ }
2168
+ function openPeerAddDialog() {
2169
+ adminDialog("Add peer", '<label>Peer URL<input id="dlgPeerAddUrl" placeholder="https://host:31979"></label><label>Pairing code<input id="dlgPeerAddCode"></label><label>Name<input id="dlgPeerAddName" placeholder="optional local label"></label><label>Public URL for this node<input id="dlgPeerAddPublicUrl" placeholder="optional"></label>', async () => {
2170
+ const r = await api("/api/peers/pair", { method: "POST", body: JSON.stringify({ url: val("dlgPeerAddUrl"), code: val("dlgPeerAddCode"), name: val("dlgPeerAddName") || void 0, publicUrl: val("dlgPeerAddPublicUrl") || void 0 }), local: true });
2171
+ toast("Added peer " + (r.peer?.name || ""));
2172
+ await loadPeers();
2173
+ });
2174
+ }
2175
+ document.getElementById("loadPeersBtn").onclick = () => loadPeers();
2176
+ document.getElementById("createPeerInviteBtn").onclick = () => {
2177
+ if (!can("peers.write")) {
2178
+ toast("Permission required: peers.write");
2179
+ return;
2180
+ }
2181
+ openPeerInviteDialog();
2182
+ };
2183
+ document.getElementById("addPeerBtn").onclick = () => {
2184
+ if (!can("peers.write")) {
2185
+ toast("Permission required: peers.write");
2186
+ return;
2187
+ }
2188
+ openPeerAddDialog();
2189
+ };
1876
2190
  async function loadAdapterHealth() {
1877
2191
  setLoading("adapterHealth", "Loading adapters...");
1878
- const d = await api("/api/adapters/health");
2192
+ setLoading("adapterConformance", "Loading conformance...");
2193
+ const [d, conformance] = await Promise.all([api("/api/adapters/health"), api("/api/adapters/conformance")]);
2194
+ state.adapterConformance = conformance;
1879
2195
  document.getElementById("adapterHealth").innerHTML = (d.adapters || []).map((a) => '<div class="item"><strong>' + esc(a.label) + ' <span class="adapter-status ' + esc(a.status) + '">' + esc(a.status) + "</span></strong><small>" + esc("CLI: " + (a.cli.label || "-") + " / path " + (a.cli.path || "-") + " / version " + (a.cli.version || "-")) + "</small><small>" + esc("Auth: " + (a.auth.supported ? a.auth.authenticated ? "authenticated" : "not authenticated" : "not managed") + " " + (a.auth.detail || "")) + "</small><small>" + esc("Version: " + a.version.installed + " / latest " + (a.version.latest || "-") + " / " + a.version.status) + "</small>" + featureMatrix(a.capabilities) + '<div class="row"><button data-auth-status="' + attr(a.id) + '">Auth status</button><button data-auth-login="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.login ? "disabled" : "") + disabledAttr("auth.manage") + '>Login</button><button data-auth-logout="' + attr(a.id) + '" class="secondary" ' + (!a.capabilities.logout ? "disabled" : "") + disabledAttr("auth.manage") + ">Logout</button></div></div>").join("") || '<div class="item">No adapters.</div>';
2196
+ renderAdapterConformance(conformance);
1880
2197
  document.querySelectorAll("[data-auth-status]").forEach((b) => b.onclick = () => safe(async () => {
1881
2198
  const r = await api("/api/auth/status", { query: { agent: b.dataset.authStatus } });
1882
2199
  toast(r.agentLabel + ": " + r.detail, { duration: 6e3 });
@@ -1901,6 +2218,22 @@
1901
2218
  }));
1902
2219
  applyPermissions();
1903
2220
  }
2221
+ function renderAdapterConformance(matrix) {
2222
+ const target = document.getElementById("adapterConformance");
2223
+ if (!target) return;
2224
+ const agents = (matrix?.agents || []).map((a) => conformanceCard(a, "agent")).join("");
2225
+ const channels = (matrix?.channels || []).map((c) => conformanceCard(c, "channel")).join("");
2226
+ target.innerHTML = '<div class="conformance-grid"><div><h3>Agent capability contract</h3>' + (agents || '<div class="item">No agent conformance rows.</div>') + "</div><div><h3>Channel command contract</h3>" + (channels || '<div class="item">No channel conformance rows.</div>') + "</div></div>";
2227
+ }
2228
+ function conformanceCard(item, kind) {
2229
+ const missing = (item.unsupported || []).length;
2230
+ const commands = kind === "channel" && item.commands ? "<small>" + esc("Commands: " + item.commands.length + (item.commands.length ? " / " + short(item.commands.join(", "), 180) : "")) + "</small>" : "";
2231
+ const badge = missing === 0 ? "enabled" : item.status === "planned" ? "disabled" : "planned";
2232
+ return '<div class="item"><strong>' + esc(item.label) + ' <span class="adapter-status ' + badge + '">' + esc(missing === 0 ? "complete" : missing + " missing") + "</span></strong><small>" + esc("Status: " + item.status + (item.enabled === void 0 ? "" : " / " + (item.enabled ? "enabled" : "disabled"))) + "</small>" + commands + conformanceFeatureMatrix(item.features || []) + "</div>";
2233
+ }
2234
+ function conformanceFeatureMatrix(features) {
2235
+ return '<div class="feature-matrix">' + (features || []).map((f) => '<span class="feature-chip ' + (f.supported ? "supported" : "unsupported") + '" title="' + attr(f.description || f.key) + '"><span>' + esc(f.label || f.key) + "</span><b>" + (f.supported ? "\u2713" : "-") + "</b></span>").join("") + "</div>";
2236
+ }
1904
2237
  document.getElementById("reloadAdaptersBtn").onclick = () => loadAdapterHealth();
1905
2238
  const versionAgentIds = { codex: "codex", pi: "pi", hermes: "hermes", openclaw: "openclaw", claudeCode: "claude-code" };
1906
2239
  async function loadVersion() {
@@ -2035,20 +2368,37 @@
2035
2368
  document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
2036
2369
  }
2037
2370
  const exportDiagnosticsBundleBtn = document.getElementById("exportDiagnosticsBundleBtn");
2038
- if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => {
2371
+ if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => safe(async () => {
2039
2372
  if (!can("diagnostics.read")) {
2040
2373
  toast("Permission required: diagnostics.read");
2041
2374
  return;
2042
2375
  }
2043
- window.open("/api/diagnostics/bundle", "_blank");
2044
- };
2376
+ if (!state.selectedPeer || state.selectedPeer === "local") {
2377
+ window.open("/api/diagnostics/bundle", "_blank");
2378
+ return;
2379
+ }
2380
+ const bundle = await api("/api/diagnostics/bundle");
2381
+ downloadBase64(bundle.name || "nordrelay-support-bundle.zip", bundle.dataBase64 || "", bundle.mimeType || "application/zip");
2382
+ toast("Remote diagnostics bundle downloaded");
2383
+ });
2384
+ function downloadBase64(name, dataBase64, mimeType) {
2385
+ const bytes = Uint8Array.from(atob(dataBase64), (c) => c.charCodeAt(0));
2386
+ const blob = new Blob([bytes], { type: mimeType });
2387
+ const a = document.createElement("a");
2388
+ a.href = URL.createObjectURL(blob);
2389
+ a.download = name;
2390
+ a.click();
2391
+ URL.revokeObjectURL(a.href);
2392
+ }
2045
2393
  function diagnosticsHtml(d) {
2046
2394
  const h = d.health || {};
2047
2395
  const s = d.snapshot?.session || {};
2048
2396
  const vc = d.versionChecks || {};
2049
2397
  const caps = s.capabilities || {};
2050
2398
  const agentDiag = d.runtime?.agentDiagnostics;
2051
- return '<div class="list">' + card("Runtime", [["Status", h.state?.status], ["PID", h.state?.pid], ["App PID", h.state?.appPid], ["State", h.stateFile], ["Log", h.logFile], ["State backend", d.runtime?.stateBackend], ["Uptime", h.uptimeSeconds + "s"]]) + card("Agent", [["Agent", s.agentLabel], ["Thread", s.threadId], ["Workspace", s.workspace], ["Model", s.model], ["Reasoning", s.reasoningEffort], ["Fast", caps.fastMode ? s.fastMode ? "on" : "off" : "n/a"]]) + card("Agent State", (agentDiag?.lines || []).map((x) => [x.label, x.value])) + card("CLI Versions", Object.values(vc).map((v) => [v.label, (v.status === "current" ? "OK " : "WARN ") + (v.installedLabel || "-") + " latest " + (v.latestVersion || "-")])) + card("External Mirror", d.runtime?.externalMirror ? Object.entries(d.runtime.externalMirror) : [["Status", "idle"]]) + "</div>";
2399
+ const slack = d.runtime?.slackDiagnostics;
2400
+ const slackRows = slack ? [["Enabled", slack.enabled ? "yes" : "no"], ["Mode", slack.mode], ["Registered channels", slack.registeredChannels], ...(slack.checks || []).map((x) => [x.status.toUpperCase() + " " + x.label, x.detail]), ...(slack.channelChecks || []).map((x) => [x.status.toUpperCase() + " channel " + x.channelId, x.detail])] : [["Status", "not collected"]];
2401
+ return '<div class="list">' + card("Runtime", [["Status", h.state?.status], ["PID", h.state?.pid], ["App PID", h.state?.appPid], ["State", h.stateFile], ["Log", h.logFile], ["State backend", d.runtime?.stateBackend], ["Uptime", h.uptimeSeconds + "s"]]) + card("Agent", [["Agent", s.agentLabel], ["Thread", s.threadId], ["Workspace", s.workspace], ["Model", s.model], ["Reasoning", s.reasoningEffort], ["Fast", caps.fastMode ? s.fastMode ? "on" : "off" : "n/a"]]) + card("Agent State", (agentDiag?.lines || []).map((x) => [x.label, x.value])) + card("CLI Versions", Object.values(vc).map((v) => [v.label, (v.status === "current" ? "OK " : "WARN ") + (v.installedLabel || "-") + " latest " + (v.latestVersion || "-")])) + card("External Mirror", d.runtime?.externalMirror ? Object.entries(d.runtime.externalMirror) : [["Status", "idle"]]) + card("Slack Readiness", slackRows) + "</div>";
2052
2402
  }
2053
2403
  function card(title, rows) {
2054
2404
  return '<div class="item"><strong>' + esc(title) + "</strong>" + rows.map((r) => "<small>" + esc(r[0]) + ": " + esc(r[1] ?? "-") + "</small>").join("") + "</div>";
@@ -2058,4 +2408,467 @@
2058
2408
  Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
2059
2409
  }
2060
2410
  loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
2411
+ function ensureGlobalPeerSessionsPanel() {
2412
+ if (document.getElementById("globalPeerSessionsPanel")) return;
2413
+ const anchor = document.getElementById("peersList");
2414
+ if (!anchor) return;
2415
+ anchor.insertAdjacentHTML("afterend", '<h2>Global sessions</h2><div id="globalPeerSessionsPanel" class="list"><div class="item"><div class="row"><input id="globalPeerSessionSearch" placeholder="Search sessions across peers"><button id="loadGlobalPeerSessionsBtn">Load global sessions</button></div><div id="globalPeerSessionsList"></div></div></div>');
2416
+ document.getElementById("loadGlobalPeerSessionsBtn").onclick = () => safe(loadGlobalPeerSessions);
2417
+ }
2418
+ async function loadGlobalPeerSessions() {
2419
+ const target = document.getElementById("globalPeerSessionsList");
2420
+ target.innerHTML = loadingHtml("Loading global sessions...");
2421
+ const q = document.getElementById("globalPeerSessionSearch").value || "";
2422
+ const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
2423
+ target.innerHTML = (d.targets || []).map((t) => '<div class="item"><strong>' + esc(t.peerName + " (" + t.peerId + ")") + ' <span class="adapter-status ' + (t.ok ? "enabled" : "disabled") + '">' + (t.ok ? "ok" : "error") + "</span></strong>" + (t.ok ? (t.data?.sessions || []).slice(0, 8).map((s) => "<small>" + esc(short(s.id, 48) + " | " + short(s.cwd || "", 80) + " | " + fmtDate(s.updatedAt)) + "</small>").join("") || "<small>No sessions.</small>" : '<small class="error">' + esc(t.error || "failed") + "</small>") + "</div>").join("") || '<div class="item">No peer sessions found.</div>';
2424
+ }
2425
+ function peerStatusHtml(d) {
2426
+ const r = d.readiness || {};
2427
+ const rows = [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Bind", [r.bindHost || "-", r.port || ""].filter(Boolean).join(":")], ["Local port", r.localListening ? "listening" : "not listening"], ["TLS", r.tlsEnabled ? "enabled" : "disabled"], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]];
2428
+ const command = r.manualCheckCommand || "nordrelay peer check " + (d.listenUrl || "");
2429
+ return '<div class="item"><strong>Local peer identity <span class="adapter-status ' + (r.enabled && r.localListening ? "enabled" : r.enabled ? "planned" : "disabled") + '">' + esc(r.enabled ? r.localListening ? "ready" : "not listening" : "disabled") + "</span></strong>" + rows.map((row) => "<small>" + esc(row[0]) + ": " + esc(row[1] ?? "-") + "</small>").join("") + peerWarningsHtml(r.warnings || [], "Peer readiness") + '<div class="peer-invite-details"><small>Manual reachability check</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(command) + '" data-peer-invite-copy-label="Peer check command copied">' + esc(command) + '</button><small>Run this on another machine to verify LAN, port-forward, or firewall reachability.</small></div><div class="row"><button id="checkPeerReachabilityBtn" class="secondary"' + disabledAttr("peers.connect") + '>Check local endpoint</button></div><div id="peerProbeResult">' + peerProbeResultHtml(state.peerProbeResult) + "</div></div>";
2430
+ }
2431
+ function peerWarningsHtml(warnings, title) {
2432
+ return (warnings || []).length ? '<div class="peer-warning full-span"><strong>' + esc(title || "Warning") + "</strong>" + warnings.map((w) => "<small>" + esc(w) + "</small>").join("") + "</div>" : "";
2433
+ }
2434
+ function peerProbeResultHtml(result) {
2435
+ if (!result) return "";
2436
+ const probe = result.probe || {};
2437
+ const label = result.type === "remote" ? "Remote probe from " + (result.peerName || result.peerId || "peer") : "Local endpoint check";
2438
+ return '<div class="peer-probe-result ' + (probe.ok ? "ok" : "warn") + '"><strong>' + esc(label) + ' <span class="adapter-status ' + (probe.ok ? "enabled" : "planned") + '">' + esc(probe.status || "unknown") + "</span></strong><small>" + esc("URL: " + (probe.url || result.readiness?.listenUrl || "-")) + "</small>" + (probe.latencyMs !== void 0 ? "<small>" + esc("Latency: " + probe.latencyMs + "ms") + "</small>" : "") + (probe.statusCode !== void 0 ? "<small>" + esc("HTTP: " + probe.statusCode) + "</small>" : "") + (probe.tlsFingerprint ? "<small>" + esc("TLS fingerprint: " + probe.tlsFingerprint) + "</small>" : "") + "<small>" + esc(probe.detail || "") + "</small></div>";
2439
+ }
2440
+ function peerInviteDetails(i) {
2441
+ const details = state.peerInviteSecrets?.[i.id];
2442
+ if (!details) return "";
2443
+ return '<div class="peer-invite-details"><small>Pairing code</small><button type="button" class="copy-id peer-invite-copy" data-peer-invite-copy="' + attr(details.code || "") + '" data-peer-invite-copy-label="Pairing code copied">' + esc(details.code || "") + '</button><small>Peer add command</small><button type="button" class="copy-id peer-invite-command" data-peer-invite-copy="' + attr(details.command || "") + '" data-peer-invite-copy-label="Peer add command copied">' + esc(details.command || "") + "</button></div>";
2444
+ }
2445
+ function peerInviteCard(i) {
2446
+ const open = new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt;
2447
+ const readiness = state.peers?.readiness;
2448
+ return '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(open ? "open" : "closed") + "</span></strong><small>" + esc("Expires: " + fmtDate(i.expiresAt)) + "</small><small>" + esc("Scopes: " + (i.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((i.allowedAgents || []).join(", ") || "all")) + "</small>" + (i.usedAt ? "<small>" + esc("Used: " + fmtDate(i.usedAt) + " by " + (i.usedByNodeId || "-")) + "</small>" : "") + (open ? peerWarningsHtml(readiness?.warnings || [], "Pairing warning") + peerInviteDetails(i) + '<div class="row"><button class="danger" data-peer-invite-delete="' + attr(i.id) + '"' + disabledAttr("peers.write") + ">Delete invite</button></div>" : "") + "</div>";
2449
+ }
2450
+ function peerCard(p) {
2451
+ const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2452
+ const health = p.remoteStatus || p.lastSeenAt ? "Health: " + (p.remoteStatus || "seen") + (p.lastLatencyMs !== void 0 ? " / " + p.lastLatencyMs + "ms" : "") + (p.remoteVersion ? " / v" + p.remoteVersion : "") : "Health: unchecked";
2453
+ const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2454
+ return '<div class="item"><strong>' + esc(p.name) + ' <span class="adapter-status ' + (p.enabled ? "enabled" : "disabled") + '">' + (p.enabled ? "enabled" : "disabled") + "</span>" + selected + "</strong><small>" + esc("URL: " + (p.url || "-")) + "</small><small>" + esc("Node: " + p.nodeId + " / " + p.fingerprint) + "</small>" + (p.tlsFingerprint ? "<small>" + esc("TLS: " + p.tlsFingerprint) + "</small>" : "") + "<small>" + esc("Direction: " + p.direction + " / scopes " + (p.scopes || []).join(", ")) + "</small><small>" + esc("Agents: " + ((p.allowedAgents || []).join(", ") || "all")) + "</small><small>" + esc("Workspaces: " + ((p.allowedWorkspaceRoots || []).join(", ") || "all")) + "</small>" + (aliases ? "<small>" + esc("Aliases: " + aliases) + "</small>" : "") + "<small>" + esc(health) + "</small>" + (p.lastCheckedAt ? "<small>" + esc("Checked: " + fmtDate(p.lastCheckedAt)) + "</small>" : "") + (p.lastSeenAt ? "<small>" + esc("Last seen: " + fmtDate(p.lastSeenAt)) + "</small>" : "") + (p.lastError ? '<small class="error">' + esc("Last error: " + p.lastError) + "</small>" : "") + '<div class="row"><button data-peer-select="' + attr(p.id) + '">Use target</button><button class="secondary" data-peer-test="' + attr(p.id) + '">Test</button><button class="secondary" data-peer-probe="' + attr(p.id) + '"' + disabledAttr("peers.connect") + '>Probe this node</button><button class="secondary" data-peer-edit="' + attr(p.id) + '"' + disabledAttr("peers.write") + '>Edit</button><button class="secondary" data-peer-toggle="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">" + (p.enabled ? "Disable" : "Enable") + '</button><button class="danger" data-peer-revoke="' + attr(p.id) + '"' + disabledAttr("peers.write") + ">Revoke</button></div></div>";
2455
+ }
2456
+ function openPeerDialog(p) {
2457
+ const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2458
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2459
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2460
+ toast("Peer updated");
2461
+ await loadPeers();
2462
+ });
2463
+ }
2464
+ function openPeerInviteDialog() {
2465
+ const warnings = state.peers?.readiness?.warnings || [];
2466
+ const warningHtml = peerWarningsHtml(warnings, "Pairing warning") + (warnings.length ? '<small class="full-span">The invite can still be created, but pairing may fail until the peer endpoint is reachable.</small>' : "");
2467
+ adminDialog("Create peer invite", warningHtml + '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1" max="1440"></label><label class="full-span">Scopes<input id="dlgPeerInviteScopes" value="inspect, sessions.read, sessions.write, prompt.send, prompt.abort, queue.read, queue.write, files.read, files.write, diagnostics.read, logs.read"></label><label class="full-span">Allowed agents<input id="dlgPeerInviteAgents" value="codex, pi, hermes, openclaw, claude-code"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerInviteWorkspaces" placeholder="empty means all"></label><label class="full-span">Workspace aliases<input id="dlgPeerInviteAliases" placeholder="project=/srv/project, demo=/home/me/demo"></label>', async () => {
2468
+ const r = await api("/api/peers/invite", { method: "POST", body: JSON.stringify({ name: val("dlgPeerInviteName"), expiresMinutes: Number(val("dlgPeerInviteExpires") || 10), scopes: csvToList(val("dlgPeerInviteScopes")), allowedAgents: csvToList(val("dlgPeerInviteAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerInviteWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerInviteAliases")) }), local: true });
2469
+ if (r.invitation?.id) state.peerInviteSecrets[r.invitation.id] = { code: r.code || "", command: r.command || "" };
2470
+ toast("Peer invite created. Pairing details are shown under Open invitations.", { duration: 8e3 });
2471
+ await loadPeers();
2472
+ });
2473
+ document.getElementById("adminDialogSubmit").textContent = warnings.length ? "Create invite anyway" : "Create invite";
2474
+ }
2475
+ function aliasMap(text) {
2476
+ return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
2477
+ }
2478
+ function bindPeerButtons() {
2479
+ document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
2480
+ state.selectedPeer = b.dataset.peerSelect || "local";
2481
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
2482
+ connectEvents();
2483
+ await loadBootstrap();
2484
+ toast("Peer target selected");
2485
+ }));
2486
+ document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
2487
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/health", { local: true });
2488
+ toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
2489
+ loadPeers();
2490
+ }));
2491
+ const localProbe = document.getElementById("checkPeerReachabilityBtn");
2492
+ if (localProbe) localProbe.onclick = () => safe(async () => {
2493
+ if (!can("peers.connect")) {
2494
+ toast("Permission required: peers.connect");
2495
+ return;
2496
+ }
2497
+ state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({}), local: true });
2498
+ document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
2499
+ toast(state.peerProbeResult.probe?.ok ? "Peer endpoint reachable" : "Peer endpoint not reachable", { duration: 7e3 });
2500
+ });
2501
+ document.querySelectorAll("[data-peer-probe]").forEach((b) => b.onclick = () => safe(async () => {
2502
+ if (!can("peers.connect")) {
2503
+ toast("Permission required: peers.connect");
2504
+ return;
2505
+ }
2506
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerProbe);
2507
+ state.peerProbeResult = await api("/api/peers/probe", { method: "POST", body: JSON.stringify({ peerId: b.dataset.peerProbe }), local: true });
2508
+ state.peerProbeResult.peerName = p?.name;
2509
+ document.getElementById("peerProbeResult").innerHTML = peerProbeResultHtml(state.peerProbeResult);
2510
+ toast(state.peerProbeResult.probe?.ok ? "Remote peer can reach this node" : "Remote peer cannot reach this node", { duration: 8e3 });
2511
+ }));
2512
+ document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2513
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2514
+ if (p) openPeerDialog(p);
2515
+ });
2516
+ document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
2517
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
2518
+ if (!p) return;
2519
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
2520
+ toast("Peer updated");
2521
+ loadPeers();
2522
+ }));
2523
+ document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
2524
+ if (confirm("Revoke this peer?")) {
2525
+ await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
2526
+ if (state.selectedPeer === b.dataset.peerRevoke) {
2527
+ state.selectedPeer = "local";
2528
+ localStorage.setItem("nordrelayPeerTarget", "local");
2529
+ }
2530
+ toast("Peer revoked");
2531
+ loadPeers();
2532
+ }
2533
+ }));
2534
+ document.querySelectorAll("[data-peer-invite-copy]").forEach((b) => b.onclick = () => copyText(b.dataset.peerInviteCopy || "", b.dataset.peerInviteCopyLabel || "Copied"));
2535
+ document.querySelectorAll("[data-peer-invite-delete]").forEach((b) => b.onclick = () => safe(async () => {
2536
+ if (confirm("Delete this peer invitation?")) {
2537
+ await api("/api/peers/invitations/" + encodeURIComponent(b.dataset.peerInviteDelete), { method: "DELETE", local: true });
2538
+ delete state.peerInviteSecrets[b.dataset.peerInviteDelete];
2539
+ toast("Peer invitation deleted");
2540
+ loadPeers();
2541
+ }
2542
+ }));
2543
+ }
2544
+ const SETUP_WIZARDS = {
2545
+ telegram: {
2546
+ id: "telegram",
2547
+ label: "Telegram",
2548
+ description: "Create a Telegram bot, choose polling or webhook delivery, and enable the Telegram adapter.",
2549
+ docs: [
2550
+ ["BotFather", "https://t.me/BotFather"],
2551
+ ["Telegram Bot API", "https://core.telegram.org/bots/api"],
2552
+ ["Webhook setup", "https://core.telegram.org/bots/api#setwebhook"]
2553
+ ],
2554
+ defaults: { TELEGRAM_ENABLED: "true", TELEGRAM_TRANSPORT: "polling", TELEGRAM_WEBHOOK_HOST: "127.0.0.1", TELEGRAM_WEBHOOK_PORT: "8080", TELEGRAM_WEBHOOK_PATH: "/telegram/webhook" },
2555
+ required: ["TELEGRAM_BOT_TOKEN"],
2556
+ steps: [
2557
+ { title: "Create the bot", body: "Open BotFather, create a bot with /newbot, then paste only the token value into NordRelay. Do not paste the full BotFather message.", settings: ["TELEGRAM_BOT_TOKEN"], links: [["Open BotFather", "https://t.me/BotFather"]] },
2558
+ { title: "Choose delivery mode", body: "Polling is the simplest setup and works without a public URL. Use webhook only when this NordRelay instance is reachable through HTTPS from Telegram.", settings: ["TELEGRAM_TRANSPORT", "TELEGRAM_WEBHOOK_URL", "TELEGRAM_WEBHOOK_HOST", "TELEGRAM_WEBHOOK_PORT", "TELEGRAM_WEBHOOK_PATH", "TELEGRAM_WEBHOOK_SECRET"], links: [["Webhook documentation", "https://core.telegram.org/bots/api#setwebhook"]] },
2559
+ { title: "Enable and secure access", body: "Enable Telegram only after the token is configured. Access is still controlled by NordRelay users, groups, and linked Telegram identities or registered chats.", settings: ["TELEGRAM_ENABLED"], links: [["Bot API overview", "https://core.telegram.org/bots/api"]] }
2560
+ ]
2561
+ },
2562
+ discord: {
2563
+ id: "discord",
2564
+ label: "Discord",
2565
+ description: "Create a Discord application, configure slash commands or message commands, invite the bot, and enable Discord.",
2566
+ docs: [
2567
+ ["Discord Developer Portal", "https://discord.com/developers/applications"],
2568
+ ["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"],
2569
+ ["OAuth2 bot invite", "https://discord.com/developers/docs/topics/oauth2"]
2570
+ ],
2571
+ defaults: { DISCORD_ENABLED: "true", DISCORD_MESSAGE_CONTENT_ENABLED: "true", DISCORD_COMMAND_MODE: "both", DISCORD_AUTO_REGISTER_COMMANDS: "true" },
2572
+ required: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"],
2573
+ steps: [
2574
+ { title: "Create application and bot", body: "Create an application in the Discord Developer Portal, add a bot, copy the bot token, and copy the Application ID from General Information.", settings: ["DISCORD_BOT_TOKEN", "DISCORD_CLIENT_ID"], links: [["Developer Portal", "https://discord.com/developers/applications"]] },
2575
+ { title: "Commands, intents, and invite", body: "For regular text commands, enable Message Content Intent under Bot > Privileged Gateway Intents. Invite the bot with bot and applications.commands scopes.", settings: ["DISCORD_MESSAGE_CONTENT_ENABLED", "DISCORD_COMMAND_MODE", "DISCORD_AUTO_REGISTER_COMMANDS"], links: [["Privileged intents", "https://discord.com/developers/docs/topics/gateway#privileged-intents"], ["OAuth2 scopes", "https://discord.com/developers/docs/topics/oauth2#shared-resources-oauth2-scopes"]] },
2576
+ { title: "Limit server and channel scope", body: "Enable Discord Developer Mode, then right-click servers and channels to copy IDs. These allow-lists run before the NordRelay user/group access checks.", settings: ["DISCORD_GUILD_IDS", "DISCORD_ALLOWED_GUILD_IDS", "DISCORD_ALLOWED_CHANNEL_IDS"], links: [["Where to find IDs", "https://support.discord.com/hc/en-us/articles/206346498-Where-can-I-find-my-User-Server-Message-ID"]] },
2577
+ { title: "Enable Discord", body: "Enable the adapter after the bot token, client ID, and invite are ready. Users still need NordRelay permissions and linked Discord identities or registered channels.", settings: ["DISCORD_ENABLED"], links: [["Developer Portal", "https://discord.com/developers/applications"]] }
2578
+ ]
2579
+ },
2580
+ slack: {
2581
+ id: "slack",
2582
+ label: "Slack",
2583
+ description: "Create a Slack app, configure Socket Mode or HTTP Events, add slash commands, and enable Slack.",
2584
+ docs: [
2585
+ ["Slack API Apps", "https://api.slack.com/apps"],
2586
+ ["Socket Mode", "https://api.slack.com/apis/connections/socket"],
2587
+ ["Slash commands", "https://api.slack.com/interactivity/slash-commands"]
2588
+ ],
2589
+ defaults: { SLACK_ENABLED: "true", SLACK_SOCKET_MODE: "true", SLACK_PORT: "3000", SLACK_COMMAND: "/nordrelay" },
2590
+ required: ["SLACK_BOT_TOKEN"],
2591
+ steps: [
2592
+ { title: "Create and install the Slack app", body: "Create a Slack app, add bot scopes, install it into the workspace, and copy the OAuth bot token that starts with xoxb-.", settings: ["SLACK_BOT_TOKEN"], links: [["Slack API Apps", "https://api.slack.com/apps"], ["OAuth scopes", "https://api.slack.com/authentication/oauth-v2"]] },
2593
+ { title: "Choose transport mode", body: "Socket Mode is recommended because it avoids exposing a public HTTP endpoint. For Socket Mode, create an app-level token with connections:write. For HTTP Events, use the Signing Secret and configure an externally reachable URL.", settings: ["SLACK_SOCKET_MODE", "SLACK_APP_TOKEN", "SLACK_SIGNING_SECRET", "SLACK_PORT"], links: [["Socket Mode", "https://api.slack.com/apis/connections/socket"], ["Events API", "https://api.slack.com/apis/events-api"]] },
2594
+ { title: "Commands and scope", body: "Configure the slash command in Slack and optionally restrict workspaces or channels before NordRelay user/group permissions are applied.", settings: ["SLACK_COMMAND", "SLACK_ALLOWED_TEAM_IDS", "SLACK_ALLOWED_CHANNEL_IDS", "SLACK_MESSAGE_CONTENT_ENABLED"], links: [["Slash commands", "https://api.slack.com/interactivity/slash-commands"], ["Finding Slack IDs", "https://slack.com/help/articles/221769328-Locate-your-Slack-URL-or-ID"]] },
2595
+ { title: "Enable Slack", body: "Enable the adapter after tokens and command settings are ready. Users still need NordRelay permissions and linked Slack identities or registered channels.", settings: ["SLACK_ENABLED"], links: [["Slack API Apps", "https://api.slack.com/apps"]] }
2596
+ ]
2597
+ }
2598
+ };
2599
+ function settingByKey(key) {
2600
+ return (state.settings || []).find((s) => s.key === key) || null;
2601
+ }
2602
+ function wizardCurrentValue(key) {
2603
+ const values = state.settingsWizard?.values || {};
2604
+ if (Object.prototype.hasOwnProperty.call(values, key)) return values[key];
2605
+ const s = settingByKey(key);
2606
+ return s ? s.configured ? s.value || "" : s.effectiveValue || "" : "";
2607
+ }
2608
+ function wizardOriginalValue(key) {
2609
+ const s = settingByKey(key);
2610
+ return s && s.configured ? s.value : "";
2611
+ }
2612
+ function isMaskedSettingValue(value) {
2613
+ return /^\*+$/.test(String(value || "")) || String(value || "").includes("...");
2614
+ }
2615
+ function wizardKeys(wizard) {
2616
+ return Array.from(new Set(wizard.steps.flatMap((step) => step.settings)));
2617
+ }
2618
+ function safeDocLink(label, url) {
2619
+ return '<a href="' + attr(url) + '" target="_blank" rel="noopener noreferrer">' + esc(label) + "</a>";
2620
+ }
2621
+ function wizardLinkList(links = []) {
2622
+ return links.length ? '<div class="wizard-links">' + links.map(([label, url]) => safeDocLink(label, url)).join("") + "</div>" : "";
2623
+ }
2624
+ function startSettingsWizard(channel) {
2625
+ const wizard = SETUP_WIZARDS[channel];
2626
+ if (!wizard) return;
2627
+ const values = {};
2628
+ for (const [key, value] of Object.entries(wizard.defaults || {})) {
2629
+ const setting = settingByKey(key);
2630
+ if (!setting?.configured || key.endsWith("_ENABLED")) values[key] = value;
2631
+ }
2632
+ state.settingsWizard = { channel, step: 0, values, errors: [], testResult: null };
2633
+ renderSettingsWizardStep();
2634
+ }
2635
+ function openSettingsWizardHome() {
2636
+ if (!can("settings.write")) {
2637
+ toast("Permission required: settings.write");
2638
+ return;
2639
+ }
2640
+ state.settingsWizard = { home: true };
2641
+ renderSettingsWizardHome();
2642
+ }
2643
+ function closeSettingsWizard() {
2644
+ state.settingsWizard = null;
2645
+ document.getElementById("settingsTabs").style.display = "";
2646
+ renderSettings();
2647
+ }
2648
+ function wizardRequiredValuePresent(key) {
2649
+ const value = wizardCurrentValue(key);
2650
+ if (!value) return false;
2651
+ const setting = settingByKey(key);
2652
+ if (isMaskedSettingValue(value)) return setting?.kind === "secret" && (setting.configured || setting.masked);
2653
+ return true;
2654
+ }
2655
+ function wizardMissingRequired(wizard) {
2656
+ return (wizard.required || []).filter((key) => !wizardRequiredValuePresent(key));
2657
+ }
2658
+ function renderSettingsWizardHome() {
2659
+ document.getElementById("settingsTabs").style.display = "none";
2660
+ const cards = Object.values(SETUP_WIZARDS).map((wizard) => {
2661
+ const missing = wizardMissingRequired(wizard);
2662
+ const docs = wizardLinkList(wizard.docs);
2663
+ return '<div class="wizard-card"><strong>' + esc(wizard.label) + ' <span class="adapter-status ' + (missing.length ? "planned" : "enabled") + '">' + esc(missing.length ? missing.length + " missing" : "ready") + "</span></strong><p>" + esc(wizard.description) + "</p><small>" + esc(missing.length ? "Missing: " + missing.join(", ") : "Required values are present or configured.") + "</small>" + docs + '<button type="button" data-start-wizard="' + attr(wizard.id) + '">Start wizard</button></div>';
2664
+ }).join("");
2665
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>Setup wizard</h2><p>Choose a chat adapter and follow the guided setup. Bot/app credentials only enable transport; NordRelay user and group permissions still control access.</p></div><button type="button" class="secondary" id="backToSettingsBtn">Back to settings</button></div><div class="wizard-choice-grid">' + cards + "</div></div>";
2666
+ document.getElementById("backToSettingsBtn").onclick = closeSettingsWizard;
2667
+ document.querySelectorAll("[data-start-wizard]").forEach((b) => b.onclick = () => startSettingsWizard(b.dataset.startWizard));
2668
+ applyPermissions();
2669
+ }
2670
+ function wizardSettingInput(s) {
2671
+ const value = wizardCurrentValue(s.key);
2672
+ const attrs = ' data-wizard-setting="' + attr(s.key) + '" data-original-value="' + attr(wizardOriginalValue(s.key)) + '"';
2673
+ if (s.options) {
2674
+ return "<select" + attrs + '><option value="">Unset / inherit active default</option>' + s.options.map((o) => '<option value="' + attr(o) + '" ' + (value === o ? "selected" : "") + ">" + esc(o) + "</option>").join("") + "</select>";
2675
+ }
2676
+ if (s.kind === "boolean") {
2677
+ return "<select" + attrs + '><option value="">Unset / inherit active default</option><option value="true" ' + (value === "true" ? "selected" : "") + '>true</option><option value="false" ' + (value === "false" ? "selected" : "") + ">false</option></select>";
2678
+ }
2679
+ if (s.kind === "json") return '<textarea rows="4"' + attrs + ">" + esc(value) + "</textarea>";
2680
+ return "<input" + attrs + ' value="' + attr(value) + '" ' + (s.kind === "secret" ? 'type="password" autocomplete="new-password"' : "") + ">";
2681
+ }
2682
+ function renderWizardSetting(key) {
2683
+ const s = settingByKey(key);
2684
+ if (!s) return '<div class="setting"><strong>' + esc(key) + "</strong><small>Unknown setting.</small></div>";
2685
+ return '<div class="setting" data-wizard-setting-box="' + attr(key) + '" data-restart-required="' + (s.restartRequired ? "true" : "false") + '">' + settingLabel(s) + wizardSettingInput(s) + "<small>" + esc(s.key + " - " + s.description) + (s.effectiveValue ? " Active: " + esc(s.effectiveValue) + "." : "") + (s.restartRequired ? " Restart required." : "") + '</small><div class="setting-error"></div></div>';
2686
+ }
2687
+ function renderWizardProgress(wizard) {
2688
+ return '<div class="wizard-progress">' + wizard.steps.map((step, index) => '<button type="button" data-wizard-step="' + index + '" class="' + (index === state.settingsWizard.step ? "active" : "") + '">' + esc(String(index + 1) + ". " + step.title) + "</button>").join("") + "</div>";
2689
+ }
2690
+ function renderSettingsWizardStep() {
2691
+ const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
2692
+ if (!wizard) {
2693
+ renderSettingsWizardHome();
2694
+ return;
2695
+ }
2696
+ const step = wizard.steps[state.settingsWizard.step] || wizard.steps[0];
2697
+ document.getElementById("settingsTabs").style.display = "none";
2698
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + " setup wizard</h2><p>" + esc(wizard.description) + '</p></div><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div>' + renderWizardProgress(wizard) + '<div class="wizard-step"><h3>' + esc(step.title) + "</h3><p>" + esc(step.body) + "</p>" + wizardLinkList(step.links) + '<div id="wizardRestartBanner"></div><div class="settings-grid">' + step.settings.map(renderWizardSetting).join("") + '</div><div id="wizardErrors" class="wizard-errors"></div><div id="wizardTestResult" class="wizard-test-result"></div><div class="wizard-actions"><button type="button" id="wizardPrevBtn" class="secondary">Back</button><button type="button" id="wizardNextBtn">' + esc(state.settingsWizard.step === wizard.steps.length - 1 ? "Review" : "Next") + '</button><button type="button" id="wizardTestBtn" class="secondary">Test setup</button><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button></div><div class="setting-help">After transport is configured, link users and register allowed chats or channels in the Users page.</div></div></div>';
2699
+ bindWizardUx();
2700
+ renderWizardValidation();
2701
+ }
2702
+ function bindWizardUx() {
2703
+ document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
2704
+ document.querySelectorAll("[data-wizard-step]").forEach((b) => b.onclick = () => {
2705
+ collectWizardValues();
2706
+ state.settingsWizard.step = Number(b.dataset.wizardStep) || 0;
2707
+ renderSettingsWizardStep();
2708
+ });
2709
+ document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
2710
+ el.oninput = () => {
2711
+ state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
2712
+ renderWizardValidation();
2713
+ };
2714
+ el.onchange = el.oninput;
2715
+ });
2716
+ document.getElementById("wizardPrevBtn").onclick = () => {
2717
+ collectWizardValues();
2718
+ if (state.settingsWizard.step > 0) {
2719
+ state.settingsWizard.step--;
2720
+ renderSettingsWizardStep();
2721
+ } else renderSettingsWizardHome();
2722
+ };
2723
+ document.getElementById("wizardNextBtn").onclick = () => {
2724
+ collectWizardValues();
2725
+ if (state.settingsWizard.step < SETUP_WIZARDS[state.settingsWizard.channel].steps.length - 1) {
2726
+ state.settingsWizard.step++;
2727
+ renderSettingsWizardStep();
2728
+ } else renderWizardReview();
2729
+ };
2730
+ document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
2731
+ document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
2732
+ document.getElementById("wizardTestBtn").onclick = () => safe(testWizardSetup);
2733
+ applyPermissions();
2734
+ }
2735
+ function collectWizardValues() {
2736
+ document.querySelectorAll("[data-wizard-setting]").forEach((el) => {
2737
+ state.settingsWizard.values[el.dataset.wizardSetting] = el.value;
2738
+ });
2739
+ }
2740
+ function collectWizardPatch(wizard) {
2741
+ collectWizardValues();
2742
+ const patch = {};
2743
+ for (const key of wizardKeys(wizard)) {
2744
+ if (!Object.prototype.hasOwnProperty.call(state.settingsWizard.values, key)) continue;
2745
+ const value = state.settingsWizard.values[key];
2746
+ if (value !== wizardOriginalValue(key)) patch[key] = value;
2747
+ }
2748
+ return patch;
2749
+ }
2750
+ function collectWizardSettings(wizard) {
2751
+ collectWizardValues();
2752
+ const settings = {};
2753
+ for (const key of wizardKeys(wizard)) settings[key] = wizardCurrentValue(key);
2754
+ return settings;
2755
+ }
2756
+ function parseList(text) {
2757
+ return String(text || "").split(",").map((item) => item.trim()).filter(Boolean);
2758
+ }
2759
+ function wizardValueBool(value) {
2760
+ return ["true", "1", "yes", "on"].includes(String(value || "").toLowerCase());
2761
+ }
2762
+ function validateSnowflakeList(value, label, required = false) {
2763
+ const items = parseList(value);
2764
+ if (required && items.length === 0) return [label + " is required."];
2765
+ return items.filter((item) => !/^[0-9]{5,32}$/.test(item)).map((item) => label + " contains invalid Discord ID: " + item);
2766
+ }
2767
+ function validateSlackIdList(value, label) {
2768
+ return parseList(value).filter((item) => !/^[A-Z0-9]{2,64}$/.test(item)).map((item) => label + " contains invalid Slack ID: " + item);
2769
+ }
2770
+ function validateWizard(wizard) {
2771
+ const v = Object.fromEntries(wizardKeys(wizard).map((key) => [key, wizardCurrentValue(key)]));
2772
+ const errors = [];
2773
+ const warnings = [];
2774
+ if (wizard.id === "telegram") {
2775
+ if (!v.TELEGRAM_BOT_TOKEN) errors.push("Telegram bot token is required.");
2776
+ else if (!isMaskedSettingValue(v.TELEGRAM_BOT_TOKEN) && !/^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/.test(v.TELEGRAM_BOT_TOKEN)) errors.push("Telegram bot token does not look like a BotFather token.");
2777
+ if (!["polling", "webhook"].includes(v.TELEGRAM_TRANSPORT || "polling")) errors.push("Telegram transport must be polling or webhook.");
2778
+ if ((v.TELEGRAM_TRANSPORT || "polling") === "webhook") {
2779
+ if (!String(v.TELEGRAM_WEBHOOK_URL || "").startsWith("https://")) errors.push("Webhook public URL must be HTTPS.");
2780
+ if (!v.TELEGRAM_WEBHOOK_HOST) errors.push("Webhook bind host is required.");
2781
+ if (!Number.isFinite(Number(v.TELEGRAM_WEBHOOK_PORT))) errors.push("Webhook port must be numeric.");
2782
+ if (!String(v.TELEGRAM_WEBHOOK_PATH || "").startsWith("/")) errors.push("Webhook path must start with /.");
2783
+ }
2784
+ }
2785
+ if (wizard.id === "discord") {
2786
+ if (!v.DISCORD_BOT_TOKEN) errors.push("Discord bot token is required.");
2787
+ else if (!isMaskedSettingValue(v.DISCORD_BOT_TOKEN) && String(v.DISCORD_BOT_TOKEN).length < 20) errors.push("Discord bot token is too short.");
2788
+ if (!v.DISCORD_CLIENT_ID) errors.push("Discord client ID is required.");
2789
+ else errors.push(...validateSnowflakeList(v.DISCORD_CLIENT_ID, "Discord client ID", true));
2790
+ errors.push(...validateSnowflakeList(v.DISCORD_GUILD_IDS, "Discord guild IDs"));
2791
+ errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_GUILD_IDS, "Allowed Discord guilds"));
2792
+ errors.push(...validateSnowflakeList(v.DISCORD_ALLOWED_CHANNEL_IDS, "Allowed Discord channels"));
2793
+ if (["message", "both"].includes(v.DISCORD_COMMAND_MODE || "both") && !wizardValueBool(v.DISCORD_MESSAGE_CONTENT_ENABLED)) warnings.push("Message command mode needs Message Content Intent enabled in the Discord Developer Portal.");
2794
+ }
2795
+ if (wizard.id === "slack") {
2796
+ if (!v.SLACK_BOT_TOKEN) errors.push("Slack bot token is required.");
2797
+ else if (!isMaskedSettingValue(v.SLACK_BOT_TOKEN) && !String(v.SLACK_BOT_TOKEN).startsWith("xoxb-")) errors.push("Slack bot token should start with xoxb-.");
2798
+ if (wizardValueBool(v.SLACK_SOCKET_MODE)) {
2799
+ if (!v.SLACK_APP_TOKEN) errors.push("Slack app token is required for Socket Mode.");
2800
+ else if (!isMaskedSettingValue(v.SLACK_APP_TOKEN) && !String(v.SLACK_APP_TOKEN).startsWith("xapp-")) errors.push("Slack app token should start with xapp-.");
2801
+ } else {
2802
+ if (!v.SLACK_SIGNING_SECRET) errors.push("Slack signing secret is required when Socket Mode is disabled.");
2803
+ if (!Number.isFinite(Number(v.SLACK_PORT))) errors.push("Slack HTTP port must be numeric.");
2804
+ }
2805
+ if (v.SLACK_COMMAND && !String(v.SLACK_COMMAND).startsWith("/")) errors.push("Slack slash command must start with /.");
2806
+ errors.push(...validateSlackIdList(v.SLACK_ALLOWED_TEAM_IDS, "Allowed Slack teams"));
2807
+ errors.push(...validateSlackIdList(v.SLACK_ALLOWED_CHANNEL_IDS, "Allowed Slack channels"));
2808
+ }
2809
+ return { errors, warnings };
2810
+ }
2811
+ function renderWizardValidation() {
2812
+ const wizard = SETUP_WIZARDS[state.settingsWizard?.channel];
2813
+ if (!wizard) return;
2814
+ const result = validateWizard(wizard);
2815
+ const target = document.getElementById("wizardErrors");
2816
+ if (target) target.innerHTML = [...result.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...result.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("");
2817
+ const changed = Object.keys(collectWizardPatch(wizard)).length;
2818
+ const banner = document.getElementById("wizardRestartBanner");
2819
+ if (banner) banner.innerHTML = changed ? '<div class="restart-banner">' + changed + " wizard setting(s) will be saved. A NordRelay restart may be required.</div>" : "";
2820
+ document.getElementById("wizardSaveBtn").disabled = !can("settings.write") || result.errors.length > 0;
2821
+ document.getElementById("wizardSaveRestartBtn").disabled = !can("settings.write") || !can("system.restart") || result.errors.length > 0;
2822
+ }
2823
+ function renderWizardReview() {
2824
+ const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
2825
+ collectWizardValues();
2826
+ const validation = validateWizard(wizard);
2827
+ const patch = collectWizardPatch(wizard);
2828
+ document.getElementById("settingsForm").innerHTML = '<div class="settings-wizard"><div class="wizard-header"><div><h2>' + esc(wizard.label) + ' review</h2><p>Review changed settings before saving. Secret values stay masked when they are already configured.</p></div><button type="button" class="secondary" id="wizardEditBtn">Back to wizard</button></div><div class="list">' + (Object.keys(patch).map((key) => '<div class="item"><strong>' + esc(key) + "</strong><small>" + esc(isMaskedSettingValue(patch[key]) ? "configured secret / unchanged" : patch[key] || "(unset)") + "</small></div>").join("") || '<div class="item">No wizard changes.</div>') + '</div><div id="wizardErrors">' + [...validation.errors.map((e) => '<div class="wizard-error">' + esc(e) + "</div>"), ...validation.warnings.map((w) => '<div class="wizard-warning">' + esc(w) + "</div>")].join("") + '</div><div class="wizard-actions"><button type="button" id="wizardSaveBtn">Save wizard settings</button><button type="button" id="wizardSaveRestartBtn" class="secondary"' + disabledAttr("system.restart") + '>Save and restart</button><button type="button" class="secondary" id="wizardHomeBtn">Wizard home</button></div></div>';
2829
+ document.getElementById("wizardEditBtn").onclick = renderSettingsWizardStep;
2830
+ document.getElementById("wizardHomeBtn").onclick = renderSettingsWizardHome;
2831
+ document.getElementById("wizardSaveBtn").onclick = () => safe(() => saveWizard(false));
2832
+ document.getElementById("wizardSaveRestartBtn").onclick = () => safe(() => saveWizard(true));
2833
+ document.getElementById("wizardSaveBtn").disabled = validation.errors.length > 0;
2834
+ document.getElementById("wizardSaveRestartBtn").disabled = !can("system.restart") || validation.errors.length > 0;
2835
+ applyPermissions();
2836
+ }
2837
+ async function saveWizard(restart) {
2838
+ const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
2839
+ const validation = validateWizard(wizard);
2840
+ if (validation.errors.length) {
2841
+ renderWizardValidation();
2842
+ toast("Wizard settings need attention");
2843
+ return;
2844
+ }
2845
+ const patch = collectWizardPatch(wizard);
2846
+ const r = await api("/api/settings", { method: "PATCH", body: JSON.stringify({ settings: patch }) });
2847
+ if (r.errors && r.errors.length) {
2848
+ document.getElementById("settingsStatus").textContent = "Fix " + r.errors.length + " setting error(s)";
2849
+ toast("Settings need attention");
2850
+ return;
2851
+ }
2852
+ toast("Wizard settings saved" + (r.restartRequired ? " - restart required" : ""));
2853
+ document.getElementById("settingsStatus").textContent = (r.changedKeys?.length ? "Saved " + r.changedKeys.length + " wizard setting(s)" : "No changes") + (r.restartRequired ? " - restart required" : "");
2854
+ if (restart) {
2855
+ await api("/api/runtime/restart", { method: "POST" });
2856
+ toast("Settings saved and restart requested", { duration: 6e3 });
2857
+ }
2858
+ await loadSettings();
2859
+ }
2860
+ async function testWizardSetup() {
2861
+ const wizard = SETUP_WIZARDS[state.settingsWizard.channel];
2862
+ const validation = validateWizard(wizard);
2863
+ if (validation.errors.length) {
2864
+ renderWizardValidation();
2865
+ toast("Fix validation errors before testing");
2866
+ return;
2867
+ }
2868
+ const target = document.getElementById("wizardTestResult");
2869
+ target.innerHTML = loadingHtml("Testing setup...");
2870
+ const result = await api("/api/settings/wizard/test", { method: "POST", body: JSON.stringify({ channel: wizard.id, settings: collectWizardSettings(wizard) }) });
2871
+ target.innerHTML = (result.checks || []).map((check) => '<div class="item"><strong>' + esc(check.label) + ' <span class="adapter-status ' + (check.status === "ok" ? "enabled" : check.status === "warn" ? "planned" : "disabled") + '">' + esc(check.status) + "</span></strong><small>" + esc(check.detail || "") + "</small></div>").join("") || '<div class="item">No checks returned.</div>';
2872
+ }
2873
+ document.getElementById("settingsWizardBtn").onclick = openSettingsWizardHome;
2061
2874
  })();