@nordbyte/nordrelay 0.7.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 (46) hide show
  1. package/.env.example +35 -0
  2. package/README.md +109 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +33 -1
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-store.js +13 -0
  23. package/dist/relay-runtime-helpers.js +3 -1
  24. package/dist/relay-runtime.js +7 -0
  25. package/dist/settings-wizard-test.js +216 -0
  26. package/dist/slack-artifacts.js +165 -0
  27. package/dist/slack-bot.js +1461 -0
  28. package/dist/slack-channel-runtime.js +147 -0
  29. package/dist/slack-command-surface.js +46 -0
  30. package/dist/slack-diagnostics.js +116 -0
  31. package/dist/slack-rate-limit.js +139 -0
  32. package/dist/user-management-crypto.js +38 -0
  33. package/dist/user-management-normalize.js +188 -0
  34. package/dist/user-management-types.js +1 -0
  35. package/dist/user-management.js +193 -196
  36. package/dist/web-api-contract.js +8 -0
  37. package/dist/web-dashboard-access-routes.js +62 -0
  38. package/dist/web-dashboard-assets.js +1 -0
  39. package/dist/web-dashboard-pages.js +14 -4
  40. package/dist/web-dashboard-peer-routes.js +32 -11
  41. package/dist/web-dashboard.js +34 -0
  42. package/dist/web-state.js +2 -2
  43. package/dist/webui-assets/dashboard.css +193 -0
  44. package/dist/webui-assets/dashboard.js +544 -144
  45. package/package.json +3 -1
  46. package/plugins/nordrelay/scripts/nordrelay.mjs +101 -10
@@ -20,10 +20,13 @@
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"] },
23
24
  { path: "/api/peers", methods: ["GET", "POST"] },
24
25
  { path: "/api/peers/invite", methods: ["POST"] },
25
26
  { path: "/api/peers/pair", methods: ["POST"] },
27
+ { path: "/api/peers/probe", methods: ["POST"] },
26
28
  { path: "/api/peers/global-sessions", methods: ["GET"] },
29
+ { re: /^\/api\/peers\/invitations\/[^\/]+$/, methods: ["DELETE"] },
27
30
  { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
28
31
  { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
29
32
  { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
@@ -38,18 +41,23 @@
38
41
  { re: /^\/api\/users\/[^\/]+\/telegram\/[^\/]+$/, methods: ["DELETE"] },
39
42
  { re: /^\/api\/users\/[^\/]+\/discord$/, methods: ["POST"] },
40
43
  { re: /^\/api\/users\/[^\/]+\/discord\/[^\/]+$/, methods: ["DELETE"] },
44
+ { re: /^\/api\/users\/[^\/]+\/slack$/, methods: ["POST"] },
45
+ { re: /^\/api\/users\/[^\/]+\/slack\/[^\/]+$/, methods: ["DELETE"] },
41
46
  { path: "/api/groups", methods: ["GET", "POST"] },
42
47
  { re: /^\/api\/groups\/[^\/]+$/, methods: ["PATCH"] },
43
48
  { path: "/api/telegram-chats", methods: ["GET", "POST"] },
44
49
  { re: /^\/api\/telegram-chats\/[^\/]+$/, methods: ["PATCH"] },
45
50
  { path: "/api/discord-channels", methods: ["GET", "POST"] },
46
51
  { re: /^\/api\/discord-channels\/[^\/]+$/, methods: ["PATCH"] },
52
+ { path: "/api/slack-channels", methods: ["GET", "POST"] },
53
+ { re: /^\/api\/slack-channels\/[^\/]+$/, methods: ["PATCH"] },
47
54
  { path: "/api/audit", methods: ["GET"] },
48
55
  { path: "/api/locks", methods: ["GET", "POST", "DELETE"] },
49
56
  { path: "/api/auth/status", methods: ["GET"] },
50
57
  { path: "/api/auth/login", methods: ["POST"] },
51
58
  { path: "/api/auth/logout", methods: ["POST"] },
52
59
  { path: "/api/settings", methods: ["GET", "PATCH"] },
60
+ { path: "/api/settings/wizard/test", methods: ["POST"] },
53
61
  { path: "/api/control-options", methods: ["GET"] },
54
62
  { path: "/api/sessions", methods: ["GET"] },
55
63
  { path: "/api/sessions/new", methods: ["POST"] },
@@ -140,10 +148,10 @@
140
148
  const peerId = selectedPeerTarget();
141
149
  if (!peerId || peerId === "local") return false;
142
150
  if (!path.startsWith("/api/")) return false;
143
- return !(path === "/api/auth/me" || path === "/api/dashboard/logout" || path === "/api/peers" || path === "/api/peers/invite" || path === "/api/peers/pair" || /^\/api\/peers\/[^/]+(?:\/events|\/proxy)?$/.test(path) || isLocalAdminApi(path));
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));
144
152
  }
145
153
  function isLocalAdminApi(path) {
146
- 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" || /^\/api\/users\//.test(path) || /^\/api\/groups\//.test(path) || /^\/api\/telegram-chats\//.test(path) || /^\/api\/discord-channels\//.test(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);
147
155
  }
148
156
  function selectedPeerTarget() {
149
157
  const runtimeState = (
@@ -222,7 +230,7 @@
222
230
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
223
231
  }
224
232
  }
225
- 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, activeSessions: null, peers: null, selectedPeer: localStorage.getItem("nordrelayPeerTarget") || "local" };
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" };
226
234
  globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
227
235
  function toast(msg, options = {}) {
228
236
  const el = document.getElementById("toast");
@@ -320,18 +328,20 @@
320
328
  ["#abortBtn", "prompt.abort"],
321
329
  ["#clearChatBtn", "sessions.write"],
322
330
  ["#saveSettingsBtn", "settings.write"],
331
+ ["#settingsWizardBtn", "settings.write"],
323
332
  ["#restartBtn", "system.restart"],
324
333
  ["#updateBtn", "updates.run"],
325
334
  ["#clearLogsBtn", "logs.clear"],
326
- ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
327
- ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke]", "peers.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"],
328
338
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
329
339
  ["[data-switch]", "sessions.write"],
330
340
  ["[data-queue],[data-q]", "queue.write"],
331
341
  ["[data-del-art],#deleteSelectedArtifactsBtn", "files.write"],
332
342
  ["[data-auth-login],[data-auth-logout]", "auth.manage"],
333
343
  ["[data-update-agent],[data-update-send],[data-update-cancel],[data-update-delete-log]", "updates.run"],
334
- ["[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"]
335
345
  ];
336
346
  disableMap.forEach(([selector, permission]) => document.querySelectorAll(selector).forEach((el) => {
337
347
  el.disabled = !can(permission);
@@ -565,6 +575,7 @@
565
575
  if (source === "cli") return "CLI";
566
576
  if (source === "telegram") return "Telegram";
567
577
  if (source === "discord") return "Discord";
578
+ if (source === "slack") return "Slack";
568
579
  if (source === "web") return "WebUI";
569
580
  return source || "-";
570
581
  }
@@ -1241,49 +1252,9 @@
1241
1252
  if (kind === "docs") return !/\\.(png|jpe?g|gif|webp|svg)$/i.test(name);
1242
1253
  return true;
1243
1254
  }
1244
- function renderArtifacts() {
1245
- const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
1246
- const kind = document.getElementById("artifactKind").value;
1247
- const reports = state.artifactReports || [];
1248
- document.getElementById("artifactList").innerHTML = reports.map((r) => {
1249
- const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
1250
- if (files.length === 0) return "";
1251
- const gallery = files.map((a) => {
1252
- const href = "/api/artifacts/file?turnId=" + encodeURIComponent(r.turnId) + "&path=" + encodeURIComponent(a.relativePath);
1253
- const img = /\\.(png|jpe?g|gif|webp|svg)$/i.test(a.name) ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
1254
- 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>';
1255
- }).join("");
1256
- 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>";
1257
- }).join("") || '<div class="item">No artifacts.</div>';
1258
- document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
1259
- if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
1260
- else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
1261
- });
1262
- document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
1263
- if (!can("files.write")) {
1264
- toast("Permission required: files.write");
1265
- return;
1266
- }
1267
- if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
1268
- await api("/api/artifacts", { method: "DELETE", query: { turnId: b.dataset.delArt } });
1269
- state.selectedArtifactTurns.delete(b.dataset.delArt);
1270
- loadArtifacts();
1271
- }
1272
- }));
1273
- document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
1274
- applyPermissions();
1275
- }
1276
1255
  document.getElementById("reloadArtifactsBtn").onclick = loadArtifacts;
1277
1256
  document.getElementById("artifactSearch").oninput = renderArtifacts;
1278
1257
  document.getElementById("artifactKind").onchange = renderArtifacts;
1279
- document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
1280
- const turnIds = [...state.selectedArtifactTurns];
1281
- if (turnIds.length === 0) {
1282
- toast("No artifact turns selected");
1283
- return;
1284
- }
1285
- turnIds.forEach((turnId) => window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
1286
- };
1287
1258
  document.getElementById("deleteSelectedArtifactsBtn").onclick = () => safe(async () => {
1288
1259
  if (!can("files.write")) {
1289
1260
  toast("Permission required: files.write");
@@ -1304,26 +1275,6 @@
1304
1275
  function highlightCode(text) {
1305
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>');
1306
1277
  }
1307
- async function previewArtifact(turnId, path) {
1308
- const target = document.getElementById("artifactPreview");
1309
- target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
1310
- target.scrollIntoView({ block: "start", behavior: "smooth" });
1311
- try {
1312
- const data = await api("/api/artifacts/preview", { query: { turnId, path } });
1313
- if (data.kind === "image") {
1314
- target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="/api/artifacts/file?turnId=' + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path) + '"></div>';
1315
- return;
1316
- }
1317
- if (data.kind === "text") {
1318
- target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + " " + fmtBytes(data.sizeBytes) + "</h2><pre>" + highlightCode(data.text || "") + "</pre>" + (data.truncated ? "<small>Preview truncated.</small>" : "") + "</div>";
1319
- return;
1320
- }
1321
- target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
1322
- } catch (err) {
1323
- target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
1324
- throw err;
1325
- }
1326
- }
1327
1278
  function isRemotePeerTarget() {
1328
1279
  return Boolean(state.selectedPeer && state.selectedPeer !== "local");
1329
1280
  }
@@ -1539,7 +1490,8 @@
1539
1490
  }
1540
1491
  function renderMetrics(d) {
1541
1492
  const adapters = d.adapters || {};
1542
- document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Process", metricProcessRows(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>";
1543
1495
  }
1544
1496
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1545
1497
  function activityQuery() {
@@ -1557,7 +1509,7 @@
1557
1509
  }
1558
1510
  function activityActorText(e) {
1559
1511
  const a = e.actor || {};
1560
- 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");
1561
1513
  }
1562
1514
  function activityMetaHtml(e) {
1563
1515
  const workspace = activityWorkspace(e);
@@ -1589,12 +1541,14 @@
1589
1541
  URL.revokeObjectURL(a.href);
1590
1542
  };
1591
1543
  async function loadSettings() {
1544
+ state.settingsWizard = null;
1545
+ document.getElementById("settingsTabs").style.display = "";
1592
1546
  setLoading("settingsForm", "Loading settings...");
1593
1547
  const data = await api("/api/settings");
1594
1548
  state.settings = data.settings;
1595
1549
  renderSettings();
1596
1550
  }
1597
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1551
+ const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Slack", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1598
1552
  const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1599
1553
  function orderedSettingsGroups(groups) {
1600
1554
  const known = settingsGroupOrder.filter((name) => groups[name]);
@@ -1733,7 +1687,7 @@
1733
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>";
1734
1688
  }
1735
1689
  function selectedValues(selector) {
1736
- 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);
1737
1691
  }
1738
1692
  function csv(values = []) {
1739
1693
  return (values || []).join(", ");
@@ -1742,6 +1696,7 @@
1742
1696
  const dialog = document.getElementById("adminDialog");
1743
1697
  document.getElementById("adminDialogTitle").textContent = title;
1744
1698
  document.getElementById("adminDialogBody").innerHTML = body;
1699
+ document.getElementById("adminDialogSubmit").textContent = "Save";
1745
1700
  document.getElementById("adminDialogCancel").onclick = () => dialog.close();
1746
1701
  document.getElementById("adminDialogForm").onsubmit = (e) => safe(async () => {
1747
1702
  e.preventDefault();
@@ -1762,10 +1717,15 @@
1762
1717
  }
1763
1718
  function bindAccessTabs() {
1764
1719
  document.querySelectorAll("[data-access-tab]").forEach((b) => b.onclick = () => switchAccessTab(b.dataset.accessTab));
1765
- const search = document.getElementById("discordChannelSearch");
1766
- if (search && !search.dataset.bound) {
1767
- search.dataset.bound = "true";
1768
- 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();
1769
1729
  }
1770
1730
  }
1771
1731
  function switchAccessTab(tab) {
@@ -1803,16 +1763,46 @@
1803
1763
  bindAccessCopyButtons();
1804
1764
  applyPermissions();
1805
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
+ }
1806
1793
  function renderUserManagement(d) {
1807
1794
  document.getElementById("accessPanel").innerHTML = (d.users || []).map((u) => {
1808
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(" ");
1809
1796
  const discord = (u.discordIdentities || []).map((identity) => discordIdentityHtml(u, identity)).join(" ");
1810
- 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>";
1811
1799
  }).join("") || '<div class="item">No users.</div>';
1812
- 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>';
1813
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>';
1814
1802
  renderDiscordChannels(d.discordChannels || []);
1803
+ renderSlackChannels(d.slackChannels || []);
1815
1804
  bindUserButtons();
1805
+ bindSlackUserButtons();
1816
1806
  bindAccessCopyButtons();
1817
1807
  applyPermissions();
1818
1808
  }
@@ -1900,6 +1890,33 @@
1900
1890
  loadAccess();
1901
1891
  }));
1902
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
+ }
1903
1920
  function openUserDialog(u) {
1904
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 () => {
1905
1922
  const payload = { email: val("dlgEmail"), displayName: val("dlgName"), active: document.getElementById("dlgActive").checked, groupIds: selectedValues("[data-group-choice]") };
@@ -1927,6 +1944,19 @@
1927
1944
  toast("Discord linked");
1928
1945
  });
1929
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
+ }
1930
1960
  function openTelegramLinkDialog(id) {
1931
1961
  adminDialog("Link Telegram ID", '<label>Telegram user ID<input id="dlgTelegramId" type="number"></label><label>Username<input id="dlgUsername"></label>', async () => {
1932
1962
  await api("/api/users/" + encodeURIComponent(id) + "/telegram", { method: "POST", body: JSON.stringify({ telegramUserId: Number(val("dlgTelegramId")), username: val("dlgUsername") || void 0 }) });
@@ -1934,8 +1964,8 @@
1934
1964
  });
1935
1965
  }
1936
1966
  function openGroupDialog(g) {
1937
- 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 () => {
1938
- 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]") };
1939
1969
  await api(g ? "/api/groups/" + encodeURIComponent(g.id) : "/api/groups", { method: g ? "PATCH" : "POST", body: JSON.stringify(payload) });
1940
1970
  toast(g ? "Group updated" : "Group created");
1941
1971
  });
@@ -1948,6 +1978,13 @@
1948
1978
  toast(c ? "Discord channel updated" : "Discord channel added");
1949
1979
  });
1950
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
+ }
1951
1988
  function openChatDialog(c) {
1952
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 () => {
1953
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]") };
@@ -1986,6 +2023,13 @@
1986
2023
  }
1987
2024
  openDiscordChannelDialog(null);
1988
2025
  };
2026
+ document.getElementById("createSlackChannelBtn").onclick = () => {
2027
+ if (!can("users.write")) {
2028
+ toast("Permission required: users.write");
2029
+ return;
2030
+ }
2031
+ openSlackChannelDialog(null);
2032
+ };
1989
2033
  async function loadLocks() {
1990
2034
  if (!can("sessions.read")) {
1991
2035
  document.getElementById("locksList").innerHTML = '<div class="item">Permission required: sessions.read</div>';
@@ -2109,67 +2153,18 @@
2109
2153
  setLoading("peersList", "Loading peers...");
2110
2154
  const d = await api("/api/peers", { local: true });
2111
2155
  state.peers = d;
2112
- document.getElementById("peerStatus").innerHTML = card("Local peer identity", [["Peer server", d.enabled ? "enabled" : "disabled"], ["Listen URL", d.listenUrl], ["Require TLS", d.requireTls ? "yes" : "no"], ["Node ID", d.identity?.nodeId], ["Fingerprint", d.identity?.fingerprint]]);
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);
2113
2161
  document.getElementById("peersList").innerHTML = (d.peers || []).map(peerCard).join("") || '<div class="item">No peers configured.</div>';
2114
- document.getElementById("peerInvites").innerHTML = (d.invitations || []).map((i) => '<div class="item"><strong>' + esc(i.name) + ' <span class="chip">' + esc(new Date(i.expiresAt) > /* @__PURE__ */ new Date() && !i.usedAt ? "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>" : "") + "</div>").join("") || '<div class="item">No open invitations.</div>';
2162
+ document.getElementById("peerInvites").innerHTML = (d.invitations || []).map(peerInviteCard).join("") || '<div class="item">No open invitations.</div>';
2163
+ ensureGlobalPeerSessionsPanel();
2115
2164
  bindPeerButtons();
2116
2165
  await loadPeerSelector();
2117
2166
  applyPermissions();
2118
2167
  }
2119
- function peerCard(p) {
2120
- const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2121
- 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>" + (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-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>";
2122
- }
2123
- function bindPeerButtons() {
2124
- document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
2125
- state.selectedPeer = b.dataset.peerSelect || "local";
2126
- localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
2127
- connectEvents();
2128
- await loadBootstrap();
2129
- toast("Peer target selected");
2130
- }));
2131
- document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
2132
- const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/proxy", { method: "POST", body: JSON.stringify({ method: "GET", path: "/api/health", query: {}, body: {} }), local: true });
2133
- toast("Peer reachable: " + (r.health?.state?.status || r.state?.status || "ok"), { duration: 6e3 });
2134
- loadPeers();
2135
- }));
2136
- document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2137
- const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2138
- if (p) openPeerDialog(p);
2139
- });
2140
- document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
2141
- const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
2142
- if (!p) return;
2143
- await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
2144
- toast("Peer updated");
2145
- loadPeers();
2146
- }));
2147
- document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
2148
- if (confirm("Revoke this peer?")) {
2149
- await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
2150
- if (state.selectedPeer === b.dataset.peerRevoke) {
2151
- state.selectedPeer = "local";
2152
- localStorage.setItem("nordrelayPeerTarget", "local");
2153
- }
2154
- toast("Peer revoked");
2155
- loadPeers();
2156
- }
2157
- }));
2158
- }
2159
- function openPeerDialog(p) {
2160
- 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>', async () => {
2161
- 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")) }), local: true });
2162
- toast("Peer updated");
2163
- await loadPeers();
2164
- });
2165
- }
2166
- function openPeerInviteDialog() {
2167
- adminDialog("Create peer invite", '<label>Name<input id="dlgPeerInviteName" value="NordRelay peer"></label><label>Expires minutes<input id="dlgPeerInviteExpires" type="number" value="10" min="1"></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>', async () => {
2168
- 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")) }), local: true });
2169
- toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
2170
- await loadPeers();
2171
- });
2172
- }
2173
2168
  function openPeerAddDialog() {
2174
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 () => {
2175
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 });
@@ -2194,8 +2189,11 @@
2194
2189
  };
2195
2190
  async function loadAdapterHealth() {
2196
2191
  setLoading("adapterHealth", "Loading adapters...");
2197
- 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;
2198
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);
2199
2197
  document.querySelectorAll("[data-auth-status]").forEach((b) => b.onclick = () => safe(async () => {
2200
2198
  const r = await api("/api/auth/status", { query: { agent: b.dataset.authStatus } });
2201
2199
  toast(r.agentLabel + ": " + r.detail, { duration: 6e3 });
@@ -2220,6 +2218,22 @@
2220
2218
  }));
2221
2219
  applyPermissions();
2222
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
+ }
2223
2237
  document.getElementById("reloadAdaptersBtn").onclick = () => loadAdapterHealth();
2224
2238
  const versionAgentIds = { codex: "codex", pi: "pi", hermes: "hermes", openclaw: "openclaw", claudeCode: "claude-code" };
2225
2239
  async function loadVersion() {
@@ -2382,7 +2396,9 @@
2382
2396
  const vc = d.versionChecks || {};
2383
2397
  const caps = s.capabilities || {};
2384
2398
  const agentDiag = d.runtime?.agentDiagnostics;
2385
- 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>";
2386
2402
  }
2387
2403
  function card(title, rows) {
2388
2404
  return '<div class="item"><strong>' + esc(title) + "</strong>" + rows.map((r) => "<small>" + esc(r[0]) + ": " + esc(r[1] ?? "-") + "</small>").join("") + "</div>";
@@ -2392,11 +2408,6 @@
2392
2408
  Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
2393
2409
  }
2394
2410
  loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
2395
- const loadPeersBase = loadPeers;
2396
- loadPeers = async function() {
2397
- await loadPeersBase();
2398
- ensureGlobalPeerSessionsPanel();
2399
- };
2400
2411
  function ensureGlobalPeerSessionsPanel() {
2401
2412
  if (document.getElementById("globalPeerSessionsPanel")) return;
2402
2413
  const anchor = document.getElementById("peersList");
@@ -2411,11 +2422,36 @@
2411
2422
  const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
2412
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>';
2413
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
+ }
2414
2450
  function peerCard(p) {
2415
2451
  const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2416
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";
2417
2453
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2418
- 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-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>";
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>";
2419
2455
  }
2420
2456
  function openPeerDialog(p) {
2421
2457
  const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
@@ -2426,11 +2462,15 @@
2426
2462
  });
2427
2463
  }
2428
2464
  function openPeerInviteDialog() {
2429
- adminDialog("Create peer invite", '<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 () => {
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 () => {
2430
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 });
2431
- toast("Pairing code: " + r.code + "\\n" + r.command, { duration: 2e4 });
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 });
2432
2471
  await loadPeers();
2433
2472
  });
2473
+ document.getElementById("adminDialogSubmit").textContent = warnings.length ? "Create invite anyway" : "Create invite";
2434
2474
  }
2435
2475
  function aliasMap(text) {
2436
2476
  return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
@@ -2448,6 +2488,27 @@
2448
2488
  toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
2449
2489
  loadPeers();
2450
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
+ }));
2451
2512
  document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2452
2513
  const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2453
2514
  if (p) openPeerDialog(p);
@@ -2470,5 +2531,344 @@
2470
2531
  loadPeers();
2471
2532
  }
2472
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>';
2473
2872
  }
2873
+ document.getElementById("settingsWizardBtn").onclick = openSettingsWizardHome;
2474
2874
  })();