@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
@@ -0,0 +1,98 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export async function peerPromptProxyPayload(prompt) {
4
+ if (typeof prompt.input === "string") {
5
+ return {
6
+ method: "POST",
7
+ path: "/api/prompt",
8
+ body: { text: prompt.input },
9
+ };
10
+ }
11
+ const files = await remoteUploadFiles(prompt);
12
+ if (files.length > 0) {
13
+ return {
14
+ method: "POST",
15
+ path: "/api/prompt/upload",
16
+ body: {
17
+ text: prompt.input.text ?? "",
18
+ files,
19
+ },
20
+ };
21
+ }
22
+ return {
23
+ method: "POST",
24
+ path: "/api/prompt",
25
+ body: { text: prompt.input.text || prompt.description },
26
+ };
27
+ }
28
+ async function remoteUploadFiles(prompt) {
29
+ if (typeof prompt.input === "string") {
30
+ return [];
31
+ }
32
+ const candidates = new Map();
33
+ for (const imagePath of prompt.input.imagePaths ?? []) {
34
+ candidates.set(imagePath, {
35
+ name: path.basename(imagePath),
36
+ mimeType: mimeTypeFromPath(imagePath),
37
+ localPath: imagePath,
38
+ });
39
+ }
40
+ for (const file of parseStagedFileInstructions(prompt.input.stagedFileInstructions)) {
41
+ candidates.set(file.localPath, file);
42
+ }
43
+ const files = [];
44
+ for (const file of candidates.values()) {
45
+ const data = await readFile(file.localPath);
46
+ files.push({
47
+ name: file.name,
48
+ mimeType: file.mimeType,
49
+ dataBase64: data.toString("base64"),
50
+ });
51
+ }
52
+ return files;
53
+ }
54
+ function parseStagedFileInstructions(text) {
55
+ if (!text) {
56
+ return [];
57
+ }
58
+ const files = [];
59
+ for (const line of text.split(/\r?\n/)) {
60
+ const match = line.match(/^- (.+?) \(([^,]+), [^)]+\) → (.+)$/);
61
+ if (!match)
62
+ continue;
63
+ files.push({
64
+ name: match[1] || path.basename(match[3] ?? "upload"),
65
+ mimeType: match[2],
66
+ localPath: match[3] ?? "",
67
+ });
68
+ }
69
+ return files.filter((file) => file.localPath);
70
+ }
71
+ function mimeTypeFromPath(filePath) {
72
+ const ext = path.extname(filePath).toLowerCase();
73
+ if (ext === ".jpg" || ext === ".jpeg")
74
+ return "image/jpeg";
75
+ if (ext === ".png")
76
+ return "image/png";
77
+ if (ext === ".gif")
78
+ return "image/gif";
79
+ if (ext === ".webp")
80
+ return "image/webp";
81
+ if (ext === ".pdf")
82
+ return "application/pdf";
83
+ if (ext === ".txt" || ext === ".md" || ext === ".log")
84
+ return "text/plain";
85
+ if (ext === ".json")
86
+ return "application/json";
87
+ if (ext === ".csv")
88
+ return "text/csv";
89
+ if (ext === ".mp3")
90
+ return "audio/mpeg";
91
+ if (ext === ".wav")
92
+ return "audio/wav";
93
+ if (ext === ".ogg")
94
+ return "audio/ogg";
95
+ if (ext === ".webm")
96
+ return "audio/webm";
97
+ return undefined;
98
+ }
@@ -1,55 +1,5 @@
1
+ import { telegramCommandCatalog } from "./channel-command-catalog.js";
2
+ export const TELEGRAM_COMMANDS = telegramCommandCatalog();
1
3
  export async function registerCommands(bot) {
2
- await bot.api.setMyCommands([
3
- { command: "start", description: "Welcome & status" },
4
- { command: "help", description: "Command reference" },
5
- { command: "link", description: "Link Telegram to NordRelay user" },
6
- { command: "whoami", description: "Show your NordRelay user" },
7
- { command: "register_chat", description: "Admin: enable this group chat" },
8
- { command: "channels", description: "Messaging adapter status" },
9
- { command: "agents", description: "Agent adapter status" },
10
- { command: "agent", description: "Select agent" },
11
- { command: "new", description: "Start a new thread" },
12
- { command: "session", description: "Current thread details" },
13
- { command: "sessions", description: "Browse & switch threads" },
14
- { command: "sync", description: "Sync active session from CLI state" },
15
- { command: "pinned", description: "Show pinned threads" },
16
- { command: "pin", description: "Pin current or given thread" },
17
- { command: "unpin", description: "Unpin current or given thread" },
18
- { command: "retry", description: "Resend the last prompt" },
19
- { command: "queue", description: "Show queued prompts" },
20
- { command: "cancel", description: "Cancel a queued prompt" },
21
- { command: "clearqueue", description: "Clear queued prompts" },
22
- { command: "artifacts", description: "List or resend generated files" },
23
- { command: "workspaces", description: "List allowed workspaces" },
24
- { command: "abort", description: "Cancel current operation" },
25
- { command: "stop", description: "Cancel current operation" },
26
- { command: "launch_profiles", description: "Select launch profile" },
27
- { command: "fast", description: "Toggle fast mode" },
28
- { command: "model", description: "View & change model" },
29
- { command: "reasoning", description: "Set reasoning effort" },
30
- { command: "mirror", description: "Control CLI mirroring" },
31
- { command: "notify", description: "Control notifications" },
32
- { command: "auth", description: "Check auth status" },
33
- { command: "login", description: "Start authentication" },
34
- { command: "logout", description: "Sign out" },
35
- { command: "voice", description: "Voice transcription status" },
36
- { command: "tasks", description: "Current turn progress" },
37
- { command: "progress", description: "Current turn progress" },
38
- { command: "activity", description: "Thread activity timeline" },
39
- { command: "audit", description: "Admin: recent audit events" },
40
- { command: "status", description: "Connector runtime status" },
41
- { command: "health", description: "Connector health report" },
42
- { command: "version", description: "Connector version" },
43
- { command: "logs", description: "Admin: show connector logs" },
44
- { command: "diagnostics", description: "Admin: connector diagnostics" },
45
- { command: "support", description: "Admin: export diagnostics bundle" },
46
- { command: "lock", description: "Lock session writes to you" },
47
- { command: "unlock", description: "Release session write lock" },
48
- { command: "locks", description: "List session write locks" },
49
- { command: "restart", description: "Admin: restart connector" },
50
- { command: "update", description: "Admin: update connector or agents" },
51
- { command: "handback", description: "Hand session back to CLI" },
52
- { command: "attach", description: "Bind a session to this topic" },
53
- { command: "switch", description: "Switch to a thread by ID" },
54
- ]);
4
+ await bot.api.setMyCommands([...TELEGRAM_COMMANDS]);
55
5
  }
@@ -37,6 +37,20 @@ export function registerTelegramGeneralCommands(options) {
37
37
  options.bot.command("agents", async (ctx) => {
38
38
  await options.replyChannelAction(ctx, options.commandService.renderAgents());
39
39
  });
40
+ options.bot.command("peers", async (ctx) => {
41
+ await options.replyChannelAction(ctx, options.commandService.renderPeers());
42
+ });
43
+ options.bot.command("target", async (ctx) => {
44
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
45
+ if (!contextSession)
46
+ return;
47
+ await options.replyChannelAction(ctx, options.commandService.renderTargetPreference({
48
+ source: "telegram",
49
+ contextKey: contextSession.contextKey,
50
+ argument: ctx.match?.toString() ?? "",
51
+ preferencesStore: options.preferencesStore,
52
+ }));
53
+ });
40
54
  options.bot.command("restart", async (ctx) => {
41
55
  await safeReply(ctx, escapeHTML("Restarting connector..."), {
42
56
  fallbackText: "Restarting connector...",
@@ -1,8 +1,5 @@
1
- import { formatQuietHours, isQuietNow, parseMirrorMode, parseNotifyMode, parseQuietHours, parseVoiceBackendPreference, } from "./bot-preferences.js";
2
- import { capabilitiesOf, idOf, labelOf, parseToggle, } from "./bot-rendering.js";
3
- import { friendlyErrorText } from "./error-messages.js";
1
+ import { capabilitiesOf, labelOf, } from "./bot-rendering.js";
4
2
  import { escapeHTML } from "./format.js";
5
- import { getAvailableBackends } from "./voice.js";
6
3
  import { evaluateWorkspacePolicy, filterAllowedWorkspaces, renderWorkspacePolicyLine, } from "./workspace-policy.js";
7
4
  import { safeReply } from "./telegram-output.js";
8
5
  export function registerTelegramPreferenceCommands(options) {
@@ -18,28 +15,15 @@ export function registerTelegramPreferenceCommands(options) {
18
15
  return;
19
16
  }
20
17
  const argument = (ctx.message?.text ?? "").replace(/^\/mirror(?:@\w+)?\s*/i, "").trim();
21
- if (argument) {
22
- const mode = parseMirrorMode(argument, options.getEffectiveMirrorMode(contextKey));
23
- if (!["off", "status", "final", "full"].includes(argument.toLowerCase())) {
24
- await safeReply(ctx, escapeHTML("Usage: /mirror [off|status|final|full]"), {
25
- fallbackText: "Usage: /mirror [off|status|final|full]",
26
- });
27
- return;
28
- }
29
- options.preferencesStore.update(contextKey, { mirrorMode: mode });
30
- }
31
- const mode = options.getEffectiveMirrorMode(contextKey);
32
- const plain = [
33
- `CLI mirroring: ${mode}`,
34
- `Minimum update interval: ${options.config.telegramMirrorMinUpdateMs} ms`,
35
- "Modes: off, status, final, full",
36
- ].join("\n");
37
- const html = [
38
- `<b>CLI mirroring:</b> <code>${escapeHTML(mode)}</code>`,
39
- `<b>Minimum update interval:</b> <code>${options.config.telegramMirrorMinUpdateMs} ms</code>`,
40
- "<b>Modes:</b> <code>off</code>, <code>status</code>, <code>final</code>, <code>full</code>",
41
- ].join("\n");
42
- await safeReply(ctx, html, { fallbackText: plain });
18
+ const response = options.commandService.renderMirrorPreference({
19
+ source: "telegram",
20
+ contextKey,
21
+ argument,
22
+ preferencesStore: options.preferencesStore,
23
+ cliMirrorSupported: capabilitiesOf(session.getInfo()).cliMirror,
24
+ agentLabel: labelOf(session.getInfo()),
25
+ });
26
+ await safeReply(ctx, response.html, { fallbackText: response.plain });
43
27
  });
44
28
  options.bot.command("notify", async (ctx) => {
45
29
  const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
@@ -48,45 +32,13 @@ export function registerTelegramPreferenceCommands(options) {
48
32
  }
49
33
  const { contextKey } = contextSession;
50
34
  const argument = (ctx.message?.text ?? "").replace(/^\/notify(?:@\w+)?\s*/i, "").trim();
51
- if (argument) {
52
- const quietMatch = argument.match(/^quiet\s+(.+)$/i);
53
- if (quietMatch) {
54
- let quietHours;
55
- try {
56
- quietHours = quietMatch[1].toLowerCase() === "off" ? null : parseQuietHours(quietMatch[1]);
57
- }
58
- catch (error) {
59
- await safeReply(ctx, escapeHTML(`Invalid quiet hours: ${friendlyErrorText(error)}`), {
60
- fallbackText: `Invalid quiet hours: ${friendlyErrorText(error)}`,
61
- });
62
- return;
63
- }
64
- options.preferencesStore.update(contextKey, { quietHours });
65
- }
66
- else {
67
- const mode = parseNotifyMode(argument, options.getEffectiveNotifyMode(contextKey));
68
- if (!["off", "minimal", "all"].includes(argument.toLowerCase())) {
69
- await safeReply(ctx, escapeHTML("Usage: /notify [off|minimal|all] or /notify quiet HH-HH"), {
70
- fallbackText: "Usage: /notify [off|minimal|all] or /notify quiet HH-HH",
71
- });
72
- return;
73
- }
74
- options.preferencesStore.update(contextKey, { notifyMode: mode });
75
- }
76
- }
77
- const mode = options.getEffectiveNotifyMode(contextKey);
78
- const quietHours = options.getEffectiveQuietHours(contextKey);
79
- const plain = [
80
- `Notifications: ${mode}`,
81
- `Quiet hours: ${formatQuietHours(quietHours)}`,
82
- `Currently quiet: ${isQuietNow(quietHours) ? "yes" : "no"}`,
83
- ].join("\n");
84
- const html = [
85
- `<b>Notifications:</b> <code>${escapeHTML(mode)}</code>`,
86
- `<b>Quiet hours:</b> <code>${escapeHTML(formatQuietHours(quietHours))}</code>`,
87
- `<b>Currently quiet:</b> <code>${isQuietNow(quietHours) ? "yes" : "no"}</code>`,
88
- ].join("\n");
89
- await safeReply(ctx, html, { fallbackText: plain });
35
+ const response = options.commandService.renderNotifyPreference({
36
+ source: "telegram",
37
+ contextKey,
38
+ argument,
39
+ preferencesStore: options.preferencesStore,
40
+ });
41
+ await safeReply(ctx, response.html, { fallbackText: response.plain });
90
42
  });
91
43
  options.bot.command("workspaces", async (ctx) => {
92
44
  const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
@@ -131,68 +83,12 @@ export function registerTelegramPreferenceCommands(options) {
131
83
  }
132
84
  const { contextKey } = contextSession;
133
85
  const argument = (ctx.message?.text ?? "").replace(/^\/voice(?:@\w+)?\s*/i, "").trim();
134
- if (argument) {
135
- const parts = argument.split(/\s+/);
136
- const key = parts[0]?.toLowerCase();
137
- const value = parts.slice(1).join(" ").trim();
138
- if (key === "backend" && value) {
139
- options.preferencesStore.update(contextKey, { voiceBackend: parseVoiceBackendPreference(value) });
140
- }
141
- else if (key === "language") {
142
- options.preferencesStore.update(contextKey, { voiceLanguage: value && value.toLowerCase() !== "auto" ? value : null });
143
- }
144
- else if (key === "transcribe_only" || key === "transcribe-only") {
145
- const enabled = parseToggle(value);
146
- if (enabled === undefined) {
147
- await safeReply(ctx, escapeHTML("Usage: /voice transcribe_only on|off"), {
148
- fallbackText: "Usage: /voice transcribe_only on|off",
149
- });
150
- return;
151
- }
152
- options.preferencesStore.update(contextKey, { voiceTranscribeOnly: enabled });
153
- }
154
- else {
155
- await safeReply(ctx, escapeHTML("Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off"), {
156
- fallbackText: "Usage: /voice, /voice backend auto|parakeet|faster-whisper|openai, /voice language auto|<code>, /voice transcribe_only on|off",
157
- });
158
- return;
159
- }
160
- }
161
- const backends = await getAvailableBackends().catch(() => []);
162
- if (backends.length === 0) {
163
- await safeReply(ctx, [
164
- "<b>Voice transcription is not available.</b>",
165
- "",
166
- "Install <code>faster-whisper</code> + ffmpeg, install <code>parakeet-coreml</code> on macOS Apple Silicon, or set <code>OPENAI_API_KEY</code>.",
167
- "<i>Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.</i>",
168
- ].join("\n"), {
169
- fallbackText: [
170
- "Voice transcription is not available.",
171
- "",
172
- "Install faster-whisper + ffmpeg, install parakeet-coreml on macOS Apple Silicon, or set OPENAI_API_KEY.",
173
- "Cloud transcription uses OPENAI_API_KEY, not CODEX_API_KEY.",
174
- ].join("\n"),
175
- });
176
- return;
177
- }
178
- const joined = backends.join(" + ");
179
- const backendPreference = options.getEffectiveVoiceBackend(contextKey);
180
- const language = options.getEffectiveVoiceLanguage(contextKey);
181
- const transcribeOnly = options.isVoiceTranscribeOnly(contextKey);
182
- const plain = [
183
- `Voice backends: ${joined}`,
184
- `Preferred backend: ${backendPreference}`,
185
- `Language: ${language ?? "auto"}`,
186
- `Transcribe only: ${transcribeOnly ? "on" : "off"}`,
187
- ].join("\n");
188
- const html = [
189
- `<b>Voice backends:</b> <code>${escapeHTML(joined)}</code>`,
190
- `<b>Preferred backend:</b> <code>${escapeHTML(backendPreference)}</code>`,
191
- `<b>Language:</b> <code>${escapeHTML(language ?? "auto")}</code>`,
192
- `<b>Transcribe only:</b> <code>${transcribeOnly ? "on" : "off"}</code>`,
193
- ].join("\n");
194
- await safeReply(ctx, html, {
195
- fallbackText: plain,
86
+ const response = await options.commandService.renderVoicePreference({
87
+ source: "telegram",
88
+ contextKey,
89
+ argument,
90
+ preferencesStore: options.preferencesStore,
196
91
  });
92
+ await safeReply(ctx, response.html, { fallbackText: response.plain });
197
93
  });
198
94
  }
@@ -20,6 +20,14 @@ export const WEB_API_ROUTE_DEFINITIONS = [
20
20
  dynamic("/api/agent-update/:id/input", "^/api/agent-update/[^/]+/input$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/input`),
21
21
  dynamic("/api/agent-update/:id/cancel", "^/api/agent-update/[^/]+/cancel$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/cancel`),
22
22
  exact("/api/adapters/health", ["GET"], "inspect"),
23
+ exact("/api/peers", ["GET", "POST"], readWrite("peers.read", "peers.write")),
24
+ exact("/api/peers/invite", ["POST"], "peers.write"),
25
+ exact("/api/peers/pair", ["POST"], "peers.write"),
26
+ exact("/api/peers/global-sessions", ["GET"], "sessions.read"),
27
+ dynamic("/api/peers/:id/health", "^/api/peers/[^/]+/health$", ["GET"], "peers.connect", `/api/peers/${stringToken}/health`),
28
+ dynamic("/api/peers/:id", "^/api/peers/[^/]+$", ["PATCH", "DELETE"], "peers.write", `/api/peers/${stringToken}`),
29
+ dynamic("/api/peers/:id/proxy", "^/api/peers/[^/]+/proxy$", ["POST"], "peers.connect", `/api/peers/${stringToken}/proxy`),
30
+ dynamic("/api/peers/:id/events", "^/api/peers/[^/]+/events$", ["GET"], "peers.connect", `/api/peers/${stringToken}/events`),
23
31
  exact("/api/permissions", ["GET"], "users.read"),
24
32
  exact("/api/users", ["GET", "POST"], readWrite("users.read", "users.write")),
25
33
  dynamic("/api/users/:id", "^/api/users/[^/]+$", ["PATCH"], "users.write", `/api/users/${stringToken}`),
@@ -123,6 +123,7 @@ export function renderDashboardApp() {
123
123
  </div>
124
124
  <div class="header-actions">
125
125
  <span id="connectionStatus" class="badge">Connecting</span>
126
+ <select id="peerSelect" title="NordRelay target"></select>
126
127
  <select id="agentSelect"></select>
127
128
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
128
129
  <button id="refreshBtn">Refresh</button>
@@ -228,6 +229,17 @@ export function renderDashboardApp() {
228
229
  </div>
229
230
  </section>
230
231
 
232
+ <section class="page" id="page-peers">
233
+ <div class="panel">
234
+ <div class="row"><button id="loadPeersBtn">Reload peers</button><button id="createPeerInviteBtn">Create invite</button><button id="addPeerBtn" class="secondary">Add peer</button></div>
235
+ <div id="peerStatus" class="list"></div>
236
+ <h2>Configured peers</h2>
237
+ <div id="peersList" class="list"></div>
238
+ <h2>Open invitations</h2>
239
+ <div id="peerInvites" class="list"></div>
240
+ </div>
241
+ </section>
242
+
231
243
  <section class="page" id="page-access">
232
244
  <div class="panel">
233
245
  <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
@@ -0,0 +1,204 @@
1
+ import { isPermission } from "./access-control.js";
2
+ import { AGENT_IDS, isAgentId } from "./agent.js";
3
+ import { ensurePeerTlsFiles, loadOrCreatePeerIdentity, } from "./peer-identity.js";
4
+ import { pairPeer, RemoteRelayClient } from "./peer-client.js";
5
+ import { PeerStore } from "./peer-store.js";
6
+ import { publicPeer } from "./peer-types.js";
7
+ import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
8
+ export async function handleDashboardPeerRoute(req, res, url, options) {
9
+ const store = new PeerStore(options.home);
10
+ const identity = loadOrCreatePeerIdentity(options.home, options.config.peerName);
11
+ const tls = options.config.peerTlsEnabled ? ensurePeerTlsFiles(options.home, identity.public) : null;
12
+ if (req.method === "GET" && url.pathname === "/api/peers") {
13
+ sendJson(res, 200, store.snapshot(identity.public, {
14
+ enabled: options.config.peerEnabled,
15
+ listenUrl: peerListenUrl(options.config),
16
+ requireTls: options.config.peerRequireTls,
17
+ }));
18
+ return true;
19
+ }
20
+ if (req.method === "POST" && url.pathname === "/api/peers/invite") {
21
+ const body = await readJsonBody(req);
22
+ const created = store.createInvitation({
23
+ name: optionalStringField(body, "name"),
24
+ expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
25
+ scopes: parseScopes(arrayStringField(body, "scopes")),
26
+ allowedAgents: parseAgents(arrayStringField(body, "allowedAgents")),
27
+ allowedWorkspaceRoots: arrayStringField(body, "allowedWorkspaceRoots"),
28
+ workspaceAliases: parseWorkspaceAliases(body.workspaceAliases),
29
+ });
30
+ const listenUrl = peerListenUrl(options.config);
31
+ const command = `nordrelay peer add ${listenUrl} --code ${created.code}`;
32
+ sendJson(res, 201, {
33
+ invitation: created.invitation,
34
+ code: created.code,
35
+ url: listenUrl,
36
+ fingerprint: identity.public.fingerprint,
37
+ tlsFingerprint: tls?.fingerprint,
38
+ command,
39
+ });
40
+ options.auditPeerAction?.("peer_invite_created", created.invitation.name);
41
+ return true;
42
+ }
43
+ if (req.method === "POST" && (url.pathname === "/api/peers" || url.pathname === "/api/peers/pair")) {
44
+ const body = await readJsonBody(req);
45
+ const result = await pairPeer({
46
+ url: requiredString(body, "url"),
47
+ code: requiredString(body, "code"),
48
+ name: optionalStringField(body, "name"),
49
+ publicUrl: optionalStringField(body, "publicUrl"),
50
+ }, identity, store);
51
+ sendJson(res, 201, { peer: publicPeer(result.peer), tlsFingerprint: result.tlsFingerprint });
52
+ options.auditPeerAction?.("peer_paired", `${result.peer.name} (${result.peer.id})`);
53
+ return true;
54
+ }
55
+ const peerMatch = url.pathname.match(/^\/api\/peers\/([^/]+)$/);
56
+ if (peerMatch?.[1] && req.method === "PATCH") {
57
+ const body = await readJsonBody(req);
58
+ const peer = store.updatePeer(decodeURIComponent(peerMatch[1]), {
59
+ name: optionalStringField(body, "name"),
60
+ url: optionalStringField(body, "url"),
61
+ enabled: optionalBooleanField(body, "enabled"),
62
+ scopes: body.scopes === undefined ? undefined : parseScopes(arrayStringField(body, "scopes")),
63
+ allowedAgents: body.allowedAgents === undefined ? undefined : parseAgents(arrayStringField(body, "allowedAgents")),
64
+ allowedWorkspaceRoots: body.allowedWorkspaceRoots === undefined ? undefined : arrayStringField(body, "allowedWorkspaceRoots"),
65
+ workspaceAliases: body.workspaceAliases === undefined ? undefined : parseWorkspaceAliases(body.workspaceAliases),
66
+ });
67
+ sendJson(res, 200, { peer: publicPeer(peer) });
68
+ options.auditPeerAction?.("peer_updated", `${peer.name} (${peer.id})`);
69
+ return true;
70
+ }
71
+ if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
72
+ const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
73
+ const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
74
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50), 1), 50);
75
+ const client = new RemoteRelayClient(store);
76
+ const targets = [];
77
+ if (options.runtime) {
78
+ targets.push({
79
+ peerId: "local",
80
+ peerName: "Local",
81
+ ok: true,
82
+ data: await options.runtime.listSessionsPage(1, limit, query, agent),
83
+ });
84
+ }
85
+ const peers = store.listPublic().filter((peer) => peer.enabled && peer.url);
86
+ const remoteTargets = await Promise.all(peers.map(async (peer) => {
87
+ try {
88
+ const data = await client.webProxy(peer.id, {
89
+ method: "GET",
90
+ path: "/api/sessions",
91
+ query: { query, page: 1, limit, agent },
92
+ body: {},
93
+ contextKey: "web:dashboard",
94
+ }, options.activityActor, "web:dashboard");
95
+ return { peerId: peer.id, peerName: peer.name, ok: true, data };
96
+ }
97
+ catch (error) {
98
+ return { peerId: peer.id, peerName: peer.name, ok: false, error: error instanceof Error ? error.message : String(error) };
99
+ }
100
+ }));
101
+ sendJson(res, 200, { targets: [...targets, ...remoteTargets] });
102
+ return true;
103
+ }
104
+ if (peerMatch?.[1] && req.method === "DELETE") {
105
+ const peerId = decodeURIComponent(peerMatch[1]);
106
+ const removed = store.revokePeer(peerId);
107
+ sendJson(res, 200, { removed });
108
+ if (removed) {
109
+ options.auditPeerAction?.("peer_revoked", peerId);
110
+ }
111
+ return true;
112
+ }
113
+ const proxyMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/proxy$/);
114
+ if (proxyMatch?.[1] && req.method === "POST") {
115
+ const body = await readJsonBody(req);
116
+ const payload = parseProxyPayload(body);
117
+ const data = await new RemoteRelayClient(store).webProxy(decodeURIComponent(proxyMatch[1]), payload, options.activityActor, payload.contextKey);
118
+ sendJson(res, 200, data);
119
+ return true;
120
+ }
121
+ const healthMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/health$/);
122
+ if (healthMatch?.[1] && req.method === "GET") {
123
+ const peerId = decodeURIComponent(healthMatch[1]);
124
+ const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
125
+ sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
126
+ return true;
127
+ }
128
+ const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
129
+ if (eventsMatch?.[1] && req.method === "GET") {
130
+ res.writeHead(200, {
131
+ "content-type": "text/event-stream; charset=utf-8",
132
+ "cache-control": "no-cache, no-transform",
133
+ connection: "keep-alive",
134
+ });
135
+ const sourceContextKey = url.searchParams.get("contextKey") || undefined;
136
+ const subscription = new RemoteRelayClient(store).subscribe(decodeURIComponent(eventsMatch[1]), (event) => {
137
+ if (res.destroyed || res.writableEnded)
138
+ return;
139
+ res.write(`event: ${event.type}\n`);
140
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
141
+ }, (error) => {
142
+ if (!res.destroyed && !res.writableEnded) {
143
+ res.write("event: status\n");
144
+ res.write(`data: ${JSON.stringify({ type: "status", level: "error", message: error.message, at: new Date().toISOString() })}\n\n`);
145
+ }
146
+ }, sourceContextKey);
147
+ const heartbeat = setInterval(() => {
148
+ if (!res.destroyed && !res.writableEnded)
149
+ res.write(": heartbeat\n\n");
150
+ }, 25_000);
151
+ heartbeat.unref?.();
152
+ req.on("close", () => {
153
+ clearInterval(heartbeat);
154
+ subscription.close();
155
+ });
156
+ return true;
157
+ }
158
+ return false;
159
+ }
160
+ function peerListenUrl(config) {
161
+ if (config.peerPublicUrl)
162
+ return config.peerPublicUrl;
163
+ const scheme = config.peerTlsEnabled ? "https" : "http";
164
+ const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
165
+ const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
166
+ return `${scheme}://${displayHost}:${config.peerPort}`;
167
+ }
168
+ function parseScopes(values) {
169
+ return values.filter(isPermission);
170
+ }
171
+ function parseAgents(values) {
172
+ const parsed = values.filter(isAgentId);
173
+ return parsed.length > 0 ? parsed : [...AGENT_IDS];
174
+ }
175
+ function parseAgent(value) {
176
+ return value && isAgentId(value) ? value : undefined;
177
+ }
178
+ function parseProxyPayload(body) {
179
+ return {
180
+ method: requiredString(body, "method"),
181
+ path: requiredString(body, "path"),
182
+ query: objectRecord(body.query),
183
+ body: objectRecord(body.body),
184
+ contextKey: optionalStringField(body, "contextKey"),
185
+ };
186
+ }
187
+ function parseWorkspaceAliases(value) {
188
+ if (typeof value === "string") {
189
+ return Object.fromEntries(value.split(",").map((item) => {
190
+ const [alias, workspace] = item.split("=", 2);
191
+ return [alias?.trim() ?? "", workspace?.trim() ?? ""];
192
+ }).filter(([alias, workspace]) => alias && workspace));
193
+ }
194
+ const record = objectRecord(value);
195
+ return Object.fromEntries(Object.entries(record).filter((entry) => typeof entry[1] === "string" && entry[0].trim().length > 0 && entry[1].trim().length > 0));
196
+ }
197
+ function requiredString(body, key) {
198
+ const value = body[key];
199
+ const text = typeof value === "string" ? value.trim() : "";
200
+ if (!text) {
201
+ throw new Error(`${key} is required.`);
202
+ }
203
+ return text;
204
+ }
@@ -8,6 +8,7 @@ export const DASHBOARD_PAGES = [
8
8
  { id: "activity", label: "Activity", permission: "sessions.read" },
9
9
  { id: "artifacts", label: "Artifacts", permission: "files.read" },
10
10
  { id: "adapters", label: "Adapters", permission: "inspect" },
11
+ { id: "peers", label: "Peers", permission: "peers.read" },
11
12
  { id: "access", label: "Users", permission: "users.read" },
12
13
  { id: "version", label: "Version", permission: "inspect" },
13
14
  { id: "settings", label: "Settings", permission: "settings.read" },
@@ -21,6 +21,7 @@ import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson
21
21
  import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
22
22
  import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
23
23
  import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
24
+ import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
24
25
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
25
26
  const options = parseOptions(process.argv.slice(2));
26
27
  const config = loadConfig();
@@ -180,6 +181,15 @@ async function handleApi(req, res, url, authUser) {
180
181
  })) {
181
182
  return;
182
183
  }
184
+ if (await handleDashboardPeerRoute(req, res, url, {
185
+ config,
186
+ home: options.home,
187
+ runtime,
188
+ activityActor: webActivityActor(authUser),
189
+ auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
190
+ })) {
191
+ return;
192
+ }
183
193
  if (req.method === "GET" && url.pathname === "/api/settings") {
184
194
  sendJson(res, 200, await settings.snapshot(process.env, activeSettingsValues(config)));
185
195
  return;
@@ -494,6 +504,8 @@ async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => can
494
504
  return canUseSession(authUser, event.session) ? event : null;
495
505
  case "activity_update":
496
506
  return { ...event, events: filterActivityByScope(authUser, event.events) };
507
+ case "active_sessions_update":
508
+ return { ...event, active: scopedActiveSessions(authUser, event.active) };
497
509
  case "agent_update":
498
510
  return users.canUseAgent(authUser, event.job.agentId) ? event : null;
499
511
  case "status":