@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.
- package/.env.example +9 -0
- package/README.md +84 -1205
- package/dist/{access-control.js → access/access-control.js} +1 -1
- package/dist/{audit-log.js → access/audit-log.js} +32 -15
- package/dist/{session-locks.js → access/session-locks.js} +1 -1
- package/dist/{user-management.js → access/user-management.js} +1 -1
- package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
- package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
- package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
- package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
- package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
- package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
- package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
- package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
- package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
- package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
- package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
- package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
- package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
- package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
- package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
- package/dist/agents/shared/agent-auth-commands.js +30 -0
- package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
- package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
- package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
- package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
- package/dist/{discord-bot.js → channels/discord/discord-bot.js} +176 -451
- package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
- package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
- package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
- package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
- package/dist/channels/shared/channel-bridge-controller.js +69 -0
- package/dist/channels/shared/channel-cli-artifacts.js +51 -0
- package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
- package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
- package/dist/channels/shared/channel-external-monitor.js +52 -0
- package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
- package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
- package/dist/channels/shared/channel-prompt-queue.js +37 -0
- package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
- package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
- package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
- package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
- package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
- package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
- package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
- package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
- package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
- package/dist/{bot.js → channels/telegram/bot.js} +195 -430
- package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
- package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
- package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
- package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
- package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
- package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
- package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
- package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
- package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
- package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
- package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
- package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
- package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
- package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
- package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
- package/dist/{config.js → core/config.js} +11 -3
- package/dist/core/pagination.js +22 -0
- package/dist/index.js +27 -23
- package/dist/peers/peer-discovery-jobs.js +206 -0
- package/dist/peers/peer-discovery.js +223 -0
- package/dist/peers/peer-health-monitor.js +49 -0
- package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
- package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
- package/dist/{peer-server.js → peers/peer-server.js} +3 -2
- package/dist/{peer-store.js → peers/peer-store.js} +96 -9
- package/dist/{peer-types.js → peers/peer-types.js} +28 -0
- package/dist/peers/peer-web-proxy-contract.js +129 -0
- package/dist/{metrics.js → runtime/metrics.js} +5 -3
- package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
- package/dist/runtime/relay-auth-service.js +63 -0
- package/dist/runtime/relay-dashboard-service.js +139 -0
- package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
- package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
- package/dist/runtime/relay-runtime-active-sessions.js +387 -0
- package/dist/runtime/relay-runtime-dashboard.js +204 -0
- package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
- package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
- package/dist/runtime/relay-runtime-sessions.js +631 -0
- package/dist/runtime/relay-runtime-trace.js +92 -0
- package/dist/runtime/relay-runtime-types.js +1 -0
- package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
- package/dist/runtime/relay-runtime.js +461 -0
- package/dist/runtime/runtime-cache.js +117 -0
- package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
- package/dist/{session-registry.js → state/session-registry.js} +3 -3
- package/dist/{operations.js → support/operations.js} +7 -7
- package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
- package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
- package/dist/web/web-api-types.js +1 -0
- package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
- package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
- package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
- package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
- package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
- package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
- package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
- package/dist/web/web-dashboard-security.js +14 -0
- package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
- package/dist/web/web-dashboard-ui.js +56 -0
- package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
- package/dist/web/web-performance.js +62 -0
- package/dist/web/web-rate-limit.js +19 -0
- package/dist/{web-state.js → web/web-state.js} +107 -9
- package/dist/webui-assets/dashboard.css +398 -49
- package/dist/webui-assets/dashboard.js +1239 -103
- package/dist/webui-assets/favicon.ico +0 -0
- package/dist/webui-assets/favicon.png +0 -0
- package/dist/webui-assets/logo.png +0 -0
- package/package.json +6 -3
- package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
- package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
- package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
- package/scripts/postinstall.mjs +122 -0
- package/dist/relay-runtime.js +0 -1916
- package/dist/runtime-cache.js +0 -57
- package/dist/web-dashboard-ui.js +0 -20
- /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
- /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
- /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
- /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
- /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
- /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
- /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
- /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
- /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
- /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
- /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
- /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
- /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
- /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
- /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
- /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
- /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
- /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
- /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
- /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
- /package/dist/{agent.js → agents/shared/agent.js} +0 -0
- /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
- /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
- /package/dist/{voice.js → artifacts/voice.js} +0 -0
- /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
- /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
- /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
- /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
- /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
- /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
- /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
- /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
- /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
- /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
- /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
- /package/dist/{activity-events.js → core/activity-events.js} +0 -0
- /package/dist/{error-messages.js → core/error-messages.js} +0 -0
- /package/dist/{format.js → core/format.js} +0 -0
- /package/dist/{logger.js → core/logger.js} +0 -0
- /package/dist/{redaction.js → core/redaction.js} +0 -0
- /package/dist/{settings-service.js → core/settings-service.js} +0 -0
- /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
- /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
- /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
- /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
- /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
- /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
- /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
- /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
- /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
- /package/dist/{job-store.js → state/job-store.js} +0 -0
- /package/dist/{persistence.js → state/persistence.js} +0 -0
- /package/dist/{state-backend.js → state/state-backend.js} +0 -0
- /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 "
|
|
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 "
|
|
3
|
-
import { listAgentAdapterDescriptors } from "
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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 "
|
|
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 "
|
|
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 "
|
|
6
|
-
import { AGENT_IDS, isAgentId } from "
|
|
7
|
-
import { readJsonFileWithBackup, writeJsonFileAtomic } from "
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
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) {
|