@nordbyte/nordrelay 0.8.1 → 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 -1206
  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/peers/peer-discovery-jobs.js +206 -0
  67. package/dist/peers/peer-discovery.js +223 -0
  68. package/dist/peers/peer-health-monitor.js +49 -0
  69. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  70. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  71. package/dist/{peer-server.js → peers/peer-server.js} +3 -2
  72. package/dist/{peer-store.js → peers/peer-store.js} +80 -9
  73. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  74. package/dist/peers/peer-web-proxy-contract.js +127 -0
  75. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  76. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  77. package/dist/runtime/relay-auth-service.js +63 -0
  78. package/dist/runtime/relay-dashboard-service.js +139 -0
  79. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  80. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  81. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  82. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  83. package/dist/runtime/relay-runtime-sessions.js +623 -0
  84. package/dist/runtime/relay-runtime-types.js +1 -0
  85. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  86. package/dist/runtime/relay-runtime.js +451 -0
  87. package/dist/runtime/runtime-cache.js +117 -0
  88. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  89. package/dist/{operations.js → support/operations.js} +7 -7
  90. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  91. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  92. package/dist/web/web-api-types.js +1 -0
  93. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  94. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  95. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  96. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  97. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  98. package/dist/web/web-dashboard-security.js +14 -0
  99. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  100. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  101. package/dist/web/web-performance.js +60 -0
  102. package/dist/web/web-rate-limit.js +19 -0
  103. package/dist/{web-state.js → web/web-state.js} +74 -5
  104. package/dist/webui-assets/dashboard.css +171 -10
  105. package/dist/webui-assets/dashboard.js +514 -48
  106. package/dist/webui-assets/favicon.ico +0 -0
  107. package/dist/webui-assets/favicon.png +0 -0
  108. package/dist/webui-assets/logo.png +0 -0
  109. package/package.json +4 -3
  110. package/plugins/nordrelay/scripts/nordrelay.mjs +13 -4
  111. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  112. package/dist/relay-runtime.js +0 -1916
  113. package/dist/runtime-cache.js +0 -57
  114. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  115. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  116. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  117. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  118. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  119. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  120. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  121. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  122. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  123. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  124. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  125. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  126. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  127. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  128. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  129. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  130. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  131. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  132. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  133. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  134. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  135. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  136. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  137. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  138. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  139. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  140. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  141. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  142. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  143. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  144. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  145. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  146. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  147. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  148. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  149. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  150. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  151. /package/dist/{format.js → core/format.js} +0 -0
  152. /package/dist/{logger.js → core/logger.js} +0 -0
  153. /package/dist/{redaction.js → core/redaction.js} +0 -0
  154. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  155. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  156. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  157. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  158. /package/dist/{peer-client.js → peers/peer-client.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
@@ -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(),
@@ -88,6 +91,7 @@ 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;
@@ -109,6 +113,7 @@ export class PeerStore {
109
113
  const record = {
110
114
  id: input.id ?? randomUUID().replace(/-/g, "").slice(0, 12),
111
115
  name: input.name.trim() || "NordRelay peer",
116
+ group: normalizeGroup(input.group),
112
117
  url: input.url,
113
118
  nodeId: input.nodeId,
114
119
  publicKey: input.publicKey,
@@ -123,6 +128,7 @@ export class PeerStore {
123
128
  workspaceAliases: normalizeWorkspaceAliases(input.workspaceAliases ?? {}),
124
129
  createdAt: now,
125
130
  updatedAt: now,
131
+ healthHistory: [],
126
132
  };
127
133
  payload.peers.push(record);
128
134
  next = clonePeer(record);
@@ -141,6 +147,8 @@ export class PeerStore {
141
147
  }
142
148
  if (patch.name !== undefined)
143
149
  peer.name = patch.name.trim() || peer.name;
150
+ if (patch.group !== undefined)
151
+ peer.group = normalizeGroup(patch.group);
144
152
  if (patch.url !== undefined)
145
153
  peer.url = patch.url.trim() || undefined;
146
154
  if (patch.enabled !== undefined)
@@ -161,18 +169,53 @@ export class PeerStore {
161
169
  }
162
170
  return next;
163
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
+ }
164
188
  markSeen(id, patch = {}) {
165
- this.patchPeer(id, {
166
- lastSeenAt: new Date().toISOString(),
167
- lastCheckedAt: new Date().toISOString(),
189
+ const checkedAt = new Date().toISOString();
190
+ this.patchPeer(id, (peer) => ({
191
+ lastSeenAt: checkedAt,
192
+ lastCheckedAt: checkedAt,
168
193
  lastLatencyMs: patch.latencyMs,
169
194
  remoteVersion: patch.remoteVersion,
170
195
  remoteStatus: patch.remoteStatus ?? "online",
171
196
  lastError: undefined,
172
- });
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
+ }));
173
205
  }
174
206
  markError(id, error) {
175
- 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
+ }));
176
219
  }
177
220
  revokePeer(id) {
178
221
  let removed = false;
@@ -200,7 +243,7 @@ export class PeerStore {
200
243
  const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
201
244
  if (!peer)
202
245
  return;
203
- Object.assign(peer, patch);
246
+ Object.assign(peer, typeof patch === "function" ? patch(peer) : patch);
204
247
  });
205
248
  }
206
249
  mutatePayload(mutator) {
@@ -219,13 +262,16 @@ export class PeerStore {
219
262
  version: 1,
220
263
  peers: payload.peers.filter(isPeerRecord).map((peer) => ({
221
264
  ...peer,
265
+ group: normalizeGroup(peer.group),
222
266
  scopes: normalizeScopes(peer.scopes),
223
267
  allowedAgents: normalizeAgents(peer.allowedAgents),
224
268
  allowedWorkspaceRoots: normalizeWorkspaceRoots(peer.allowedWorkspaceRoots),
225
269
  workspaceAliases: normalizeWorkspaceAliases(peer.workspaceAliases ?? {}),
270
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
226
271
  })),
227
272
  invitations: payload.invitations.filter(isInvitationRecord).map((invitation) => ({
228
273
  ...invitation,
274
+ group: normalizeGroup(invitation.group),
229
275
  scopes: normalizeScopes(invitation.scopes),
230
276
  allowedAgents: normalizeAgents(invitation.allowedAgents),
231
277
  allowedWorkspaceRoots: normalizeWorkspaceRoots(invitation.allowedWorkspaceRoots),
@@ -272,6 +318,30 @@ function normalizeWorkspaceAliases(value) {
272
318
  }
273
319
  return aliases;
274
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
+ }
275
345
  function clonePeer(peer) {
276
346
  return {
277
347
  ...peer,
@@ -279,6 +349,7 @@ function clonePeer(peer) {
279
349
  allowedAgents: [...peer.allowedAgents],
280
350
  allowedWorkspaceRoots: [...peer.allowedWorkspaceRoots],
281
351
  workspaceAliases: { ...peer.workspaceAliases },
352
+ healthHistory: normalizeHealthHistory(peer.healthHistory),
282
353
  };
283
354
  }
284
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],