@nordbyte/nordrelay 0.8.1 → 0.8.3

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 (179) hide show
  1. package/.env.example +9 -0
  2. package/README.md +84 -1205
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +32 -15
  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} +176 -451
  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/channels/shared/channel-prompt-queue.js +37 -0
  40. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
  41. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  42. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  43. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  44. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
  45. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  46. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  47. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  48. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  49. package/dist/{bot.js → channels/telegram/bot.js} +195 -430
  50. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  51. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  52. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  53. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  54. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  55. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  56. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  57. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  58. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  59. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  60. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  61. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  62. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  63. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  64. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  65. package/dist/{config.js → core/config.js} +11 -3
  66. package/dist/core/pagination.js +22 -0
  67. package/dist/index.js +27 -23
  68. package/dist/peers/peer-discovery-jobs.js +206 -0
  69. package/dist/peers/peer-discovery.js +223 -0
  70. package/dist/peers/peer-health-monitor.js +49 -0
  71. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  72. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  73. package/dist/{peer-server.js → peers/peer-server.js} +3 -2
  74. package/dist/{peer-store.js → peers/peer-store.js} +96 -9
  75. package/dist/{peer-types.js → peers/peer-types.js} +28 -0
  76. package/dist/peers/peer-web-proxy-contract.js +129 -0
  77. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  78. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  79. package/dist/runtime/relay-auth-service.js +63 -0
  80. package/dist/runtime/relay-dashboard-service.js +139 -0
  81. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
  82. package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
  83. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  84. package/dist/runtime/relay-runtime-dashboard.js +204 -0
  85. package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
  86. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
  87. package/dist/runtime/relay-runtime-sessions.js +631 -0
  88. package/dist/runtime/relay-runtime-trace.js +92 -0
  89. package/dist/runtime/relay-runtime-types.js +1 -0
  90. package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
  91. package/dist/runtime/relay-runtime.js +461 -0
  92. package/dist/runtime/runtime-cache.js +117 -0
  93. package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
  94. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  95. package/dist/{operations.js → support/operations.js} +7 -7
  96. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  97. package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
  98. package/dist/web/web-api-types.js +1 -0
  99. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
  100. package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
  101. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
  102. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  103. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
  104. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
  105. package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
  106. package/dist/web/web-dashboard-security.js +14 -0
  107. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
  108. package/dist/web/web-dashboard-ui.js +56 -0
  109. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  110. package/dist/web/web-performance.js +62 -0
  111. package/dist/web/web-rate-limit.js +19 -0
  112. package/dist/{web-state.js → web/web-state.js} +107 -9
  113. package/dist/webui-assets/dashboard.css +398 -49
  114. package/dist/webui-assets/dashboard.js +1239 -103
  115. package/dist/webui-assets/favicon.ico +0 -0
  116. package/dist/webui-assets/favicon.png +0 -0
  117. package/dist/webui-assets/logo.png +0 -0
  118. package/package.json +6 -3
  119. package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
  120. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  121. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  122. package/scripts/postinstall.mjs +122 -0
  123. package/dist/relay-runtime.js +0 -1916
  124. package/dist/runtime-cache.js +0 -57
  125. package/dist/web-dashboard-ui.js +0 -20
  126. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  127. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  128. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  129. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  130. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  131. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  132. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  133. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  134. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  135. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  136. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  137. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  138. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  139. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  140. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  141. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  142. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  143. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  144. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  145. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  146. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  147. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  148. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  149. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  150. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  151. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  152. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  153. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  154. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  155. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  156. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  157. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  158. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  159. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  160. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  161. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  162. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  163. /package/dist/{format.js → core/format.js} +0 -0
  164. /package/dist/{logger.js → core/logger.js} +0 -0
  165. /package/dist/{redaction.js → core/redaction.js} +0 -0
  166. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  167. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  168. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  169. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  170. /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
  171. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  172. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  173. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  174. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  175. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  176. /package/dist/{job-store.js → state/job-store.js} +0 -0
  177. /package/dist/{persistence.js → state/persistence.js} +0 -0
  178. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  179. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
@@ -0,0 +1,223 @@
1
+ import dns from "node:dns/promises";
2
+ import net from "node:net";
3
+ import os from "node:os";
4
+ import { checkPeerIdentityEndpoint } from "./peer-client.js";
5
+ export async function discoverLanPeers(config, options = {}) {
6
+ const warnings = [];
7
+ const targets = await buildDiscoveryTargets(config, options.maxHosts ?? 512, warnings, options.targets ?? []);
8
+ const candidates = [];
9
+ const concurrency = Math.max(1, Math.min(options.concurrency ?? 32, 128));
10
+ let index = 0;
11
+ let scanned = 0;
12
+ async function worker() {
13
+ while (index < targets.length) {
14
+ if (options.signal?.aborted) {
15
+ return;
16
+ }
17
+ const target = targets[index++];
18
+ const startedAt = Date.now();
19
+ const probe = await checkPeerIdentityEndpoint(target.url, { timeoutMs: options.timeoutMs ?? config.peerDiscoveryTimeoutMs });
20
+ scanned += 1;
21
+ if (!probe.ok || !probe.identity) {
22
+ options.onProgress?.({ scanned, total: targets.length, target: target.url });
23
+ continue;
24
+ }
25
+ const candidate = {
26
+ url: target.url,
27
+ host: target.host,
28
+ port: target.port,
29
+ scheme: target.scheme,
30
+ nodeId: probe.identity.nodeId,
31
+ name: probe.identity.name,
32
+ fingerprint: probe.identity.fingerprint,
33
+ tlsFingerprint: probe.tlsFingerprint,
34
+ latencyMs: probe.latencyMs ?? Date.now() - startedAt,
35
+ };
36
+ candidates.push(candidate);
37
+ options.onProgress?.({ scanned, total: targets.length, candidate, target: target.url });
38
+ }
39
+ }
40
+ await Promise.all(Array.from({ length: Math.min(concurrency, targets.length) }, () => worker()));
41
+ return {
42
+ scanned,
43
+ candidates: dedupeCandidates(candidates),
44
+ warnings: options.signal?.aborted ? [...warnings, "Discovery was cancelled."] : warnings,
45
+ };
46
+ }
47
+ export async function countDiscoveryTargets(config, options = {}) {
48
+ return (await buildDiscoveryTargets(config, options.maxHosts ?? 512, [], options.targets ?? [])).length;
49
+ }
50
+ async function buildDiscoveryTargets(config, maxHosts, warnings, requestedTargets) {
51
+ const schemes = config.peerTlsEnabled ? ["https"] : ["http", "https"];
52
+ const explicitTargets = await customDiscoveryTargets(requestedTargets, config.peerPort, schemes, maxHosts, warnings);
53
+ if (explicitTargets.length > 0) {
54
+ return dedupeTargets(explicitTargets);
55
+ }
56
+ const targets = [];
57
+ const hosts = localSubnetHosts(maxHosts, warnings);
58
+ const mdnsHosts = await mdnsCandidateHosts(warnings);
59
+ if (hosts.length === 0 && mdnsHosts.length === 0) {
60
+ warnings.push("No private IPv4 LAN interface was found for peer discovery.");
61
+ }
62
+ for (const host of [...hosts, ...mdnsHosts]) {
63
+ for (const scheme of schemes) {
64
+ targets.push({ host, scheme, port: config.peerPort, url: formatDiscoveryUrl(scheme, host, config.peerPort) });
65
+ }
66
+ }
67
+ return dedupeTargets(targets);
68
+ }
69
+ async function customDiscoveryTargets(requested, port, schemes, maxHosts, warnings) {
70
+ const targets = [];
71
+ for (const raw of requested.flatMap((value) => value.split(/[\n, ]/)).map((value) => value.trim()).filter(Boolean)) {
72
+ if (/^https?:\/\//i.test(raw)) {
73
+ try {
74
+ const url = new URL(raw);
75
+ const scheme = url.protocol === "http:" ? "http" : "https";
76
+ const targetPort = Number(url.port || port);
77
+ targets.push({ host: url.hostname, scheme, port: targetPort, url: formatDiscoveryUrl(scheme, url.hostname, targetPort) });
78
+ }
79
+ catch {
80
+ warnings.push(`Ignored invalid discovery URL: ${raw}`);
81
+ }
82
+ continue;
83
+ }
84
+ for (const host of expandHostPattern(raw, maxHosts, warnings)) {
85
+ for (const scheme of schemes) {
86
+ targets.push({ host, scheme, port, url: formatDiscoveryUrl(scheme, host, port) });
87
+ }
88
+ }
89
+ }
90
+ return targets;
91
+ }
92
+ function expandHostPattern(raw, maxHosts, warnings) {
93
+ if (raw.includes("/")) {
94
+ return expandIpv4Cidr(raw, maxHosts, warnings);
95
+ }
96
+ const range = raw.match(/^(\d+\.\d+\.\d+\.)(\d+)-(\d+)$/);
97
+ if (range) {
98
+ const start = Number(range[2]);
99
+ const end = Number(range[3]);
100
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > 255 || start > end) {
101
+ warnings.push(`Ignored invalid IPv4 range: ${raw}`);
102
+ return [];
103
+ }
104
+ return Array.from({ length: Math.min(maxHosts, end - start + 1) }, (_, index) => `${range[1]}${start + index}`);
105
+ }
106
+ if (net.isIP(raw) || /^[a-z0-9_.-]+$/i.test(raw)) {
107
+ return [raw];
108
+ }
109
+ warnings.push(`Ignored invalid discovery target: ${raw}`);
110
+ return [];
111
+ }
112
+ function expandIpv4Cidr(raw, maxHosts, warnings) {
113
+ const [address, prefixText] = raw.split("/");
114
+ const prefix = Number(prefixText);
115
+ if (net.isIP(address) !== 4 || !Number.isInteger(prefix) || prefix < 16 || prefix > 32) {
116
+ warnings.push(`Ignored unsupported discovery CIDR: ${raw}. Use IPv4 /16 through /32.`);
117
+ return [];
118
+ }
119
+ const base = ipv4ToNumber(address);
120
+ const hostBits = 32 - prefix;
121
+ const mask = hostBits === 32 ? 0 : (0xffffffff << hostBits) >>> 0;
122
+ const network = base & mask;
123
+ const total = prefix === 32 ? 1 : Math.max(0, (2 ** hostBits) - 2);
124
+ const count = Math.min(total, maxHosts);
125
+ if (total > maxHosts) {
126
+ warnings.push(`CIDR ${raw} was limited to ${maxHosts} host candidates.`);
127
+ }
128
+ return Array.from({ length: count }, (_, index) => numberToIpv4(network + (prefix === 32 ? index : index + 1)));
129
+ }
130
+ async function mdnsCandidateHosts(warnings) {
131
+ const names = [`${os.hostname()}.local`, "nordrelay.local"];
132
+ const found = [];
133
+ for (const name of names) {
134
+ try {
135
+ await withTimeout(dns.lookup(name), 250);
136
+ found.push(name);
137
+ }
138
+ catch {
139
+ // mDNS support depends on the host resolver; absence is normal.
140
+ }
141
+ }
142
+ return found;
143
+ }
144
+ async function withTimeout(promise, timeoutMs) {
145
+ let timeout;
146
+ try {
147
+ return await Promise.race([
148
+ promise,
149
+ new Promise((_, reject) => {
150
+ timeout = setTimeout(() => reject(new Error("mDNS lookup timed out.")), timeoutMs);
151
+ timeout.unref?.();
152
+ }),
153
+ ]);
154
+ }
155
+ finally {
156
+ if (timeout)
157
+ clearTimeout(timeout);
158
+ }
159
+ }
160
+ function formatDiscoveryUrl(scheme, host, port) {
161
+ const displayHost = net.isIP(host) === 6 && !host.startsWith("[") ? `[${host}]` : host;
162
+ return `${scheme}://${displayHost}:${port}`;
163
+ }
164
+ function dedupeTargets(targets) {
165
+ const seen = new Set();
166
+ return targets.filter((target) => {
167
+ const key = target.url.toLowerCase();
168
+ if (seen.has(key))
169
+ return false;
170
+ seen.add(key);
171
+ return true;
172
+ });
173
+ }
174
+ function localSubnetHosts(maxHosts, warnings) {
175
+ const interfaces = os.networkInterfaces();
176
+ const hosts = new Set();
177
+ for (const items of Object.values(interfaces)) {
178
+ for (const item of items ?? []) {
179
+ if (item.family !== "IPv4" || item.internal || !isPrivateIPv4(item.address)) {
180
+ continue;
181
+ }
182
+ const parts = item.address.split(".").map(Number);
183
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part))) {
184
+ continue;
185
+ }
186
+ const prefix = parts.slice(0, 3).join(".");
187
+ for (let last = 1; last <= 254; last += 1) {
188
+ const host = `${prefix}.${last}`;
189
+ if (host !== item.address) {
190
+ hosts.add(host);
191
+ }
192
+ if (hosts.size >= maxHosts) {
193
+ warnings.push(`LAN discovery was limited to ${maxHosts} host candidates.`);
194
+ return [...hosts];
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return [...hosts];
200
+ }
201
+ function isPrivateIPv4(address) {
202
+ const [a, b] = address.split(".").map(Number);
203
+ return a === 10 ||
204
+ (a === 172 && b >= 16 && b <= 31) ||
205
+ (a === 192 && b === 168) ||
206
+ (a === 169 && b === 254);
207
+ }
208
+ function ipv4ToNumber(address) {
209
+ return address.split(".").map(Number).reduce((sum, part) => ((sum << 8) + part) >>> 0, 0);
210
+ }
211
+ function numberToIpv4(value) {
212
+ return [24, 16, 8, 0].map((shift) => (value >>> shift) & 255).join(".");
213
+ }
214
+ function dedupeCandidates(candidates) {
215
+ const byNode = new Map();
216
+ for (const candidate of candidates) {
217
+ const existing = byNode.get(candidate.nodeId);
218
+ if (!existing || (candidate.latencyMs ?? Number.MAX_SAFE_INTEGER) < (existing.latencyMs ?? Number.MAX_SAFE_INTEGER)) {
219
+ byNode.set(candidate.nodeId, candidate);
220
+ }
221
+ }
222
+ return [...byNode.values()].sort((a, b) => (a.name || a.host).localeCompare(b.name || b.host));
223
+ }
@@ -0,0 +1,49 @@
1
+ import { RemoteRelayClient } from "./peer-client.js";
2
+ import { PeerStore } from "./peer-store.js";
3
+ export function startPeerHealthMonitor(options) {
4
+ const store = new PeerStore(options.home);
5
+ const client = new RemoteRelayClient(store);
6
+ let running = false;
7
+ let timer;
8
+ async function checkNow() {
9
+ if (running) {
10
+ return;
11
+ }
12
+ running = true;
13
+ try {
14
+ const peers = store.list().filter((peer) => peer.enabled && peer.url);
15
+ await Promise.all(peers.map(async (peer) => {
16
+ try {
17
+ const startedAt = Date.now();
18
+ const result = await client.rpc(peer.id, "peer.ping");
19
+ const record = result && typeof result === "object" ? result : {};
20
+ store.markSeen(peer.id, {
21
+ latencyMs: Date.now() - startedAt,
22
+ remoteVersion: typeof record.version === "string" ? record.version : undefined,
23
+ remoteStatus: typeof record.status === "string" ? record.status : "online",
24
+ });
25
+ }
26
+ catch (error) {
27
+ store.markError(peer.id, error instanceof Error ? error.message : String(error));
28
+ }
29
+ }));
30
+ }
31
+ finally {
32
+ running = false;
33
+ }
34
+ }
35
+ if (options.config.peerHealthCheckMs > 0) {
36
+ timer = setInterval(() => void checkNow().catch(() => { }), options.config.peerHealthCheckMs);
37
+ timer.unref?.();
38
+ setTimeout(() => void checkNow().catch(() => { }), 2_000).unref?.();
39
+ }
40
+ return {
41
+ checkNow,
42
+ close() {
43
+ if (timer) {
44
+ clearInterval(timer);
45
+ timer = undefined;
46
+ }
47
+ },
48
+ };
49
+ }
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import selfsigned from "selfsigned";
6
- import { readJsonFileWithBackup, writeJsonFileAtomic, writeTextFileAtomic } from "./persistence.js";
6
+ import { readJsonFileWithBackup, writeJsonFileAtomic, writeTextFileAtomic } from "../state/persistence.js";
7
7
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
8
8
  export function loadOrCreatePeerIdentity(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, name) {
9
9
  const filePath = path.join(home, "identity.json");
@@ -48,6 +48,55 @@ export function loadOrCreatePeerIdentity(home = process.env.NORDRELAY_HOME || DE
48
48
  privateKey: identity.privateKey,
49
49
  };
50
50
  }
51
+ export function exportPeerIdentityBackup(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, name) {
52
+ const identity = loadOrCreatePeerIdentity(home, name);
53
+ const tls = existsSync(path.join(home, "tls", "peer.crt"))
54
+ ? ensurePeerTlsFiles(home, identity.public)
55
+ : undefined;
56
+ return {
57
+ version: 1,
58
+ exportedAt: new Date().toISOString(),
59
+ identity: identity.public,
60
+ privateKey: identity.privateKey,
61
+ tlsFingerprint: tls?.fingerprint,
62
+ };
63
+ }
64
+ export function restorePeerIdentityBackup(backup, home = process.env.NORDRELAY_HOME || DEFAULT_HOME) {
65
+ if (!backup || backup.version !== 1 || !backup.identity?.publicKey || !backup.privateKey) {
66
+ throw new Error("Invalid peer identity backup.");
67
+ }
68
+ const fingerprint = fingerprintForPublicKey(backup.identity.publicKey);
69
+ if (fingerprint !== backup.identity.fingerprint) {
70
+ throw new Error("Peer identity backup fingerprint does not match the public key.");
71
+ }
72
+ const probe = `nordrelay-identity-restore:${Date.now()}`;
73
+ const signature = signPeerPayload(backup.privateKey, probe);
74
+ if (!verifyPeerPayload(backup.identity.publicKey, probe, signature)) {
75
+ throw new Error("Peer identity backup private key does not match the public key.");
76
+ }
77
+ const payload = {
78
+ nodeId: backup.identity.nodeId,
79
+ name: backup.identity.name || defaultNodeName(),
80
+ publicKey: backup.identity.publicKey,
81
+ privateKey: backup.privateKey,
82
+ fingerprint,
83
+ createdAt: backup.identity.createdAt || new Date().toISOString(),
84
+ };
85
+ const filePath = path.join(home, "identity.json");
86
+ mkdirSync(path.dirname(filePath), { recursive: true });
87
+ writeJsonFileAtomic(filePath, payload);
88
+ chmodSync(filePath, 0o600);
89
+ return {
90
+ public: {
91
+ nodeId: payload.nodeId,
92
+ name: payload.name,
93
+ publicKey: payload.publicKey,
94
+ fingerprint: payload.fingerprint,
95
+ createdAt: payload.createdAt,
96
+ },
97
+ privateKey: payload.privateKey,
98
+ };
99
+ }
51
100
  export function ensurePeerTlsFiles(home = process.env.NORDRELAY_HOME || DEFAULT_HOME, identity) {
52
101
  const certDir = path.join(home, "tls");
53
102
  const certPath = path.join(certDir, "peer.crt");
@@ -1,11 +1,12 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { enabledAgents } from "./agent-factory.js";
3
- import { listAgentAdapterDescriptors } from "./agent-adapter.js";
4
- import { isAgentId } from "./agent.js";
5
- import { permissionForWebRequest } from "./access-control.js";
6
- import { listChannelDescriptors } from "./channel-adapter.js";
7
- import { friendlyErrorText } from "./error-messages.js";
8
- import { getPackageVersion } from "./operations.js";
2
+ import { enabledAgents } from "../agents/shared/agent-factory.js";
3
+ import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
4
+ import { buildAdapterConformanceMatrix } from "../agents/shared/adapter-conformance.js";
5
+ import { isAgentId } from "../agents/shared/agent.js";
6
+ import { permissionForWebRequest } from "../access/access-control.js";
7
+ import { listChannelDescriptors } from "../channels/shared/channel-adapter.js";
8
+ import { friendlyErrorText } from "../core/error-messages.js";
9
+ import { getPackageVersion } from "../support/operations.js";
9
10
  import { checkPeerEndpoint } from "./peer-client.js";
10
11
  export class PeerRuntimeService {
11
12
  config;
@@ -147,6 +148,12 @@ export class PeerRuntimeService {
147
148
  if (method === "GET" && path === "/api/adapters/health") {
148
149
  return { adapters: (await runtime.adapterHealth()).filter((adapter) => this.canUseAgent(peer, adapter.id)) };
149
150
  }
151
+ if (method === "GET" && path === "/api/adapters/conformance") {
152
+ return buildAdapterConformanceMatrix({
153
+ agents: listAgentAdapterDescriptors().filter((adapter) => this.canUseAgent(peer, adapter.id)),
154
+ channels: listChannelDescriptors(),
155
+ });
156
+ }
150
157
  if (method === "GET" && path === "/api/diagnostics")
151
158
  return this.scopedDiagnostics(peer, await runtime.diagnostics());
152
159
  if (method === "GET" && path === "/api/diagnostics/bundle") {
@@ -163,6 +170,13 @@ export class PeerRuntimeService {
163
170
  this.assertAgentScope(peer, agentId);
164
171
  return this.scopedControlOptions(peer, await runtime.controlOptions(agentId));
165
172
  }
173
+ if (method === "GET" && path === "/api/locks")
174
+ return { locks: runtime.locks() };
175
+ if (method === "POST" && path === "/api/locks") {
176
+ return { lock: runtime.lockWebSession(stringValue(body.ownerName) || `Peer ${peer.name}`, remoteActor), locks: runtime.locks() };
177
+ }
178
+ if (method === "DELETE" && path === "/api/locks")
179
+ return runtime.unlockWebSession(remoteActor);
166
180
  if (method === "GET" && path === "/api/auth/status") {
167
181
  const agentId = parseAgentId(query.agent);
168
182
  this.assertAgentScope(peer, agentId);
@@ -281,6 +295,14 @@ export class PeerRuntimeService {
281
295
  await this.assertCurrentSessionScope(peer, runtime);
282
296
  return { messages: await runtime.chatHistory(numberValue(query.limit, 200)) };
283
297
  }
298
+ if (method === "GET" && path === "/api/chat/mirror") {
299
+ await this.assertCurrentSessionScope(peer, runtime);
300
+ return runtime.webMirrorPreference("");
301
+ }
302
+ if (method === "POST" && path === "/api/chat/mirror") {
303
+ await this.assertCurrentSessionScope(peer, runtime);
304
+ return runtime.webMirrorPreference(stringValue(body.argument) || stringValue(body.mode) || "", remoteActor);
305
+ }
284
306
  if (method === "DELETE" && path === "/api/chat/history") {
285
307
  await this.assertCurrentSessionScope(peer, runtime);
286
308
  return runtime.clearChatHistory(remoteActor);
@@ -1,7 +1,7 @@
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
7
  import { checkPeerIdentityEndpoint } from "./peer-client.js";
@@ -9,7 +9,7 @@ import { peerRuntimeContextKey } from "./peer-context.js";
9
9
  import { PeerStore } from "./peer-store.js";
10
10
  import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
11
11
  import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
12
- import { RelayRuntime } from "./relay-runtime.js";
12
+ import { RelayRuntime } from "../runtime/relay-runtime.js";
13
13
  export async function startPeerServer(options) {
14
14
  const { config, runtime } = options;
15
15
  if (!config.peerEnabled) {
@@ -147,6 +147,7 @@ export async function startPeerServer(options) {
147
147
  const secret = createSharedSecret();
148
148
  const peer = store.upsertPeer({
149
149
  name: body.name?.trim() || body.identity.name || invitation.name,
150
+ group: invitation.group,
150
151
  url: publicUrl,
151
152
  nodeId: body.identity.nodeId,
152
153
  publicKey: body.identity.publicKey,
@@ -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(),
@@ -58,6 +61,22 @@ export class PeerStore {
58
61
  });
59
62
  return { invitation: publicInvitation(invitation), code };
60
63
  }
64
+ createRotationInvitation(id, options = {}) {
65
+ const peer = this.get(id);
66
+ if (!peer) {
67
+ throw new Error("Peer not found.");
68
+ }
69
+ const created = this.createInvitation({
70
+ name: `${peer.name} rotation`,
71
+ group: peer.group,
72
+ expiresInMs: options.expiresInMs,
73
+ scopes: peer.scopes,
74
+ allowedAgents: peer.allowedAgents,
75
+ allowedWorkspaceRoots: peer.allowedWorkspaceRoots,
76
+ workspaceAliases: peer.workspaceAliases,
77
+ });
78
+ return { peer: publicPeer(peer), ...created };
79
+ }
61
80
  consumeInvitation(code, usedByNodeId) {
62
81
  const trimmed = code.trim();
63
82
  if (!trimmed) {
@@ -88,6 +107,7 @@ export class PeerStore {
88
107
  const existing = payload.peers.find((peer) => peer.nodeId === input.nodeId || (input.id && peer.id === input.id));
89
108
  if (existing) {
90
109
  existing.name = input.name.trim() || existing.name;
110
+ existing.group = normalizeGroup(input.group) ?? existing.group;
91
111
  existing.url = input.url ?? existing.url;
92
112
  existing.publicKey = input.publicKey;
93
113
  existing.fingerprint = input.fingerprint;
@@ -109,6 +129,7 @@ export class PeerStore {
109
129
  const record = {
110
130
  id: input.id ?? randomUUID().replace(/-/g, "").slice(0, 12),
111
131
  name: input.name.trim() || "NordRelay peer",
132
+ group: normalizeGroup(input.group),
112
133
  url: input.url,
113
134
  nodeId: input.nodeId,
114
135
  publicKey: input.publicKey,
@@ -123,6 +144,7 @@ export class PeerStore {
123
144
  workspaceAliases: normalizeWorkspaceAliases(input.workspaceAliases ?? {}),
124
145
  createdAt: now,
125
146
  updatedAt: now,
147
+ healthHistory: [],
126
148
  };
127
149
  payload.peers.push(record);
128
150
  next = clonePeer(record);
@@ -141,6 +163,8 @@ export class PeerStore {
141
163
  }
142
164
  if (patch.name !== undefined)
143
165
  peer.name = patch.name.trim() || peer.name;
166
+ if (patch.group !== undefined)
167
+ peer.group = normalizeGroup(patch.group);
144
168
  if (patch.url !== undefined)
145
169
  peer.url = patch.url.trim() || undefined;
146
170
  if (patch.enabled !== undefined)
@@ -161,18 +185,53 @@ export class PeerStore {
161
185
  }
162
186
  return next;
163
187
  }
188
+ updatePeerTlsFingerprint(id, tlsFingerprint) {
189
+ let next = null;
190
+ this.mutatePayload((payload) => {
191
+ const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
192
+ if (!peer) {
193
+ throw new Error("Peer not found.");
194
+ }
195
+ peer.tlsFingerprint = tlsFingerprint || undefined;
196
+ peer.updatedAt = new Date().toISOString();
197
+ next = clonePeer(peer);
198
+ });
199
+ if (!next) {
200
+ throw new Error("Peer not found.");
201
+ }
202
+ return next;
203
+ }
164
204
  markSeen(id, patch = {}) {
165
- this.patchPeer(id, {
166
- lastSeenAt: new Date().toISOString(),
167
- lastCheckedAt: new Date().toISOString(),
205
+ const checkedAt = new Date().toISOString();
206
+ this.patchPeer(id, (peer) => ({
207
+ lastSeenAt: checkedAt,
208
+ lastCheckedAt: checkedAt,
168
209
  lastLatencyMs: patch.latencyMs,
169
210
  remoteVersion: patch.remoteVersion,
170
211
  remoteStatus: patch.remoteStatus ?? "online",
171
212
  lastError: undefined,
172
- });
213
+ healthHistory: appendHealthSample(peer.healthHistory, {
214
+ checkedAt,
215
+ status: "online",
216
+ latencyMs: patch.latencyMs,
217
+ remoteVersion: patch.remoteVersion,
218
+ remoteStatus: patch.remoteStatus ?? "online",
219
+ }),
220
+ }));
173
221
  }
174
222
  markError(id, error) {
175
- this.patchPeer(id, { lastError: error, remoteStatus: "offline", lastCheckedAt: new Date().toISOString(), updatedAt: new Date().toISOString() });
223
+ const checkedAt = new Date().toISOString();
224
+ this.patchPeer(id, (peer) => ({
225
+ lastError: error,
226
+ remoteStatus: "offline",
227
+ lastCheckedAt: checkedAt,
228
+ updatedAt: checkedAt,
229
+ healthHistory: appendHealthSample(peer.healthHistory, {
230
+ checkedAt,
231
+ status: "offline",
232
+ error,
233
+ }),
234
+ }));
176
235
  }
177
236
  revokePeer(id) {
178
237
  let removed = false;
@@ -200,7 +259,7 @@ export class PeerStore {
200
259
  const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
201
260
  if (!peer)
202
261
  return;
203
- Object.assign(peer, patch);
262
+ Object.assign(peer, typeof patch === "function" ? patch(peer) : patch);
204
263
  });
205
264
  }
206
265
  mutatePayload(mutator) {
@@ -219,13 +278,16 @@ export class PeerStore {
219
278
  version: 1,
220
279
  peers: payload.peers.filter(isPeerRecord).map((peer) => ({
221
280
  ...peer,
281
+ group: normalizeGroup(peer.group),
222
282
  scopes: normalizeScopes(peer.scopes),
223
283
  allowedAgents: normalizeAgents(peer.allowedAgents),
224
284
  allowedWorkspaceRoots: normalizeWorkspaceRoots(peer.allowedWorkspaceRoots),
225
285
  workspaceAliases: normalizeWorkspaceAliases(peer.workspaceAliases ?? {}),
286
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
226
287
  })),
227
288
  invitations: payload.invitations.filter(isInvitationRecord).map((invitation) => ({
228
289
  ...invitation,
290
+ group: normalizeGroup(invitation.group),
229
291
  scopes: normalizeScopes(invitation.scopes),
230
292
  allowedAgents: normalizeAgents(invitation.allowedAgents),
231
293
  allowedWorkspaceRoots: normalizeWorkspaceRoots(invitation.allowedWorkspaceRoots),
@@ -272,6 +334,30 @@ function normalizeWorkspaceAliases(value) {
272
334
  }
273
335
  return aliases;
274
336
  }
337
+ function normalizeGroup(value) {
338
+ const group = typeof value === "string" ? value.trim() : "";
339
+ return group ? group.slice(0, 80) : undefined;
340
+ }
341
+ function normalizeHealthHistory(value) {
342
+ if (!Array.isArray(value)) {
343
+ return [];
344
+ }
345
+ return value
346
+ .filter((item) => {
347
+ if (!item || typeof item !== "object" || Array.isArray(item))
348
+ return false;
349
+ const record = item;
350
+ return typeof record.checkedAt === "string" && (record.status === "online" || record.status === "offline");
351
+ })
352
+ .slice(-MAX_HEALTH_HISTORY)
353
+ .map((item) => ({ ...item }));
354
+ }
355
+ function appendHealthSample(history, sample) {
356
+ return [...normalizeHealthHistory(history), sample].slice(-MAX_HEALTH_HISTORY);
357
+ }
358
+ function listGroups(payload) {
359
+ return [...new Set(payload.peers.map((peer) => normalizeGroup(peer.group)).filter((group) => Boolean(group)))].sort();
360
+ }
275
361
  function clonePeer(peer) {
276
362
  return {
277
363
  ...peer,
@@ -279,6 +365,7 @@ function clonePeer(peer) {
279
365
  allowedAgents: [...peer.allowedAgents],
280
366
  allowedWorkspaceRoots: [...peer.allowedWorkspaceRoots],
281
367
  workspaceAliases: { ...peer.workspaceAliases },
368
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
282
369
  };
283
370
  }
284
371
  function mergeDirection(left, right) {