@nordbyte/nordrelay 0.6.0 → 0.7.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 (42) hide show
  1. package/.env.example +17 -0
  2. package/README.md +67 -6
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/bot-preferences.js +1 -0
  6. package/dist/bot.js +77 -6
  7. package/dist/channel-adapter.js +11 -5
  8. package/dist/channel-command-catalog.js +88 -0
  9. package/dist/channel-command-service.js +214 -1
  10. package/dist/channel-mirror-registry.js +77 -0
  11. package/dist/channel-peer-prompt.js +95 -0
  12. package/dist/channel-runtime.js +12 -5
  13. package/dist/codex-state.js +114 -78
  14. package/dist/config-metadata.js +15 -0
  15. package/dist/config.js +31 -6
  16. package/dist/context-key.js +10 -0
  17. package/dist/discord-bot.js +85 -26
  18. package/dist/discord-command-surface.js +11 -73
  19. package/dist/index.js +20 -0
  20. package/dist/metrics.js +46 -0
  21. package/dist/peer-auth.js +85 -0
  22. package/dist/peer-client.js +256 -0
  23. package/dist/peer-context.js +21 -0
  24. package/dist/peer-identity.js +127 -0
  25. package/dist/peer-runtime-service.js +636 -0
  26. package/dist/peer-server.js +220 -0
  27. package/dist/peer-store.js +294 -0
  28. package/dist/peer-types.js +52 -0
  29. package/dist/relay-runtime-helpers.js +208 -0
  30. package/dist/relay-runtime.js +72 -274
  31. package/dist/remote-prompt.js +98 -0
  32. package/dist/telegram-command-menu.js +3 -53
  33. package/dist/telegram-general-commands.js +14 -0
  34. package/dist/telegram-preference-commands.js +23 -127
  35. package/dist/web-api-contract.js +8 -0
  36. package/dist/web-dashboard-pages.js +12 -0
  37. package/dist/web-dashboard-peer-routes.js +204 -0
  38. package/dist/web-dashboard-ui.js +1 -0
  39. package/dist/web-dashboard.js +12 -0
  40. package/dist/webui-assets/dashboard.js +427 -14
  41. package/package.json +3 -2
  42. package/plugins/nordrelay/scripts/nordrelay.mjs +373 -7
@@ -20,6 +20,14 @@
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/peers", methods: ["GET", "POST"] },
24
+ { path: "/api/peers/invite", methods: ["POST"] },
25
+ { path: "/api/peers/pair", methods: ["POST"] },
26
+ { path: "/api/peers/global-sessions", methods: ["GET"] },
27
+ { re: /^\/api\/peers\/[^\/]+\/health$/, methods: ["GET"] },
28
+ { re: /^\/api\/peers\/[^\/]+$/, methods: ["PATCH", "DELETE"] },
29
+ { re: /^\/api\/peers\/[^\/]+\/proxy$/, methods: ["POST"] },
30
+ { re: /^\/api\/peers\/[^\/]+\/events$/, methods: ["GET"] },
23
31
  { path: "/api/permissions", methods: ["GET"] },
24
32
  { path: "/api/users", methods: ["GET", "POST"] },
25
33
  { re: /^\/api\/users\/[^\/]+$/, methods: ["PATCH"] },
@@ -84,6 +92,32 @@
84
92
  const method = normalizeMethod(options.method, options.body);
85
93
  const url = apiUrl(path, options.query);
86
94
  assertApiRoute(url.pathname, method);
95
+ if (!options.local && shouldProxyApi(url.pathname)) {
96
+ const peerId = selectedPeerTarget();
97
+ const proxyBody = JSON.stringify({
98
+ method,
99
+ path: url.pathname,
100
+ query: queryObject(url),
101
+ body: bodyObject(options.body),
102
+ contextKey: "web:dashboard"
103
+ });
104
+ const res2 = await fetch("/api/peers/" + encodeURIComponent(peerId) + "/proxy", {
105
+ method: "POST",
106
+ headers: { "content-type": "application/json" },
107
+ body: proxyBody
108
+ });
109
+ if (res2.status === 401) {
110
+ location.reload();
111
+ return (
112
+ /** @type {never} */
113
+ void 0
114
+ );
115
+ }
116
+ const text2 = await res2.text();
117
+ const data2 = text2 ? JSON.parse(text2) : {};
118
+ if (!res2.ok) throw new Error(data2.error || res2.statusText);
119
+ return data2;
120
+ }
87
121
  const body = normalizeBody(options.body);
88
122
  const headers = {
89
123
  ...body !== void 0 && shouldSendJsonHeader(options.body) ? { "content-type": "application/json" } : {},
@@ -102,6 +136,46 @@
102
136
  if (!res.ok) throw new Error(data.error || res.statusText);
103
137
  return data;
104
138
  }
139
+ function shouldProxyApi(path) {
140
+ const peerId = selectedPeerTarget();
141
+ if (!peerId || peerId === "local") return false;
142
+ 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));
144
+ }
145
+ 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);
147
+ }
148
+ function selectedPeerTarget() {
149
+ const runtimeState = (
150
+ /** @type {{ NORDRELAY_WEBUI_RUNTIME_STATE?: { selectedPeer?: string } }} */
151
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE
152
+ );
153
+ return runtimeState?.selectedPeer || "local";
154
+ }
155
+ function queryObject(url) {
156
+ const result = {};
157
+ for (const [key, value] of url.searchParams.entries()) {
158
+ if (result[key] === void 0) result[key] = value;
159
+ else if (Array.isArray(result[key])) result[key].push(value);
160
+ else result[key] = [result[key], value];
161
+ }
162
+ return result;
163
+ }
164
+ function bodyObject(body) {
165
+ if (body === void 0 || body === null) return {};
166
+ if (typeof body === "string") {
167
+ try {
168
+ return body ? JSON.parse(body) : {};
169
+ } catch {
170
+ return { value: body };
171
+ }
172
+ }
173
+ if (isNativeBody(body)) return {};
174
+ return (
175
+ /** @type {Record<string, unknown>} */
176
+ body
177
+ );
178
+ }
105
179
  function apiUrl(path, query) {
106
180
  const url = new URL(path, location.origin);
107
181
  if (query) {
@@ -148,7 +222,8 @@
148
222
  throw new Error("Unsupported WebUI API method: " + method + " " + path);
149
223
  }
150
224
  }
151
- const state = { snapshot: null, controls: null, newSessionControls: null, enabledAgents: [], auth: null, permissions: [], settings: [], currentPage: "overview", settingsGroup: null, accessTab: "users", logsPlain: "", logTimer: null, toastTimer: null, cliStatusActive: false, selectedArtifactTurns: /* @__PURE__ */ new Set(), mediaRecorder: null, recordedChunks: [], events: null, reconnectTimer: null, notifications: false, toolTooltipTimer: null, toolTooltipTarget: null, agentUpdateJobs: [], sessionsRequestId: 0, activeSessionsTimer: null };
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" };
226
+ globalThis.NORDRELAY_WEBUI_RUNTIME_STATE = state;
152
227
  function toast(msg, options = {}) {
153
228
  const el = document.getElementById("toast");
154
229
  el.textContent = msg;
@@ -249,6 +324,7 @@
249
324
  ["#updateBtn", "updates.run"],
250
325
  ["#clearLogsBtn", "logs.clear"],
251
326
  ["#createUserBtn,#createGroupBtn,#createChatBtn,#createDiscordChannelBtn", "users.write"],
327
+ ["#createPeerInviteBtn,#addPeerBtn,[data-peer-edit],[data-peer-toggle],[data-peer-revoke]", "peers.write"],
252
328
  ["#lockSessionBtn,#unlockSessionBtn", "sessions.write"],
253
329
  ["[data-switch]", "sessions.write"],
254
330
  ["[data-queue],[data-q]", "queue.write"],
@@ -311,6 +387,7 @@
311
387
  if (name === "tasks") await loadTasks();
312
388
  if (name === "metrics") await loadMetrics();
313
389
  if (name === "adapters") await loadAdapterHealth();
390
+ if (name === "peers") await loadPeers();
314
391
  if (name === "access") await loadAccess();
315
392
  if (name === "version") await loadVersion();
316
393
  }
@@ -354,9 +431,11 @@
354
431
  }
355
432
  const sessionsPager = createPaginator("sessionsPager", () => loadSessions(false), 50);
356
433
  async function loadBootstrap() {
357
- const data = await api("/api/bootstrap");
358
- state.auth = data.auth || null;
359
- state.permissions = data.auth?.permissions || [];
434
+ const local = await api("/api/bootstrap", { local: true });
435
+ state.auth = local.auth || null;
436
+ state.permissions = local.auth?.permissions || [];
437
+ await loadPeerSelector();
438
+ const data = state.selectedPeer && state.selectedPeer !== "local" ? await api("/api/bootstrap") : local;
360
439
  state.snapshot = data.status.snapshot;
361
440
  state.controls = data.controls;
362
441
  state.enabledAgents = data.enabledAgents || [];
@@ -368,7 +447,7 @@
368
447
  renderAdapters(data.channels, data.agentAdapters);
369
448
  document.getElementById("footerVersion").textContent = "NordRelay " + (data.status.health?.version || "");
370
449
  document.getElementById("footerHealth").textContent = "Health: " + (data.status.health?.state?.status || "unknown");
371
- document.getElementById("footerUser").textContent = "User: " + (data.auth?.user?.email || "-");
450
+ document.getElementById("footerUser").textContent = "User: " + (local.auth?.user?.email || "-") + (state.selectedPeer && state.selectedPeer !== "local" ? " / target peer" : "");
372
451
  const agentSelect = document.getElementById("agentSelect");
373
452
  agentSelect.innerHTML = data.enabledAgents.map((a) => '<option value="' + a + '">' + a + "</option>").join("");
374
453
  agentSelect.value = state.snapshot.session.agentId;
@@ -385,6 +464,35 @@
385
464
  });
386
465
  applyPermissions();
387
466
  }
467
+ async function loadPeerSelector() {
468
+ const peerSelect = document.getElementById("peerSelect");
469
+ if (!peerSelect) return;
470
+ if (!can("peers.read")) {
471
+ peerSelect.innerHTML = '<option value="local">Local</option>';
472
+ peerSelect.value = "local";
473
+ state.selectedPeer = "local";
474
+ return;
475
+ }
476
+ try {
477
+ const peers = await api("/api/peers", { local: true });
478
+ state.peers = peers;
479
+ const available = (peers.peers || []).filter((p) => p.enabled && p.url);
480
+ peerSelect.innerHTML = '<option value="local">Local node</option>' + available.map((p) => '<option value="' + attr(p.id) + '">' + esc(p.name) + "</option>").join("");
481
+ if (state.selectedPeer !== "local" && !available.some((p) => p.id === state.selectedPeer)) state.selectedPeer = "local";
482
+ peerSelect.value = state.selectedPeer;
483
+ peerSelect.onchange = () => safe(async () => {
484
+ state.selectedPeer = peerSelect.value || "local";
485
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
486
+ connectEvents();
487
+ toast(state.selectedPeer === "local" ? "Target: local" : "Target: " + peerSelect.options[peerSelect.selectedIndex].text);
488
+ await loadBootstrap();
489
+ await reloadCurrentPage();
490
+ });
491
+ } catch {
492
+ peerSelect.innerHTML = '<option value="local">Local node</option>';
493
+ peerSelect.value = "local";
494
+ }
495
+ }
388
496
  function renderSnapshot(s) {
389
497
  document.getElementById("sessionLine").textContent = (s.session.agentLabel || "Agent") + " / " + (s.session.model || "default") + " / " + (s.session.threadId || "not started");
390
498
  document.getElementById("metrics").innerHTML = [
@@ -409,6 +517,7 @@
409
517
  renderActiveSessions(data.sessions || []);
410
518
  }
411
519
  function renderActiveSessions(items) {
520
+ state.activeSessions = { sessions: items || [], updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
412
521
  const box = document.getElementById("activeSessions");
413
522
  if (!box) return;
414
523
  box.innerHTML = (items || []).map(activeSessionCard).join("") || '<div class="item">No active sessions.</div>';
@@ -552,9 +661,6 @@
552
661
  if (status === "running") return "planned";
553
662
  return "disabled";
554
663
  }
555
- state.activeSessionsTimer = setInterval(() => {
556
- if (state.currentPage === "overview") safe(loadActiveSessions);
557
- }, 5e3);
558
664
  function scrollChatToBottom() {
559
665
  const box = document.getElementById("messages");
560
666
  if (!box) return;
@@ -600,7 +706,8 @@
600
706
  let currentAgentMessage = null;
601
707
  function connectEvents() {
602
708
  if (state.events) state.events.close();
603
- const events = new EventSource("/api/events");
709
+ const eventsUrl = state.selectedPeer && state.selectedPeer !== "local" ? "/api/peers/" + encodeURIComponent(state.selectedPeer) + "/events?contextKey=" + encodeURIComponent("web:dashboard") : "/api/events";
710
+ const events = new EventSource(eventsUrl);
604
711
  state.events = events;
605
712
  setConnection("Connecting", "warn");
606
713
  events.onopen = () => {
@@ -618,6 +725,11 @@
618
725
  });
619
726
  events.addEventListener("chat_history", (e) => renderChatMessages(JSON.parse(e.data).messages || []));
620
727
  events.addEventListener("activity_update", (e) => renderActivity(JSON.parse(e.data).events || []));
728
+ events.addEventListener("active_sessions_update", (e) => {
729
+ const d = JSON.parse(e.data);
730
+ state.activeSessions = d.active || null;
731
+ if (state.currentPage === "overview") renderActiveSessions(state.activeSessions?.sessions || []);
732
+ });
621
733
  events.addEventListener("session_update", (e) => {
622
734
  loadBootstrap();
623
735
  loadChatHistory();
@@ -1212,6 +1324,99 @@
1212
1324
  throw err;
1213
1325
  }
1214
1326
  }
1327
+ function isRemotePeerTarget() {
1328
+ return Boolean(state.selectedPeer && state.selectedPeer !== "local");
1329
+ }
1330
+ function artifactFileHref(turnId, path) {
1331
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1332
+ }
1333
+ function artifactZipHref(turnId) {
1334
+ return isRemotePeerTarget() ? "#" : "/api/artifacts/zip?turnId=" + encodeURIComponent(turnId);
1335
+ }
1336
+ async function downloadArtifactFile(turnId, path) {
1337
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1338
+ downloadBase64(file.name || path, file.dataBase64 || "", file.mimeType || "application/octet-stream");
1339
+ }
1340
+ async function downloadArtifactZip(turnId) {
1341
+ const file = await api("/api/artifacts/zip", { query: { turnId } });
1342
+ downloadBase64(file.name || "nordrelay-artifacts-" + turnId + ".zip", file.dataBase64 || "", file.mimeType || "application/zip");
1343
+ }
1344
+ async function artifactDataUrl(turnId, path) {
1345
+ const file = await api("/api/artifacts/file", { query: { turnId, path } });
1346
+ return "data:" + (file.mimeType || "application/octet-stream") + ";base64," + (file.dataBase64 || "");
1347
+ }
1348
+ function renderArtifacts() {
1349
+ const query = (document.getElementById("artifactSearch").value || "").toLowerCase();
1350
+ const kind = document.getElementById("artifactKind").value;
1351
+ const reports = state.artifactReports || [];
1352
+ document.getElementById("artifactList").innerHTML = reports.map((r) => {
1353
+ const files = (r.artifacts || []).filter((a) => artifactMatches(a, kind, query));
1354
+ if (files.length === 0) return "";
1355
+ const gallery = files.map((a) => {
1356
+ const href = artifactFileHref(r.turnId, a.relativePath);
1357
+ const isImage = /\.(png|jpe?g|gif|webp|svg)$/i.test(a.name);
1358
+ const img = isImage && !isRemotePeerTarget() ? '<img src="' + href + '">' : "<pre>" + esc(a.name.split(".").pop() || "file") + "</pre>";
1359
+ return '<div class="artifact-card"><label><input type="checkbox" data-artifact-select="' + attr(r.turnId) + '" ' + (state.selectedArtifactTurns.has(r.turnId) ? "checked" : "") + "> " + esc(short(a.name, 32)) + "</label>" + img + "<small>" + esc(fmtBytes(a.sizeBytes)) + '</small><div class="row"><a href="' + href + '" data-open-artifact="' + attr(r.turnId) + '" data-open-path="' + attr(a.relativePath) + '">Open</a><button class="secondary" data-preview-turn="' + attr(r.turnId) + '" data-preview-path="' + attr(a.relativePath) + '">Preview</button></div></div>';
1360
+ }).join("");
1361
+ return '<div class="item"><strong>' + esc(r.turnId) + " - " + files.length + "/" + r.fileCount + " files - " + fmtBytes(r.totalSizeBytes) + "</strong><small>" + fmtDate(r.updatedAt) + " / " + esc(r.source || "turn") + '</small><div class="row"><a href="' + artifactZipHref(r.turnId) + '" data-zip-artifact="' + attr(r.turnId) + '">Download ZIP</a><button data-del-art="' + esc(r.turnId) + '" class="danger"' + disabledAttr("files.write") + '>Delete</button></div><div class="gallery">' + gallery + "</div></div>";
1362
+ }).join("") || '<div class="item">No artifacts.</div>';
1363
+ document.querySelectorAll("[data-artifact-select]").forEach((c) => c.onchange = () => {
1364
+ if (c.checked) state.selectedArtifactTurns.add(c.dataset.artifactSelect);
1365
+ else state.selectedArtifactTurns.delete(c.dataset.artifactSelect);
1366
+ });
1367
+ document.querySelectorAll("[data-open-artifact]").forEach((a) => a.onclick = (e) => {
1368
+ if (!isRemotePeerTarget()) return;
1369
+ e.preventDefault();
1370
+ safe(() => downloadArtifactFile(a.dataset.openArtifact, a.dataset.openPath));
1371
+ });
1372
+ document.querySelectorAll("[data-zip-artifact]").forEach((a) => a.onclick = (e) => {
1373
+ if (!isRemotePeerTarget()) return;
1374
+ e.preventDefault();
1375
+ safe(() => downloadArtifactZip(a.dataset.zipArtifact));
1376
+ });
1377
+ document.querySelectorAll("[data-del-art]").forEach((b) => b.onclick = () => safe(async () => {
1378
+ if (!can("files.write")) {
1379
+ toast("Permission required: files.write");
1380
+ return;
1381
+ }
1382
+ if (confirm("Delete artifact turn " + b.dataset.delArt + "?")) {
1383
+ await api("/api/artifacts", { method: "DELETE", query: { turnId: b.dataset.delArt } });
1384
+ state.selectedArtifactTurns.delete(b.dataset.delArt);
1385
+ loadArtifacts();
1386
+ }
1387
+ }));
1388
+ document.querySelectorAll("[data-preview-turn]").forEach((b) => b.onclick = () => safe(() => previewArtifact(b.dataset.previewTurn, b.dataset.previewPath)));
1389
+ applyPermissions();
1390
+ }
1391
+ document.getElementById("zipSelectedArtifactsBtn").onclick = () => {
1392
+ const turnIds = [...state.selectedArtifactTurns];
1393
+ if (turnIds.length === 0) {
1394
+ toast("No artifact turns selected");
1395
+ return;
1396
+ }
1397
+ turnIds.forEach((turnId) => isRemotePeerTarget() ? safe(() => downloadArtifactZip(turnId)) : window.open("/api/artifacts/zip?turnId=" + encodeURIComponent(turnId), "_blank"));
1398
+ };
1399
+ async function previewArtifact(turnId, path) {
1400
+ const target = document.getElementById("artifactPreview");
1401
+ target.innerHTML = '<div class="panel">' + loadingHtml("Loading preview...") + "</div>";
1402
+ target.scrollIntoView({ block: "start", behavior: "smooth" });
1403
+ try {
1404
+ const data = await api("/api/artifacts/preview", { query: { turnId, path } });
1405
+ if (data.kind === "image") {
1406
+ const src = isRemotePeerTarget() ? await artifactDataUrl(turnId, path) : "/api/artifacts/file?turnId=" + encodeURIComponent(turnId) + "&path=" + encodeURIComponent(path);
1407
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + '</h2><img src="' + src + '"></div>';
1408
+ return;
1409
+ }
1410
+ if (data.kind === "text") {
1411
+ 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>";
1412
+ return;
1413
+ }
1414
+ target.innerHTML = '<div class="panel"><h2>' + esc(data.name) + "</h2><p>" + esc(data.detail || "Preview unavailable") + "</p></div>";
1415
+ } catch (err) {
1416
+ target.innerHTML = '<div class="panel"><h2>Preview failed</h2><p>' + esc(err.message || String(err)) + "</p></div>";
1417
+ throw err;
1418
+ }
1419
+ }
1215
1420
  async function loadTasks() {
1216
1421
  setLoading("tasksList", "Loading tasks...");
1217
1422
  const [d, jobs] = await Promise.all([api("/api/tasks"), api("/api/jobs")]);
@@ -1296,6 +1501,29 @@
1296
1501
  ["Aborted", jobs.aborted]
1297
1502
  ];
1298
1503
  }
1504
+ function metricProcessRows(d) {
1505
+ const p = d.process || {};
1506
+ const memory = p.memory || {};
1507
+ const cpu = p.cpu || {};
1508
+ const loop = p.eventLoop || {};
1509
+ return [
1510
+ ["PID", p.pid],
1511
+ ["Node", p.nodeVersion],
1512
+ ["Platform", [p.platform, p.arch].filter(Boolean).join(" ")],
1513
+ ["Uptime", fmtDuration(p.uptimeMs)],
1514
+ ["Started", fmtDate(p.startedAt)],
1515
+ ["RSS", fmtBytes(memory.rssBytes || 0)],
1516
+ ["Heap used", fmtBytes(memory.heapUsedBytes || 0)],
1517
+ ["Heap total", fmtBytes(memory.heapTotalBytes || 0)],
1518
+ ["CPU total", fmtDuration(cpu.totalMs)],
1519
+ ["CPU avg", cpu.percentSinceStart === null || cpu.percentSinceStart === void 0 ? "-" : cpu.percentSinceStart + "%"],
1520
+ ["Event loop p95", formatMs(loop.delayP95Ms)],
1521
+ ["Event loop max", formatMs(loop.delayMaxMs)]
1522
+ ];
1523
+ }
1524
+ function formatMs(value) {
1525
+ return value === null || value === void 0 ? "-" : value + "ms";
1526
+ }
1299
1527
  function rateRows(name, rate) {
1300
1528
  return [
1301
1529
  ["Queued", rate?.queued ?? 0],
@@ -1311,7 +1539,7 @@
1311
1539
  }
1312
1540
  function renderMetrics(d) {
1313
1541
  const adapters = d.adapters || {};
1314
- document.getElementById("metricsPanel").innerHTML = '<div class="metrics-grid">' + card("Runtime", metricStatusRows(d)) + card("Jobs", metricJobRows(d)) + card("Telegram rate limits", rateRows("", adapters.telegram).map(([k, v]) => [String(k).trim(), v])) + card("Discord rate limits", rateRows("", adapters.discord).map(([k, v]) => [String(k).trim(), v])) + "</div>";
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>";
1315
1543
  }
1316
1544
  document.getElementById("reloadMetricsBtn").onclick = () => safe(loadMetrics);
1317
1545
  function activityQuery() {
@@ -1366,7 +1594,7 @@
1366
1594
  state.settings = data.settings;
1367
1595
  renderSettings();
1368
1596
  }
1369
- const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Voice", "Dashboard"];
1597
+ const settingsGroupOrder = ["Agents", "Codex", "Pi", "Hermes", "OpenClaw", "Claude Code", "Telegram", "Discord", "Operations", "Artifacts", "Workspace", "Peers", "Voice", "Dashboard"];
1370
1598
  const agentSettingGroups = ["Codex", "Pi", "Hermes", "OpenClaw", "Claude Code"];
1371
1599
  function orderedSettingsGroups(groups) {
1372
1600
  const known = settingsGroupOrder.filter((name) => groups[name]);
@@ -1873,6 +2101,97 @@
1873
2101
  toast("Cleared " + target + " log");
1874
2102
  }
1875
2103
  });
2104
+ async function loadPeers() {
2105
+ if (!can("peers.read")) {
2106
+ document.getElementById("peersList").innerHTML = '<div class="item">Permission required: peers.read</div>';
2107
+ return;
2108
+ }
2109
+ setLoading("peersList", "Loading peers...");
2110
+ const d = await api("/api/peers", { local: true });
2111
+ 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]]);
2113
+ 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>';
2115
+ bindPeerButtons();
2116
+ await loadPeerSelector();
2117
+ applyPermissions();
2118
+ }
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
+ function openPeerAddDialog() {
2174
+ 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
+ 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 });
2176
+ toast("Added peer " + (r.peer?.name || ""));
2177
+ await loadPeers();
2178
+ });
2179
+ }
2180
+ document.getElementById("loadPeersBtn").onclick = () => loadPeers();
2181
+ document.getElementById("createPeerInviteBtn").onclick = () => {
2182
+ if (!can("peers.write")) {
2183
+ toast("Permission required: peers.write");
2184
+ return;
2185
+ }
2186
+ openPeerInviteDialog();
2187
+ };
2188
+ document.getElementById("addPeerBtn").onclick = () => {
2189
+ if (!can("peers.write")) {
2190
+ toast("Permission required: peers.write");
2191
+ return;
2192
+ }
2193
+ openPeerAddDialog();
2194
+ };
1876
2195
  async function loadAdapterHealth() {
1877
2196
  setLoading("adapterHealth", "Loading adapters...");
1878
2197
  const d = await api("/api/adapters/health");
@@ -2035,13 +2354,28 @@
2035
2354
  document.getElementById("diagnostics").innerHTML = diagnosticsHtml(data);
2036
2355
  }
2037
2356
  const exportDiagnosticsBundleBtn = document.getElementById("exportDiagnosticsBundleBtn");
2038
- if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => {
2357
+ if (exportDiagnosticsBundleBtn) exportDiagnosticsBundleBtn.onclick = () => safe(async () => {
2039
2358
  if (!can("diagnostics.read")) {
2040
2359
  toast("Permission required: diagnostics.read");
2041
2360
  return;
2042
2361
  }
2043
- window.open("/api/diagnostics/bundle", "_blank");
2044
- };
2362
+ if (!state.selectedPeer || state.selectedPeer === "local") {
2363
+ window.open("/api/diagnostics/bundle", "_blank");
2364
+ return;
2365
+ }
2366
+ const bundle = await api("/api/diagnostics/bundle");
2367
+ downloadBase64(bundle.name || "nordrelay-support-bundle.zip", bundle.dataBase64 || "", bundle.mimeType || "application/zip");
2368
+ toast("Remote diagnostics bundle downloaded");
2369
+ });
2370
+ function downloadBase64(name, dataBase64, mimeType) {
2371
+ const bytes = Uint8Array.from(atob(dataBase64), (c) => c.charCodeAt(0));
2372
+ const blob = new Blob([bytes], { type: mimeType });
2373
+ const a = document.createElement("a");
2374
+ a.href = URL.createObjectURL(blob);
2375
+ a.download = name;
2376
+ a.click();
2377
+ URL.revokeObjectURL(a.href);
2378
+ }
2045
2379
  function diagnosticsHtml(d) {
2046
2380
  const h = d.health || {};
2047
2381
  const s = d.snapshot?.session || {};
@@ -2058,4 +2392,83 @@
2058
2392
  Promise.resolve().then(fn).catch((err) => toast(err.message || String(err)));
2059
2393
  }
2060
2394
  loadBootstrap().then(() => connectEvents()).catch((err) => toast(err.message));
2395
+ const loadPeersBase = loadPeers;
2396
+ loadPeers = async function() {
2397
+ await loadPeersBase();
2398
+ ensureGlobalPeerSessionsPanel();
2399
+ };
2400
+ function ensureGlobalPeerSessionsPanel() {
2401
+ if (document.getElementById("globalPeerSessionsPanel")) return;
2402
+ const anchor = document.getElementById("peersList");
2403
+ if (!anchor) return;
2404
+ anchor.insertAdjacentHTML("afterend", '<h2>Global sessions</h2><div id="globalPeerSessionsPanel" class="list"><div class="item"><div class="row"><input id="globalPeerSessionSearch" placeholder="Search sessions across peers"><button id="loadGlobalPeerSessionsBtn">Load global sessions</button></div><div id="globalPeerSessionsList"></div></div></div>');
2405
+ document.getElementById("loadGlobalPeerSessionsBtn").onclick = () => safe(loadGlobalPeerSessions);
2406
+ }
2407
+ async function loadGlobalPeerSessions() {
2408
+ const target = document.getElementById("globalPeerSessionsList");
2409
+ target.innerHTML = loadingHtml("Loading global sessions...");
2410
+ const q = document.getElementById("globalPeerSessionSearch").value || "";
2411
+ const d = await api("/api/peers/global-sessions", { local: true, query: { query: q, agent: state.snapshot?.session?.agentId || void 0, limit: 50 } });
2412
+ 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
+ }
2414
+ function peerCard(p) {
2415
+ const selected = state.selectedPeer === p.id ? ' <span class="chip">selected</span>' : "";
2416
+ 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
+ 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>";
2419
+ }
2420
+ function openPeerDialog(p) {
2421
+ const aliases = Object.entries(p.workspaceAliases || {}).map(([a, w]) => a + "=" + w).join(", ");
2422
+ adminDialog("Edit peer", '<label>Name<input id="dlgPeerName" value="' + attr(p.name || "") + '"></label><label>URL<input id="dlgPeerUrl" value="' + attr(p.url || "") + '"></label><label class="checkbox"><input id="dlgPeerEnabled" type="checkbox" ' + (p.enabled ? "checked" : "") + '> Enabled</label><label class="full-span">Scopes<input id="dlgPeerScopes" value="' + attr((p.scopes || []).join(", ")) + '"></label><label class="full-span">Allowed agents<input id="dlgPeerAgents" value="' + attr((p.allowedAgents || []).join(", ")) + '"></label><label class="full-span">Allowed workspace roots<input id="dlgPeerWorkspaces" value="' + attr((p.allowedWorkspaceRoots || []).join(", ")) + '"></label><label class="full-span">Workspace aliases<input id="dlgPeerAliases" placeholder="project=/srv/project, demo=/home/me/demo" value="' + attr(aliases) + '"></label>', async () => {
2423
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ name: val("dlgPeerName"), url: val("dlgPeerUrl"), enabled: document.getElementById("dlgPeerEnabled").checked, scopes: csvToList(val("dlgPeerScopes")), allowedAgents: csvToList(val("dlgPeerAgents")), allowedWorkspaceRoots: csvToList(val("dlgPeerWorkspaces")), workspaceAliases: aliasMap(val("dlgPeerAliases")) }), local: true });
2424
+ toast("Peer updated");
2425
+ await loadPeers();
2426
+ });
2427
+ }
2428
+ 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 () => {
2430
+ 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 });
2432
+ await loadPeers();
2433
+ });
2434
+ }
2435
+ function aliasMap(text) {
2436
+ return Object.fromEntries((text || "").split(",").map((item) => item.split("=", 2).map((part) => part.trim())).filter(([a, w]) => a && w));
2437
+ }
2438
+ function bindPeerButtons() {
2439
+ document.querySelectorAll("[data-peer-select]").forEach((b) => b.onclick = () => safe(async () => {
2440
+ state.selectedPeer = b.dataset.peerSelect || "local";
2441
+ localStorage.setItem("nordrelayPeerTarget", state.selectedPeer);
2442
+ connectEvents();
2443
+ await loadBootstrap();
2444
+ toast("Peer target selected");
2445
+ }));
2446
+ document.querySelectorAll("[data-peer-test]").forEach((b) => b.onclick = () => safe(async () => {
2447
+ const r = await api("/api/peers/" + encodeURIComponent(b.dataset.peerTest) + "/health", { local: true });
2448
+ toast("Peer reachable: " + (r.data?.version ? "v" + r.data.version : "ok"), { duration: 6e3 });
2449
+ loadPeers();
2450
+ }));
2451
+ document.querySelectorAll("[data-peer-edit]").forEach((b) => b.onclick = () => {
2452
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerEdit);
2453
+ if (p) openPeerDialog(p);
2454
+ });
2455
+ document.querySelectorAll("[data-peer-toggle]").forEach((b) => b.onclick = () => safe(async () => {
2456
+ const p = (state.peers?.peers || []).find((x) => x.id === b.dataset.peerToggle);
2457
+ if (!p) return;
2458
+ await api("/api/peers/" + encodeURIComponent(p.id), { method: "PATCH", body: JSON.stringify({ enabled: !p.enabled }), local: true });
2459
+ toast("Peer updated");
2460
+ loadPeers();
2461
+ }));
2462
+ document.querySelectorAll("[data-peer-revoke]").forEach((b) => b.onclick = () => safe(async () => {
2463
+ if (confirm("Revoke this peer?")) {
2464
+ await api("/api/peers/" + encodeURIComponent(b.dataset.peerRevoke), { method: "DELETE", local: true });
2465
+ if (state.selectedPeer === b.dataset.peerRevoke) {
2466
+ state.selectedPeer = "local";
2467
+ localStorage.setItem("nordrelayPeerTarget", "local");
2468
+ }
2469
+ toast("Peer revoked");
2470
+ loadPeers();
2471
+ }
2472
+ }));
2473
+ }
2061
2474
  })();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nordbyte/nordrelay",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Remote control plane for coding agents across messaging channels.",
5
5
  "type": "module",
6
6
  "author": "Ricardo",
@@ -61,11 +61,12 @@
61
61
  "webui:check": "tsc -p tsconfig.webui.json"
62
62
  },
63
63
  "dependencies": {
64
- "@anthropic-ai/claude-agent-sdk": "^0.2.140",
64
+ "@anthropic-ai/claude-agent-sdk": "^0.2.141",
65
65
  "@grammyjs/auto-retry": "^2.0.2",
66
66
  "@openai/codex-sdk": "^0.130.0",
67
67
  "discord.js": "^14.26.4",
68
68
  "grammy": "^1.41.1",
69
+ "selfsigned": "^3.0.1",
69
70
  "zod": "^4.4.3"
70
71
  },
71
72
  "devDependencies": {