@nordbyte/nordrelay 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/.env.example +52 -0
  2. package/README.md +171 -50
  3. package/dist/access-control.js +6 -1
  4. package/dist/activity-events.js +2 -2
  5. package/dist/adapter-conformance.js +61 -0
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot.js +95 -37
  8. package/dist/channel-adapter.js +44 -11
  9. package/dist/channel-command-catalog.js +94 -0
  10. package/dist/channel-command-core.js +60 -0
  11. package/dist/channel-command-service.js +230 -1
  12. package/dist/channel-mirror-registry.js +84 -0
  13. package/dist/channel-peer-prompt.js +95 -0
  14. package/dist/channel-prompt-engine.js +177 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-lifecycle.js +73 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +82 -8
  19. package/dist/config.js +79 -7
  20. package/dist/context-key.js +42 -0
  21. package/dist/discord-bot.js +173 -342
  22. package/dist/discord-command-surface.js +11 -73
  23. package/dist/index.js +29 -0
  24. package/dist/metrics.js +48 -0
  25. package/dist/peer-auth.js +85 -0
  26. package/dist/peer-client.js +288 -0
  27. package/dist/peer-context.js +21 -0
  28. package/dist/peer-identity.js +127 -0
  29. package/dist/peer-readiness.js +77 -0
  30. package/dist/peer-runtime-service.js +658 -0
  31. package/dist/peer-server.js +220 -0
  32. package/dist/peer-store.js +307 -0
  33. package/dist/peer-types.js +52 -0
  34. package/dist/relay-runtime-helpers.js +210 -0
  35. package/dist/relay-runtime.js +79 -274
  36. package/dist/remote-prompt.js +98 -0
  37. package/dist/settings-wizard-test.js +216 -0
  38. package/dist/slack-artifacts.js +165 -0
  39. package/dist/slack-bot.js +1461 -0
  40. package/dist/slack-channel-runtime.js +147 -0
  41. package/dist/slack-command-surface.js +46 -0
  42. package/dist/slack-diagnostics.js +116 -0
  43. package/dist/slack-rate-limit.js +139 -0
  44. package/dist/telegram-command-menu.js +3 -53
  45. package/dist/telegram-general-commands.js +14 -0
  46. package/dist/telegram-preference-commands.js +23 -127
  47. package/dist/user-management-crypto.js +38 -0
  48. package/dist/user-management-normalize.js +188 -0
  49. package/dist/user-management-types.js +1 -0
  50. package/dist/user-management.js +193 -196
  51. package/dist/web-api-contract.js +16 -0
  52. package/dist/web-dashboard-access-routes.js +62 -0
  53. package/dist/web-dashboard-assets.js +1 -0
  54. package/dist/web-dashboard-pages.js +26 -4
  55. package/dist/web-dashboard-peer-routes.js +225 -0
  56. package/dist/web-dashboard-ui.js +1 -0
  57. package/dist/web-dashboard.js +46 -0
  58. package/dist/web-state.js +2 -2
  59. package/dist/webui-assets/dashboard.css +193 -0
  60. package/dist/webui-assets/dashboard.js +870 -57
  61. package/package.json +5 -2
  62. package/plugins/nordrelay/scripts/nordrelay.mjs +468 -11
@@ -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>
@@ -208,7 +209,7 @@ export function renderDashboardApp() {
208
209
 
209
210
  <section class="page" id="page-activity">
210
211
  <div class="panel">
211
- <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
212
+ <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
212
213
  <div id="activityList" class="list"></div>
213
214
  </div>
214
215
  </section>
@@ -225,17 +226,31 @@ export function renderDashboardApp() {
225
226
  <div class="panel">
226
227
  <div class="row"><button id="reloadAdaptersBtn">Reload adapters</button></div>
227
228
  <div id="adapterHealth" class="list"></div>
229
+ <h2 class="task-section-title">Adapter Conformance</h2>
230
+ <div id="adapterConformance" class="list"></div>
231
+ </div>
232
+ </section>
233
+
234
+ <section class="page" id="page-peers">
235
+ <div class="panel">
236
+ <div class="row"><button id="loadPeersBtn">Reload peers</button><button id="createPeerInviteBtn">Create invite</button><button id="addPeerBtn" class="secondary">Add peer</button></div>
237
+ <div id="peerStatus" class="list"></div>
238
+ <h2>Configured peers</h2>
239
+ <div id="peersList" class="list"></div>
240
+ <h2>Open invitations</h2>
241
+ <div id="peerInvites" class="list"></div>
228
242
  </div>
229
243
  </section>
230
244
 
231
245
  <section class="page" id="page-access">
232
246
  <div class="panel">
233
- <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>
247
+ <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="createSlackChannelBtn" class="secondary">Add Slack channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
234
248
  <div id="accessTabs" class="tabs access-tabs">
235
249
  <button type="button" data-access-tab="users" class="active">Users</button>
236
250
  <button type="button" data-access-tab="groups">Groups</button>
237
251
  <button type="button" data-access-tab="telegram">Telegram</button>
238
252
  <button type="button" data-access-tab="discord">Discord</button>
253
+ <button type="button" data-access-tab="slack">Slack</button>
239
254
  <button type="button" data-access-tab="locks">Locks</button>
240
255
  <button type="button" data-access-tab="audit">Audit</button>
241
256
  </div>
@@ -257,13 +272,20 @@ export function renderDashboardApp() {
257
272
  </div>
258
273
  <div id="discordChannelsList" class="list"></div>
259
274
  </div>
275
+ <div class="access-tab" data-access-tab-panel="slack">
276
+ <div class="access-tab-heading">
277
+ <h2>Slack channels</h2>
278
+ <input id="slackChannelSearch" placeholder="Search Slack channels">
279
+ </div>
280
+ <div id="slackChannelsList" class="list"></div>
281
+ </div>
260
282
  <div class="access-tab" data-access-tab-panel="locks">
261
283
  <h2>Locks</h2>
262
284
  <div id="locksList" class="list"></div>
263
285
  </div>
264
286
  <div class="access-tab" data-access-tab-panel="audit">
265
287
  <h2>Audit</h2>
266
- <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
288
+ <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
267
289
  <div id="auditList" class="list"></div>
268
290
  </div>
269
291
  </div>
@@ -280,7 +302,7 @@ export function renderDashboardApp() {
280
302
 
281
303
  <section class="page" id="page-settings">
282
304
  <div class="panel">
283
- <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
305
+ <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
284
306
  <div id="settingsTabs" class="tabs"></div>
285
307
  <div id="settingsForm" class="settings-grid"></div>
286
308
  </div>
@@ -0,0 +1,225 @@
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 { checkPeerEndpoint, pairPeer, RemoteRelayClient } from "./peer-client.js";
5
+ import { buildPeerReadiness, peerListenUrl } from "./peer-readiness.js";
6
+ import { PeerStore } from "./peer-store.js";
7
+ import { publicPeer } from "./peer-types.js";
8
+ import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
9
+ export async function handleDashboardPeerRoute(req, res, url, options) {
10
+ const store = new PeerStore(options.home);
11
+ const identity = loadOrCreatePeerIdentity(options.home, options.config.peerName);
12
+ const tls = options.config.peerTlsEnabled ? ensurePeerTlsFiles(options.home, identity.public) : null;
13
+ if (req.method === "GET" && url.pathname === "/api/peers") {
14
+ const readiness = await buildPeerReadiness(options.config);
15
+ sendJson(res, 200, store.snapshot(identity.public, {
16
+ enabled: options.config.peerEnabled,
17
+ listenUrl: readiness.listenUrl,
18
+ requireTls: options.config.peerRequireTls,
19
+ readiness,
20
+ }));
21
+ return true;
22
+ }
23
+ if (req.method === "POST" && url.pathname === "/api/peers/invite") {
24
+ const body = await readJsonBody(req);
25
+ const readiness = await buildPeerReadiness(options.config);
26
+ const created = store.createInvitation({
27
+ name: optionalStringField(body, "name"),
28
+ expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
29
+ scopes: parseScopes(arrayStringField(body, "scopes")),
30
+ allowedAgents: parseAgents(arrayStringField(body, "allowedAgents")),
31
+ allowedWorkspaceRoots: arrayStringField(body, "allowedWorkspaceRoots"),
32
+ workspaceAliases: parseWorkspaceAliases(body.workspaceAliases),
33
+ });
34
+ const listenUrl = readiness.listenUrl;
35
+ const command = `nordrelay peer add ${listenUrl} --code ${created.code}`;
36
+ sendJson(res, 201, {
37
+ invitation: created.invitation,
38
+ code: created.code,
39
+ url: listenUrl,
40
+ fingerprint: identity.public.fingerprint,
41
+ tlsFingerprint: tls?.fingerprint,
42
+ command,
43
+ readiness,
44
+ warnings: readiness.warnings,
45
+ });
46
+ options.auditPeerAction?.("peer_invite_created", created.invitation.name);
47
+ return true;
48
+ }
49
+ if (req.method === "POST" && url.pathname === "/api/peers/probe") {
50
+ const body = await readJsonBody(req);
51
+ const readiness = await buildPeerReadiness(options.config);
52
+ const peerId = optionalStringField(body, "peerId");
53
+ if (peerId) {
54
+ const probe = await new RemoteRelayClient(store).rpc(peerId, "peer.probe", {}, options.activityActor);
55
+ sendJson(res, 200, { type: "remote", peerId, readiness, probe });
56
+ return true;
57
+ }
58
+ const expectedTlsFingerprint = options.config.peerPublicUrl ? undefined : tls?.fingerprint;
59
+ const probe = await checkPeerEndpoint(readiness.listenUrl, { expectedTlsFingerprint });
60
+ sendJson(res, 200, { type: "local", readiness, probe });
61
+ return true;
62
+ }
63
+ const invitationMatch = url.pathname.match(/^\/api\/peers\/invitations\/([^/]+)$/);
64
+ if (invitationMatch?.[1] && req.method === "DELETE") {
65
+ const invitation = store.deleteInvitation(decodeURIComponent(invitationMatch[1]));
66
+ sendJson(res, 200, { removed: Boolean(invitation), invitation });
67
+ if (invitation) {
68
+ options.auditPeerAction?.("peer_invite_deleted", `${invitation.name} (${invitation.id})`);
69
+ }
70
+ return true;
71
+ }
72
+ if (req.method === "POST" && (url.pathname === "/api/peers" || url.pathname === "/api/peers/pair")) {
73
+ const body = await readJsonBody(req);
74
+ const result = await pairPeer({
75
+ url: requiredString(body, "url"),
76
+ code: requiredString(body, "code"),
77
+ name: optionalStringField(body, "name"),
78
+ publicUrl: optionalStringField(body, "publicUrl"),
79
+ }, identity, store);
80
+ sendJson(res, 201, { peer: publicPeer(result.peer), tlsFingerprint: result.tlsFingerprint });
81
+ options.auditPeerAction?.("peer_paired", `${result.peer.name} (${result.peer.id})`);
82
+ return true;
83
+ }
84
+ const peerMatch = url.pathname.match(/^\/api\/peers\/([^/]+)$/);
85
+ if (peerMatch?.[1] && req.method === "PATCH") {
86
+ const body = await readJsonBody(req);
87
+ const peer = store.updatePeer(decodeURIComponent(peerMatch[1]), {
88
+ name: optionalStringField(body, "name"),
89
+ url: optionalStringField(body, "url"),
90
+ enabled: optionalBooleanField(body, "enabled"),
91
+ scopes: body.scopes === undefined ? undefined : parseScopes(arrayStringField(body, "scopes")),
92
+ allowedAgents: body.allowedAgents === undefined ? undefined : parseAgents(arrayStringField(body, "allowedAgents")),
93
+ allowedWorkspaceRoots: body.allowedWorkspaceRoots === undefined ? undefined : arrayStringField(body, "allowedWorkspaceRoots"),
94
+ workspaceAliases: body.workspaceAliases === undefined ? undefined : parseWorkspaceAliases(body.workspaceAliases),
95
+ });
96
+ sendJson(res, 200, { peer: publicPeer(peer) });
97
+ options.auditPeerAction?.("peer_updated", `${peer.name} (${peer.id})`);
98
+ return true;
99
+ }
100
+ if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
101
+ const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
102
+ const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
103
+ const limit = Math.min(Math.max(Number(url.searchParams.get("limit") ?? 50), 1), 50);
104
+ const client = new RemoteRelayClient(store);
105
+ const targets = [];
106
+ if (options.runtime) {
107
+ targets.push({
108
+ peerId: "local",
109
+ peerName: "Local",
110
+ ok: true,
111
+ data: await options.runtime.listSessionsPage(1, limit, query, agent),
112
+ });
113
+ }
114
+ const peers = store.listPublic().filter((peer) => peer.enabled && peer.url);
115
+ const remoteTargets = await Promise.all(peers.map(async (peer) => {
116
+ try {
117
+ const data = await client.webProxy(peer.id, {
118
+ method: "GET",
119
+ path: "/api/sessions",
120
+ query: { query, page: 1, limit, agent },
121
+ body: {},
122
+ contextKey: "web:dashboard",
123
+ }, options.activityActor, "web:dashboard");
124
+ return { peerId: peer.id, peerName: peer.name, ok: true, data };
125
+ }
126
+ catch (error) {
127
+ return { peerId: peer.id, peerName: peer.name, ok: false, error: error instanceof Error ? error.message : String(error) };
128
+ }
129
+ }));
130
+ sendJson(res, 200, { targets: [...targets, ...remoteTargets] });
131
+ return true;
132
+ }
133
+ if (peerMatch?.[1] && req.method === "DELETE") {
134
+ const peerId = decodeURIComponent(peerMatch[1]);
135
+ const removed = store.revokePeer(peerId);
136
+ sendJson(res, 200, { removed });
137
+ if (removed) {
138
+ options.auditPeerAction?.("peer_revoked", peerId);
139
+ }
140
+ return true;
141
+ }
142
+ const proxyMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/proxy$/);
143
+ if (proxyMatch?.[1] && req.method === "POST") {
144
+ const body = await readJsonBody(req);
145
+ const payload = parseProxyPayload(body);
146
+ const data = await new RemoteRelayClient(store).webProxy(decodeURIComponent(proxyMatch[1]), payload, options.activityActor, payload.contextKey);
147
+ sendJson(res, 200, data);
148
+ return true;
149
+ }
150
+ const healthMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/health$/);
151
+ if (healthMatch?.[1] && req.method === "GET") {
152
+ const peerId = decodeURIComponent(healthMatch[1]);
153
+ const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
154
+ sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
155
+ return true;
156
+ }
157
+ const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
158
+ if (eventsMatch?.[1] && req.method === "GET") {
159
+ res.writeHead(200, {
160
+ "content-type": "text/event-stream; charset=utf-8",
161
+ "cache-control": "no-cache, no-transform",
162
+ connection: "keep-alive",
163
+ });
164
+ const sourceContextKey = url.searchParams.get("contextKey") || undefined;
165
+ const subscription = new RemoteRelayClient(store).subscribe(decodeURIComponent(eventsMatch[1]), (event) => {
166
+ if (res.destroyed || res.writableEnded)
167
+ return;
168
+ res.write(`event: ${event.type}\n`);
169
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
170
+ }, (error) => {
171
+ if (!res.destroyed && !res.writableEnded) {
172
+ res.write("event: status\n");
173
+ res.write(`data: ${JSON.stringify({ type: "status", level: "error", message: error.message, at: new Date().toISOString() })}\n\n`);
174
+ }
175
+ }, sourceContextKey);
176
+ const heartbeat = setInterval(() => {
177
+ if (!res.destroyed && !res.writableEnded)
178
+ res.write(": heartbeat\n\n");
179
+ }, 25_000);
180
+ heartbeat.unref?.();
181
+ req.on("close", () => {
182
+ clearInterval(heartbeat);
183
+ subscription.close();
184
+ });
185
+ return true;
186
+ }
187
+ return false;
188
+ }
189
+ function parseScopes(values) {
190
+ return values.filter(isPermission);
191
+ }
192
+ function parseAgents(values) {
193
+ const parsed = values.filter(isAgentId);
194
+ return parsed.length > 0 ? parsed : [...AGENT_IDS];
195
+ }
196
+ function parseAgent(value) {
197
+ return value && isAgentId(value) ? value : undefined;
198
+ }
199
+ function parseProxyPayload(body) {
200
+ return {
201
+ method: requiredString(body, "method"),
202
+ path: requiredString(body, "path"),
203
+ query: objectRecord(body.query),
204
+ body: objectRecord(body.body),
205
+ contextKey: optionalStringField(body, "contextKey"),
206
+ };
207
+ }
208
+ function parseWorkspaceAliases(value) {
209
+ if (typeof value === "string") {
210
+ return Object.fromEntries(value.split(",").map((item) => {
211
+ const [alias, workspace] = item.split("=", 2);
212
+ return [alias?.trim() ?? "", workspace?.trim() ?? ""];
213
+ }).filter(([alias, workspace]) => alias && workspace));
214
+ }
215
+ const record = objectRecord(value);
216
+ return Object.fromEntries(Object.entries(record).filter((entry) => typeof entry[1] === "string" && entry[0].trim().length > 0 && entry[1].trim().length > 0));
217
+ }
218
+ function requiredString(body, key) {
219
+ const value = body[key];
220
+ const text = typeof value === "string" ? value.trim() : "";
221
+ if (!text) {
222
+ throw new Error(`${key} is required.`);
223
+ }
224
+ return text;
225
+ }
@@ -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" },
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { URL } from "node:url";
6
6
  import { enabledAgents } from "./agent-factory.js";
7
+ import { buildAdapterConformanceMatrix } from "./adapter-conformance.js";
7
8
  import { listAgentAdapterDescriptors } from "./agent-adapter.js";
8
9
  import { isAgentId } from "./agent.js";
9
10
  import { AuditLogStore } from "./audit-log.js";
@@ -13,6 +14,7 @@ import { loadConfig } from "./config.js";
13
14
  import { friendlyErrorText } from "./error-messages.js";
14
15
  import { RelayRuntime } from "./relay-runtime.js";
15
16
  import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
17
+ import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "./settings-wizard-test.js";
16
18
  import { UserStore, publicUser } from "./user-management.js";
17
19
  import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
18
20
  import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
@@ -21,6 +23,7 @@ import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson
21
23
  import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
22
24
  import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
23
25
  import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
26
+ import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
24
27
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
25
28
  const options = parseOptions(process.argv.slice(2));
26
29
  const config = loadConfig();
@@ -160,6 +163,7 @@ async function handleApi(req, res, url, authUser) {
160
163
  auth: currentUserDto(authUser),
161
164
  channels: listChannelDescriptors(),
162
165
  agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
166
+ adapterConformance: scopedAdapterConformance(authUser),
163
167
  enabledAgents: enabledAgents(config).filter((agentId) => users.canUseAgent(authUser, agentId)),
164
168
  controls: scopedControlOptions(authUser, await runtime.controlOptions()),
165
169
  status: await runtime.bootstrapStatus(),
@@ -172,6 +176,10 @@ async function handleApi(req, res, url, authUser) {
172
176
  sendJson(res, 200, scopedControlOptions(authUser, await runtime.controlOptions(agentId)));
173
177
  return;
174
178
  }
179
+ if (req.method === "GET" && url.pathname === "/api/adapters/conformance") {
180
+ sendJson(res, 200, scopedAdapterConformance(authUser));
181
+ return;
182
+ }
175
183
  if (await handleDashboardAccessRoute(req, res, url, {
176
184
  users,
177
185
  runtime,
@@ -180,6 +188,15 @@ async function handleApi(req, res, url, authUser) {
180
188
  })) {
181
189
  return;
182
190
  }
191
+ if (await handleDashboardPeerRoute(req, res, url, {
192
+ config,
193
+ home: options.home,
194
+ runtime,
195
+ activityActor: webActivityActor(authUser),
196
+ auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
197
+ })) {
198
+ return;
199
+ }
183
200
  if (req.method === "GET" && url.pathname === "/api/settings") {
184
201
  sendJson(res, 200, await settings.snapshot(process.env, activeSettingsValues(config)));
185
202
  return;
@@ -189,6 +206,11 @@ async function handleApi(req, res, url, authUser) {
189
206
  sendJson(res, 200, await settings.update(objectRecord(body?.settings)));
190
207
  return;
191
208
  }
209
+ if (req.method === "POST" && url.pathname === "/api/settings/wizard/test") {
210
+ const body = await readJsonBody(req);
211
+ sendJson(res, 200, await runSettingsWizardTest(optionalStringField(body, "channel") ?? "", mergeSettingsWizardTestSettings(activeSettingsValues(config), objectRecord(body?.settings))));
212
+ return;
213
+ }
192
214
  if (await handleDashboardSessionRoute(req, res, url, {
193
215
  runtime,
194
216
  authUser,
@@ -464,6 +486,13 @@ function scopedControlOptions(authUser, options) {
464
486
  workspaces: options.workspaces.filter((workspace) => users.canUseWorkspace(authUser, workspace)),
465
487
  };
466
488
  }
489
+ function scopedAdapterConformance(authUser) {
490
+ const matrix = buildAdapterConformanceMatrix();
491
+ return {
492
+ ...matrix,
493
+ agents: matrix.agents.filter((adapter) => users.canUseAgent(authUser, adapter.id)),
494
+ };
495
+ }
467
496
  function scopedSessionPage(authUser, page) {
468
497
  return {
469
498
  ...page,
@@ -494,6 +523,8 @@ async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => can
494
523
  return canUseSession(authUser, event.session) ? event : null;
495
524
  case "activity_update":
496
525
  return { ...event, events: filterActivityByScope(authUser, event.events) };
526
+ case "active_sessions_update":
527
+ return { ...event, active: scopedActiveSessions(authUser, event.active) };
497
528
  case "agent_update":
498
529
  return users.canUseAgent(authUser, event.job.agentId) ? event : null;
499
530
  case "status":
@@ -640,6 +671,21 @@ function activeSettingsValues(current) {
640
671
  DISCORD_NOTIFY_MODE: current.discordNotifyMode === current.notifyMode ? "" : current.discordNotifyMode,
641
672
  DISCORD_QUIET_HOURS: quietOverrideValue(current.discordQuietHours, current.quietHours),
642
673
  DISCORD_AUTO_SEND_ARTIFACTS: current.discordAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.discordAutoSendArtifacts),
674
+ SLACK_ENABLED: boolValue(current.slackEnabled),
675
+ SLACK_BOT_TOKEN: current.slackBotToken,
676
+ SLACK_APP_TOKEN: current.slackAppToken,
677
+ SLACK_SIGNING_SECRET: current.slackSigningSecret,
678
+ SLACK_SOCKET_MODE: boolValue(current.slackSocketMode),
679
+ SLACK_PORT: String(current.slackPort),
680
+ SLACK_ALLOWED_TEAM_IDS: current.slackAllowedTeamIds.join(","),
681
+ SLACK_ALLOWED_CHANNEL_IDS: current.slackAllowedChannelIds.join(","),
682
+ SLACK_MESSAGE_CONTENT_ENABLED: boolValue(current.slackMessageContentEnabled),
683
+ SLACK_COMMAND: current.slackCommand,
684
+ SLACK_CLI_MIRROR_MODE: current.slackMirrorMode === current.mirrorMode ? "" : current.slackMirrorMode,
685
+ SLACK_CLI_MIRROR_MIN_UPDATE_MS: current.slackMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.slackMirrorMinUpdateMs),
686
+ SLACK_NOTIFY_MODE: current.slackNotifyMode === current.notifyMode ? "" : current.slackNotifyMode,
687
+ SLACK_QUIET_HOURS: quietOverrideValue(current.slackQuietHours, current.quietHours),
688
+ SLACK_AUTO_SEND_ARTIFACTS: current.slackAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.slackAutoSendArtifacts),
643
689
  NORDRELAY_CODEX_ENABLED: boolValue(current.codexEnabled),
644
690
  NORDRELAY_PI_ENABLED: boolValue(current.piEnabled),
645
691
  NORDRELAY_HERMES_ENABLED: boolValue(current.hermesEnabled),
package/dist/web-state.js CHANGED
@@ -123,7 +123,7 @@ function isWebChatMessage(value) {
123
123
  typeof candidate.text === "string" &&
124
124
  typeof candidate.timestamp === "string" &&
125
125
  ["user", "agent", "system", "tool"].includes(candidate.role) &&
126
- ["web", "telegram", "discord", "cli"].includes(candidate.source);
126
+ ["web", "telegram", "discord", "slack", "cli"].includes(candidate.source);
127
127
  }
128
128
  function isWebActivityEvent(value) {
129
129
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -133,7 +133,7 @@ function isWebActivityEvent(value) {
133
133
  return typeof candidate.id === "string" &&
134
134
  typeof candidate.timestamp === "string" &&
135
135
  typeof candidate.type === "string" &&
136
- ["web", "telegram", "discord", "cli"].includes(candidate.source) &&
136
+ ["web", "telegram", "discord", "slack", "cli"].includes(candidate.source) &&
137
137
  ["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
138
138
  }
139
139
  function normalizeSince(value) {