@nordbyte/nordrelay 0.8.0 → 0.8.2

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 (173) hide show
  1. package/.env.example +9 -0
  2. package/README.md +81 -1197
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +2 -2
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
  40. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  41. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  42. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  43. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
  44. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  45. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  46. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  47. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  48. package/dist/{bot.js → channels/telegram/bot.js} +178 -427
  49. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  50. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  51. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  52. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  53. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  54. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  55. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  56. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  57. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  58. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  59. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  60. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  61. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  62. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  63. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  64. package/dist/{config.js → core/config.js} +11 -3
  65. package/dist/index.js +27 -23
  66. package/dist/{peer-client.js → peers/peer-client.js} +57 -1
  67. package/dist/peers/peer-discovery-jobs.js +206 -0
  68. package/dist/peers/peer-discovery.js +223 -0
  69. package/dist/peers/peer-health-monitor.js +49 -0
  70. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  71. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  72. package/dist/{peer-server.js → peers/peer-server.js} +23 -6
  73. package/dist/{peer-store.js → peers/peer-store.js} +84 -11
  74. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  75. package/dist/peers/peer-web-proxy-contract.js +127 -0
  76. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  77. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  78. package/dist/runtime/relay-auth-service.js +63 -0
  79. package/dist/runtime/relay-dashboard-service.js +139 -0
  80. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  81. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  82. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  83. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  84. package/dist/runtime/relay-runtime-sessions.js +623 -0
  85. package/dist/runtime/relay-runtime-types.js +1 -0
  86. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  87. package/dist/runtime/relay-runtime.js +451 -0
  88. package/dist/runtime/runtime-cache.js +117 -0
  89. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  90. package/dist/{operations.js → support/operations.js} +7 -7
  91. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  92. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  93. package/dist/web/web-api-types.js +1 -0
  94. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  95. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  96. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  97. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  98. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  99. package/dist/web/web-dashboard-security.js +14 -0
  100. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  101. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  102. package/dist/web/web-performance.js +60 -0
  103. package/dist/web/web-rate-limit.js +19 -0
  104. package/dist/{web-state.js → web/web-state.js} +74 -5
  105. package/dist/webui-assets/dashboard.css +171 -10
  106. package/dist/webui-assets/dashboard.js +515 -48
  107. package/dist/webui-assets/favicon.ico +0 -0
  108. package/dist/webui-assets/favicon.png +0 -0
  109. package/dist/webui-assets/logo.png +0 -0
  110. package/package.json +4 -3
  111. package/plugins/nordrelay/scripts/nordrelay.mjs +17 -5
  112. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  113. package/dist/relay-runtime.js +0 -1916
  114. package/dist/runtime-cache.js +0 -57
  115. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  116. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  117. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  118. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  119. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  120. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  121. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  122. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  123. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  124. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  125. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  126. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  127. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  128. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  129. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  130. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  131. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  132. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  133. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  134. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  135. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  136. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  137. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  138. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  139. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  140. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  141. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  142. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  143. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  144. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  145. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  146. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  147. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  148. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  149. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  150. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  151. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  152. /package/dist/{format.js → core/format.js} +0 -0
  153. /package/dist/{logger.js → core/logger.js} +0 -0
  154. /package/dist/{redaction.js → core/redaction.js} +0 -0
  155. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  156. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  157. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  158. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  159. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  160. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  161. /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
  162. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  163. /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
  164. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  165. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  166. /package/dist/{job-store.js → state/job-store.js} +0 -0
  167. /package/dist/{persistence.js → state/persistence.js} +0 -0
  168. /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
  169. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  170. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
  171. /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
  172. /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
  173. /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
@@ -1,14 +1,15 @@
1
1
  import { createServer as createHttpServer } from "node:http";
2
2
  import { createServer as createHttpsServer } from "node:https";
3
3
  import { URL } from "node:url";
4
- import { friendlyErrorText } from "./error-messages.js";
4
+ import { friendlyErrorText } from "../core/error-messages.js";
5
5
  import { createPairingSignaturePayload, createSharedSecret, ensurePeerTlsFiles, fingerprintForPublicKey, loadOrCreatePeerIdentity, verifyPeerPayload, } from "./peer-identity.js";
6
6
  import { header, PeerNonceCache, verifyPeerRequest } from "./peer-auth.js";
7
+ import { checkPeerIdentityEndpoint } from "./peer-client.js";
7
8
  import { peerRuntimeContextKey } from "./peer-context.js";
8
9
  import { PeerStore } from "./peer-store.js";
9
10
  import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
10
11
  import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
11
- import { RelayRuntime } from "./relay-runtime.js";
12
+ import { RelayRuntime } from "../runtime/relay-runtime.js";
12
13
  export async function startPeerServer(options) {
13
14
  const { config, runtime } = options;
14
15
  if (!config.peerEnabled) {
@@ -78,7 +79,7 @@ export async function startPeerServer(options) {
78
79
  if (req.method === "POST" && url.pathname === "/peer/pair") {
79
80
  const bodyText = await readBody(req, 128 * 1024);
80
81
  const body = parseJson(bodyText);
81
- const response = handlePair(body);
82
+ const response = await handlePair(body);
82
83
  sendJson(res, 201, response);
83
84
  return;
84
85
  }
@@ -123,7 +124,7 @@ export async function startPeerServer(options) {
123
124
  sendJson(res, status, { error: friendlyErrorText(error) });
124
125
  }
125
126
  }
126
- function handlePair(body) {
127
+ async function handlePair(body) {
127
128
  if (!body?.identity?.nodeId || !body.identity.publicKey || !body.code || !body.signature || !body.timestamp) {
128
129
  throw new Error("Invalid peer pairing request.");
129
130
  }
@@ -140,17 +141,21 @@ export async function startPeerServer(options) {
140
141
  if (!verifyPeerPayload(body.identity.publicKey, signaturePayload, body.signature)) {
141
142
  throw new Error("Invalid peer pairing signature.");
142
143
  }
144
+ const publicUrl = body.publicUrl?.trim() || undefined;
145
+ const publicUrlTlsFingerprint = publicUrl ? await verifyPairingPublicUrl(publicUrl, body.identity) : undefined;
143
146
  const invitation = store.consumeInvitation(body.code, body.identity.nodeId);
144
147
  const secret = createSharedSecret();
145
148
  const peer = store.upsertPeer({
146
149
  name: body.name?.trim() || body.identity.name || invitation.name,
147
- url: body.publicUrl,
150
+ group: invitation.group,
151
+ url: publicUrl,
148
152
  nodeId: body.identity.nodeId,
149
153
  publicKey: body.identity.publicKey,
150
154
  fingerprint: body.identity.fingerprint,
155
+ tlsFingerprint: publicUrl ? publicUrlTlsFingerprint ?? null : undefined,
151
156
  secret,
152
157
  enabled: true,
153
- direction: body.publicUrl ? "bidirectional" : "inbound",
158
+ direction: publicUrl ? "bidirectional" : "inbound",
154
159
  scopes: invitation.scopes,
155
160
  allowedAgents: invitation.allowedAgents,
156
161
  allowedWorkspaceRoots: invitation.allowedWorkspaceRoots,
@@ -167,6 +172,18 @@ export async function startPeerServer(options) {
167
172
  workspaceAliases: peer.workspaceAliases,
168
173
  };
169
174
  }
175
+ async function verifyPairingPublicUrl(publicUrl, expectedIdentity) {
176
+ const probe = await checkPeerIdentityEndpoint(publicUrl, { timeoutMs: 4_000 });
177
+ if (!probe.ok || !probe.identity) {
178
+ throw new Error(`Peer public URL is not reachable or does not expose a valid NordRelay identity: ${probe.detail}`);
179
+ }
180
+ if (probe.identity.nodeId !== expectedIdentity.nodeId ||
181
+ probe.identity.publicKey !== expectedIdentity.publicKey ||
182
+ probe.identity.fingerprint !== expectedIdentity.fingerprint) {
183
+ throw new Error("Peer public URL identity does not match the pairing identity.");
184
+ }
185
+ return probe.tlsFingerprint;
186
+ }
170
187
  function authenticate(req, method, pathname, body) {
171
188
  const peerId = header(req, "x-nordrelay-peer-id");
172
189
  const peer = peerId ? store.get(peerId) : null;
@@ -2,13 +2,14 @@ import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypt
2
2
  import { mkdirSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
- import { ALL_PERMISSIONS } from "./access-control.js";
6
- import { AGENT_IDS, isAgentId } from "./agent.js";
7
- import { readJsonFileWithBackup, writeJsonFileAtomic } from "./persistence.js";
5
+ import { ALL_PERMISSIONS } from "../access/access-control.js";
6
+ import { AGENT_IDS, isAgentId } from "../agents/shared/agent.js";
7
+ import { readJsonFileWithBackup, writeJsonFileAtomic } from "../state/persistence.js";
8
8
  import { DEFAULT_PEER_SCOPES, publicInvitation, publicPeer, } from "./peer-types.js";
9
9
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
10
10
  const INVITE_CODE_BYTES = 18;
11
11
  const MAX_INVITATION_TTL_MS = 24 * 60 * 60 * 1000;
12
+ const MAX_HEALTH_HISTORY = 20;
12
13
  export class PeerStore {
13
14
  filePath;
14
15
  constructor(home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
@@ -22,6 +23,7 @@ export class PeerStore {
22
23
  listenUrl: options.listenUrl,
23
24
  requireTls: options.requireTls,
24
25
  readiness: options.readiness,
26
+ groups: listGroups(payload),
25
27
  peers: payload.peers.map(publicPeer),
26
28
  invitations: payload.invitations.map(publicInvitation),
27
29
  };
@@ -44,6 +46,7 @@ export class PeerStore {
44
46
  const invitation = {
45
47
  id: randomUUID().replace(/-/g, "").slice(0, 12),
46
48
  name: options.name?.trim() || "NordRelay peer",
49
+ group: normalizeGroup(options.group),
47
50
  codeHash: hashSecret(code),
48
51
  createdAt: now.toISOString(),
49
52
  expiresAt: expiresAt.toISOString(),
@@ -88,10 +91,13 @@ export class PeerStore {
88
91
  const existing = payload.peers.find((peer) => peer.nodeId === input.nodeId || (input.id && peer.id === input.id));
89
92
  if (existing) {
90
93
  existing.name = input.name.trim() || existing.name;
94
+ existing.group = normalizeGroup(input.group) ?? existing.group;
91
95
  existing.url = input.url ?? existing.url;
92
96
  existing.publicKey = input.publicKey;
93
97
  existing.fingerprint = input.fingerprint;
94
- existing.tlsFingerprint = input.tlsFingerprint ?? existing.tlsFingerprint;
98
+ if (input.tlsFingerprint !== undefined) {
99
+ existing.tlsFingerprint = input.tlsFingerprint || undefined;
100
+ }
95
101
  existing.secret = input.secret;
96
102
  existing.enabled = input.enabled ?? existing.enabled;
97
103
  existing.direction = mergeDirection(existing.direction, input.direction ?? existing.direction);
@@ -107,11 +113,12 @@ export class PeerStore {
107
113
  const record = {
108
114
  id: input.id ?? randomUUID().replace(/-/g, "").slice(0, 12),
109
115
  name: input.name.trim() || "NordRelay peer",
116
+ group: normalizeGroup(input.group),
110
117
  url: input.url,
111
118
  nodeId: input.nodeId,
112
119
  publicKey: input.publicKey,
113
120
  fingerprint: input.fingerprint,
114
- tlsFingerprint: input.tlsFingerprint,
121
+ tlsFingerprint: input.tlsFingerprint || undefined,
115
122
  secret: input.secret,
116
123
  enabled: input.enabled ?? true,
117
124
  direction: input.direction ?? "outbound",
@@ -121,6 +128,7 @@ export class PeerStore {
121
128
  workspaceAliases: normalizeWorkspaceAliases(input.workspaceAliases ?? {}),
122
129
  createdAt: now,
123
130
  updatedAt: now,
131
+ healthHistory: [],
124
132
  };
125
133
  payload.peers.push(record);
126
134
  next = clonePeer(record);
@@ -139,6 +147,8 @@ export class PeerStore {
139
147
  }
140
148
  if (patch.name !== undefined)
141
149
  peer.name = patch.name.trim() || peer.name;
150
+ if (patch.group !== undefined)
151
+ peer.group = normalizeGroup(patch.group);
142
152
  if (patch.url !== undefined)
143
153
  peer.url = patch.url.trim() || undefined;
144
154
  if (patch.enabled !== undefined)
@@ -159,18 +169,53 @@ export class PeerStore {
159
169
  }
160
170
  return next;
161
171
  }
172
+ updatePeerTlsFingerprint(id, tlsFingerprint) {
173
+ let next = null;
174
+ this.mutatePayload((payload) => {
175
+ const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
176
+ if (!peer) {
177
+ throw new Error("Peer not found.");
178
+ }
179
+ peer.tlsFingerprint = tlsFingerprint || undefined;
180
+ peer.updatedAt = new Date().toISOString();
181
+ next = clonePeer(peer);
182
+ });
183
+ if (!next) {
184
+ throw new Error("Peer not found.");
185
+ }
186
+ return next;
187
+ }
162
188
  markSeen(id, patch = {}) {
163
- this.patchPeer(id, {
164
- lastSeenAt: new Date().toISOString(),
165
- lastCheckedAt: new Date().toISOString(),
189
+ const checkedAt = new Date().toISOString();
190
+ this.patchPeer(id, (peer) => ({
191
+ lastSeenAt: checkedAt,
192
+ lastCheckedAt: checkedAt,
166
193
  lastLatencyMs: patch.latencyMs,
167
194
  remoteVersion: patch.remoteVersion,
168
195
  remoteStatus: patch.remoteStatus ?? "online",
169
196
  lastError: undefined,
170
- });
197
+ healthHistory: appendHealthSample(peer.healthHistory, {
198
+ checkedAt,
199
+ status: "online",
200
+ latencyMs: patch.latencyMs,
201
+ remoteVersion: patch.remoteVersion,
202
+ remoteStatus: patch.remoteStatus ?? "online",
203
+ }),
204
+ }));
171
205
  }
172
206
  markError(id, error) {
173
- this.patchPeer(id, { lastError: error, remoteStatus: "offline", lastCheckedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
207
+ const checkedAt = new Date().toISOString();
208
+ this.patchPeer(id, (peer) => ({
209
+ lastError: error,
210
+ remoteStatus: "offline",
211
+ lastCheckedAt: checkedAt,
212
+ updatedAt: checkedAt,
213
+ healthHistory: appendHealthSample(peer.healthHistory, {
214
+ checkedAt,
215
+ status: "offline",
216
+ error,
217
+ }),
218
+ }));
174
219
  }
175
220
  revokePeer(id) {
176
221
  let removed = false;
@@ -198,7 +243,7 @@ export class PeerStore {
198
243
  const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
199
244
  if (!peer)
200
245
  return;
201
- Object.assign(peer, patch);
246
+ Object.assign(peer, typeof patch === "function" ? patch(peer) : patch);
202
247
  });
203
248
  }
204
249
  mutatePayload(mutator) {
@@ -217,13 +262,16 @@ export class PeerStore {
217
262
  version: 1,
218
263
  peers: payload.peers.filter(isPeerRecord).map((peer) => ({
219
264
  ...peer,
265
+ group: normalizeGroup(peer.group),
220
266
  scopes: normalizeScopes(peer.scopes),
221
267
  allowedAgents: normalizeAgents(peer.allowedAgents),
222
268
  allowedWorkspaceRoots: normalizeWorkspaceRoots(peer.allowedWorkspaceRoots),
223
269
  workspaceAliases: normalizeWorkspaceAliases(peer.workspaceAliases ?? {}),
270
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
224
271
  })),
225
272
  invitations: payload.invitations.filter(isInvitationRecord).map((invitation) => ({
226
273
  ...invitation,
274
+ group: normalizeGroup(invitation.group),
227
275
  scopes: normalizeScopes(invitation.scopes),
228
276
  allowedAgents: normalizeAgents(invitation.allowedAgents),
229
277
  allowedWorkspaceRoots: normalizeWorkspaceRoots(invitation.allowedWorkspaceRoots),
@@ -270,6 +318,30 @@ function normalizeWorkspaceAliases(value) {
270
318
  }
271
319
  return aliases;
272
320
  }
321
+ function normalizeGroup(value) {
322
+ const group = typeof value === "string" ? value.trim() : "";
323
+ return group ? group.slice(0, 80) : undefined;
324
+ }
325
+ function normalizeHealthHistory(value) {
326
+ if (!Array.isArray(value)) {
327
+ return [];
328
+ }
329
+ return value
330
+ .filter((item) => {
331
+ if (!item || typeof item !== "object" || Array.isArray(item))
332
+ return false;
333
+ const record = item;
334
+ return typeof record.checkedAt === "string" && (record.status === "online" || record.status === "offline");
335
+ })
336
+ .slice(-MAX_HEALTH_HISTORY)
337
+ .map((item) => ({ ...item }));
338
+ }
339
+ function appendHealthSample(history, sample) {
340
+ return [...normalizeHealthHistory(history), sample].slice(-MAX_HEALTH_HISTORY);
341
+ }
342
+ function listGroups(payload) {
343
+ return [...new Set(payload.peers.map((peer) => normalizeGroup(peer.group)).filter((group) => Boolean(group)))].sort();
344
+ }
273
345
  function clonePeer(peer) {
274
346
  return {
275
347
  ...peer,
@@ -277,6 +349,7 @@ function clonePeer(peer) {
277
349
  allowedAgents: [...peer.allowedAgents],
278
350
  allowedWorkspaceRoots: [...peer.allowedWorkspaceRoots],
279
351
  workspaceAliases: { ...peer.workspaceAliases },
352
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
280
353
  };
281
354
  }
282
355
  function mergeDirection(left, right) {
@@ -16,6 +16,7 @@ export function publicPeer(record) {
16
16
  return {
17
17
  id: record.id,
18
18
  name: record.name,
19
+ group: record.group,
19
20
  url: record.url,
20
21
  nodeId: record.nodeId,
21
22
  fingerprint: record.fingerprint,
@@ -34,12 +35,20 @@ export function publicPeer(record) {
34
35
  remoteVersion: record.remoteVersion,
35
36
  remoteStatus: record.remoteStatus,
36
37
  lastError: record.lastError,
38
+ healthHistory: record.healthHistory?.map((sample) => ({ ...sample })),
39
+ effectiveAccess: {
40
+ scopes: [...record.scopes],
41
+ allowedAgents: [...record.allowedAgents],
42
+ allowedWorkspaceRoots: [...record.allowedWorkspaceRoots],
43
+ workspaceAliases: { ...record.workspaceAliases },
44
+ },
37
45
  };
38
46
  }
39
47
  export function publicInvitation(record) {
40
48
  return {
41
49
  id: record.id,
42
50
  name: record.name,
51
+ group: record.group,
43
52
  expiresAt: record.expiresAt,
44
53
  createdAt: record.createdAt,
45
54
  scopes: [...record.scopes],
@@ -0,0 +1,127 @@
1
+ import { WEB_API_ROUTE_DEFINITIONS } from "../web/web-api-contract.js";
2
+ const LOCAL_ONLY_ROUTE_PATHS = new Set([
3
+ "/api/auth/me",
4
+ "/api/dashboard/logout",
5
+ "/api/permissions",
6
+ "/api/settings",
7
+ "/api/settings/wizard/test",
8
+ "/api/peers",
9
+ "/api/peers/invite",
10
+ "/api/peers/pair",
11
+ "/api/peers/probe",
12
+ "/api/peers/discover",
13
+ "/api/peers/discovery-jobs",
14
+ "/api/peers/discovery-jobs/:id",
15
+ "/api/peers/discovery-jobs/:id/cancel",
16
+ "/api/peers/discovery-jobs/:id/log",
17
+ "/api/peers/identity/backup",
18
+ "/api/peers/identity/restore",
19
+ "/api/peers/invitations/:id",
20
+ "/api/peers/:id",
21
+ "/api/peers/:id/repin",
22
+ "/api/peers/:id/health",
23
+ "/api/peers/:id/proxy",
24
+ "/api/peers/:id/events",
25
+ "/api/peers/global-sessions",
26
+ "/api/users",
27
+ "/api/users/:id",
28
+ "/api/users/:id/password",
29
+ "/api/users/:id/sessions",
30
+ "/api/users/:id/sessions/:sessionId",
31
+ "/api/users/:id/telegram",
32
+ "/api/users/:id/telegram/:identityId",
33
+ "/api/users/:id/discord",
34
+ "/api/users/:id/discord/:identityId",
35
+ "/api/users/:id/slack",
36
+ "/api/users/:id/slack/:identityId",
37
+ "/api/groups",
38
+ "/api/groups/:id",
39
+ "/api/telegram-chats",
40
+ "/api/telegram-chats/:id",
41
+ "/api/discord-channels",
42
+ "/api/discord-channels/:id",
43
+ "/api/slack-channels",
44
+ "/api/slack-channels/:id",
45
+ "/api/audit",
46
+ ]);
47
+ const IMPLEMENTED_ROUTE_PATHS = new Set([
48
+ "/api/bootstrap",
49
+ "/api/health",
50
+ "/api/snapshot",
51
+ "/api/tasks",
52
+ "/api/progress",
53
+ "/api/metrics",
54
+ "/api/jobs",
55
+ "/api/jobs/:id/log",
56
+ "/api/jobs/:id/action",
57
+ "/api/active-sessions",
58
+ "/api/version",
59
+ "/api/update",
60
+ "/api/agent-updates",
61
+ "/api/agent-update",
62
+ "/api/agent-update/:id/log",
63
+ "/api/agent-update/:id/input",
64
+ "/api/agent-update/:id/cancel",
65
+ "/api/adapters/health",
66
+ "/api/adapters/conformance",
67
+ "/api/locks",
68
+ "/api/auth/status",
69
+ "/api/auth/login",
70
+ "/api/auth/logout",
71
+ "/api/control-options",
72
+ "/api/sessions",
73
+ "/api/sessions/new",
74
+ "/api/sessions/switch",
75
+ "/api/sessions/attach",
76
+ "/api/sessions/detail",
77
+ "/api/agent",
78
+ "/api/models",
79
+ "/api/session/model",
80
+ "/api/session/reasoning",
81
+ "/api/session/fast",
82
+ "/api/session/launch",
83
+ "/api/prompt",
84
+ "/api/prompt/upload",
85
+ "/api/abort",
86
+ "/api/stop",
87
+ "/api/handback",
88
+ "/api/retry",
89
+ "/api/sync",
90
+ "/api/queue",
91
+ "/api/chat/history",
92
+ "/api/chat/mirror",
93
+ "/api/activity",
94
+ "/api/artifacts",
95
+ "/api/artifacts/bulk",
96
+ "/api/artifacts/zip",
97
+ "/api/artifacts/file",
98
+ "/api/artifacts/preview",
99
+ "/api/logs",
100
+ "/api/logs/clear",
101
+ "/api/diagnostics",
102
+ "/api/diagnostics/bundle",
103
+ "/api/runtime/restart",
104
+ ]);
105
+ export function peerProxyCoverage() {
106
+ const implemented = [];
107
+ const localOnly = [];
108
+ const missing = [];
109
+ for (const route of WEB_API_ROUTE_DEFINITIONS) {
110
+ for (const method of route.methods) {
111
+ const key = { method, path: route.path };
112
+ if (IMPLEMENTED_ROUTE_PATHS.has(route.path)) {
113
+ implemented.push(key);
114
+ }
115
+ else if (LOCAL_ONLY_ROUTE_PATHS.has(route.path)) {
116
+ localOnly.push(key);
117
+ }
118
+ else {
119
+ missing.push(key);
120
+ }
121
+ }
122
+ }
123
+ return { implemented, localOnly, missing };
124
+ }
125
+ export function isPeerProxyLocalOnlyPath(path) {
126
+ return LOCAL_ONLY_ROUTE_PATHS.has(path);
127
+ }
@@ -1,7 +1,8 @@
1
1
  import { monitorEventLoopDelay } from "node:perf_hooks";
2
- import { getDiscordRateLimitMetrics } from "./discord-rate-limit.js";
3
- import { getSlackRateLimitMetrics } from "./slack-rate-limit.js";
4
- import { getTelegramRateLimitMetrics } from "./telegram-rate-limit.js";
2
+ import { getDiscordRateLimitMetrics } from "../channels/discord/discord-rate-limit.js";
3
+ import { getSlackRateLimitMetrics } from "../channels/slack/slack-rate-limit.js";
4
+ import { getTelegramRateLimitMetrics } from "../channels/telegram/telegram-rate-limit.js";
5
+ import { getWebApiPerformanceMetrics } from "../web/web-performance.js";
5
6
  const startedAt = Date.now();
6
7
  const eventLoopDelay = monitorEventLoopDelay({ resolution: 20 });
7
8
  eventLoopDelay.enable();
@@ -38,6 +39,7 @@ export function buildRuntimeMetrics(input) {
38
39
  discord: getDiscordRateLimitMetrics(),
39
40
  slack: getSlackRateLimitMetrics(),
40
41
  },
42
+ web: getWebApiPerformanceMetrics(),
41
43
  };
42
44
  }
43
45
  function processMetrics() {
@@ -1,6 +1,6 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { collectRecentWorkspaceArtifacts, createArtifactZipBundle, getArtifactTurnReport, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "./artifacts.js";
3
+ import { collectRecentWorkspaceArtifacts, createArtifactZipBundle, getArtifactTurnReport, listRecentArtifactReports, persistWorkspaceArtifactReport, removeArtifactTurn, totalArtifactSize, } from "../artifacts/artifacts.js";
4
4
  const MAX_TEXT_PREVIEW_BYTES = 256 * 1024;
5
5
  export class RelayArtifactService {
6
6
  config;
@@ -0,0 +1,63 @@
1
+ import { checkClaudeCodeAuthStatus, startClaudeCodeLogin, startClaudeCodeLogout } from "../agents/claude-code/claude-code-auth.js";
2
+ import { checkAuthStatus, startLogin as startCodexLogin, startLogout as startCodexLogout } from "../agents/codex/codex-auth.js";
3
+ import { checkHermesAuthStatus, startHermesLogin, startHermesLogout } from "../agents/hermes/hermes-auth.js";
4
+ import { checkOpenClawAuthStatus } from "../agents/openclaw/openclaw-auth.js";
5
+ import { checkPiAuthStatus } from "../agents/pi/pi-auth.js";
6
+ export class RelayAuthService {
7
+ config;
8
+ constructor(config) {
9
+ this.config = config;
10
+ }
11
+ async check(info) {
12
+ if (info.agentId === "pi") {
13
+ return checkPiAuthStatus(info.model);
14
+ }
15
+ if (info.agentId === "hermes") {
16
+ return checkHermesAuthStatus({
17
+ baseUrl: this.config.hermesApiBaseUrl,
18
+ apiKey: this.config.hermesApiKey,
19
+ });
20
+ }
21
+ if (info.agentId === "openclaw") {
22
+ return checkOpenClawAuthStatus({
23
+ gatewayUrl: this.config.openClawGatewayUrl,
24
+ token: this.config.openClawGatewayToken,
25
+ password: this.config.openClawGatewayPassword,
26
+ });
27
+ }
28
+ if (info.agentId === "claude-code") {
29
+ return checkClaudeCodeAuthStatus(this.config.claudeCodeCliPath);
30
+ }
31
+ return checkAuthStatus(this.config.codexApiKey);
32
+ }
33
+ async startLogin(info) {
34
+ if (info.agentId === "hermes") {
35
+ return startHermesLogin(this.config.hermesCliPath);
36
+ }
37
+ if (info.agentId === "claude-code") {
38
+ return startClaudeCodeLogin(this.config.claudeCodeCliPath);
39
+ }
40
+ if (info.agentId === "codex") {
41
+ return startCodexLogin();
42
+ }
43
+ return {
44
+ success: false,
45
+ message: `${info.agentLabel} login is not managed by NordRelay. Run the agent login flow on the host.`,
46
+ };
47
+ }
48
+ async startLogout(info) {
49
+ if (info.agentId === "hermes") {
50
+ return startHermesLogout(this.config.hermesCliPath);
51
+ }
52
+ if (info.agentId === "claude-code") {
53
+ return startClaudeCodeLogout(this.config.claudeCodeCliPath);
54
+ }
55
+ if (info.agentId === "codex") {
56
+ return startCodexLogout();
57
+ }
58
+ return {
59
+ success: false,
60
+ message: `${info.agentLabel} logout is not managed by NordRelay. Run the agent logout flow on the host.`,
61
+ };
62
+ }
63
+ }
@@ -0,0 +1,139 @@
1
+ import { enabledAgents } from "../agents/shared/agent-factory.js";
2
+ import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
3
+ import { friendlyErrorText } from "../core/error-messages.js";
4
+ import { getAgentDiagnostics } from "../agents/shared/agent-activity.js";
5
+ import { getConnectorHealth, getVersionChecks, readConnectorState } from "../support/operations.js";
6
+ import { collectSlackDiagnostics } from "../channels/slack/slack-diagnostics.js";
7
+ import { getSlackRateLimitMetrics } from "../channels/slack/slack-rate-limit.js";
8
+ import { cliHealthForAgent, versionCheckForAgent } from "./relay-runtime-helpers.js";
9
+ export class RelayDashboardService {
10
+ options;
11
+ keys = ["version", "adapterHealth", "diagnostics"];
12
+ warmTimer;
13
+ constructor(options) {
14
+ this.options = options;
15
+ options.cache.register("version", () => this.produceVersion());
16
+ options.cache.register("adapterHealth", () => this.produceAdapterHealth());
17
+ options.cache.register("diagnostics", () => this.produceDiagnostics());
18
+ }
19
+ startBackgroundRefresh() {
20
+ this.options.cache.warm(this.keys);
21
+ const ttlMs = this.options.config.dashboardCacheTtlMs;
22
+ if (ttlMs <= 0 || this.warmTimer) {
23
+ return;
24
+ }
25
+ const intervalMs = Math.max(5_000, ttlMs);
26
+ this.warmTimer = setInterval(() => this.options.cache.warm(this.keys), intervalMs);
27
+ this.warmTimer.unref?.();
28
+ }
29
+ stopBackgroundRefresh() {
30
+ if (!this.warmTimer) {
31
+ return;
32
+ }
33
+ clearInterval(this.warmTimer);
34
+ this.warmTimer = undefined;
35
+ }
36
+ async version() {
37
+ return this.cached("version");
38
+ }
39
+ async diagnostics() {
40
+ return this.cached("diagnostics");
41
+ }
42
+ async adapterHealth() {
43
+ return this.cached("adapterHealth");
44
+ }
45
+ invalidate(key) {
46
+ this.options.cache.invalidate(key);
47
+ if (key) {
48
+ this.options.cache.warm([key]);
49
+ return;
50
+ }
51
+ this.options.cache.warm(this.keys);
52
+ }
53
+ async cached(key) {
54
+ return (await this.options.cache.get(key, this.options.config.dashboardCacheTtlMs)).value;
55
+ }
56
+ async produceVersion() {
57
+ const cliOptions = this.options.cliPathOptions();
58
+ const [health, state, versionChecks] = await Promise.all([
59
+ getConnectorHealth(cliOptions),
60
+ readConnectorState(),
61
+ getVersionChecks(cliOptions),
62
+ ]);
63
+ return {
64
+ health,
65
+ state,
66
+ versionChecks,
67
+ };
68
+ }
69
+ async produceDiagnostics() {
70
+ const cliOptions = this.options.cliPathOptions();
71
+ const [health, versionChecks, snapshot, session] = await Promise.all([
72
+ getConnectorHealth(cliOptions),
73
+ getVersionChecks(cliOptions),
74
+ this.options.snapshot(),
75
+ this.options.getSession(),
76
+ ]);
77
+ return {
78
+ health,
79
+ versionChecks,
80
+ snapshot,
81
+ runtime: {
82
+ stateBackend: this.options.config.stateBackend,
83
+ sourceWorkspace: this.options.config.workspace,
84
+ queuePaused: this.options.queuePaused(),
85
+ externalMirror: this.options.externalMirror(),
86
+ agentDiagnostics: getAgentDiagnostics(session, this.options.config),
87
+ slackDiagnostics: await collectSlackDiagnostics({
88
+ config: this.options.config,
89
+ timeoutMs: 2_500,
90
+ rateLimit: getSlackRateLimitMetrics(),
91
+ }),
92
+ },
93
+ };
94
+ }
95
+ async produceAdapterHealth() {
96
+ const cliOptions = this.options.cliPathOptions();
97
+ const [health, versions] = await Promise.all([
98
+ getConnectorHealth(cliOptions),
99
+ getVersionChecks(cliOptions),
100
+ ]);
101
+ return Promise.all(listAgentAdapterDescriptors().map(async (descriptor) => {
102
+ const enabled = enabledAgents(this.options.config).includes(descriptor.id);
103
+ const auth = descriptor.capabilities.auth && enabled
104
+ ? await this.options.authStatus(descriptor.id).catch((error) => ({
105
+ agentId: descriptor.id,
106
+ agentLabel: descriptor.label,
107
+ supported: descriptor.capabilities.auth,
108
+ authenticated: false,
109
+ detail: friendlyErrorText(error),
110
+ loginSupported: descriptor.capabilities.login,
111
+ logoutSupported: descriptor.capabilities.logout,
112
+ }))
113
+ : null;
114
+ const cli = cliHealthForAgent(descriptor.id, health);
115
+ const version = versionCheckForAgent(descriptor.id, versions);
116
+ return {
117
+ id: descriptor.id,
118
+ label: descriptor.label,
119
+ enabled,
120
+ status: descriptor.status === "available" ? (enabled ? "enabled" : "disabled") : "planned",
121
+ auth: {
122
+ supported: descriptor.capabilities.auth,
123
+ authenticated: auth ? auth.authenticated : null,
124
+ method: auth?.method,
125
+ detail: auth?.detail,
126
+ },
127
+ cli,
128
+ version: {
129
+ installed: version.installedLabel,
130
+ latest: version.latestVersion,
131
+ status: version.status,
132
+ detail: version.detail,
133
+ },
134
+ capabilities: descriptor.capabilities,
135
+ notes: descriptor.notes,
136
+ };
137
+ }));
138
+ }
139
+ }