@nordbyte/nordrelay 0.7.0 → 0.8.1
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 +35 -0
- package/README.md +118 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +90 -2
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-server.js +20 -4
- package/dist/peer-store.js +17 -2
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +546 -145
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
package/dist/peer-client.js
CHANGED
|
@@ -4,6 +4,73 @@ import { createPairingSignaturePayload, signPeerPayload, fingerprintForPublicKey
|
|
|
4
4
|
import { signPeerRequest } from "./peer-auth.js";
|
|
5
5
|
import { PeerStore } from "./peer-store.js";
|
|
6
6
|
import { PEER_PROTOCOL_VERSION, } from "./peer-types.js";
|
|
7
|
+
export async function checkPeerEndpoint(url, options = {}) {
|
|
8
|
+
const target = joinPeerUrl(url, "/peer/healthz");
|
|
9
|
+
const startedAt = Date.now();
|
|
10
|
+
try {
|
|
11
|
+
const result = await requestJson({
|
|
12
|
+
url: target,
|
|
13
|
+
method: "GET",
|
|
14
|
+
expectedTlsFingerprint: options.expectedTlsFingerprint,
|
|
15
|
+
allowSelfSigned: true,
|
|
16
|
+
timeoutMs: options.timeoutMs ?? 4_000,
|
|
17
|
+
});
|
|
18
|
+
return {
|
|
19
|
+
ok: true,
|
|
20
|
+
status: "reachable",
|
|
21
|
+
url: target,
|
|
22
|
+
latencyMs: Date.now() - startedAt,
|
|
23
|
+
statusCode: result.statusCode,
|
|
24
|
+
tlsFingerprint: result.tlsFingerprint,
|
|
25
|
+
detail: result.data?.ok === true ? "Peer health endpoint is reachable." : "Endpoint responded, but did not return the expected peer health payload.",
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
return {
|
|
30
|
+
ok: false,
|
|
31
|
+
status: "unreachable",
|
|
32
|
+
url: target,
|
|
33
|
+
latencyMs: Date.now() - startedAt,
|
|
34
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
export async function checkPeerIdentityEndpoint(url, options = {}) {
|
|
39
|
+
const target = joinPeerUrl(url, "/peer/identity");
|
|
40
|
+
const startedAt = Date.now();
|
|
41
|
+
try {
|
|
42
|
+
const result = await requestJson({
|
|
43
|
+
url: target,
|
|
44
|
+
method: "GET",
|
|
45
|
+
expectedTlsFingerprint: options.expectedTlsFingerprint,
|
|
46
|
+
allowSelfSigned: true,
|
|
47
|
+
timeoutMs: options.timeoutMs ?? 4_000,
|
|
48
|
+
});
|
|
49
|
+
const identity = parsePeerIdentity(result.data?.identity);
|
|
50
|
+
const protocolVersion = result.data?.protocolVersion;
|
|
51
|
+
return {
|
|
52
|
+
ok: Boolean(identity) && protocolVersion === PEER_PROTOCOL_VERSION,
|
|
53
|
+
status: "reachable",
|
|
54
|
+
url: target,
|
|
55
|
+
latencyMs: Date.now() - startedAt,
|
|
56
|
+
statusCode: result.statusCode,
|
|
57
|
+
tlsFingerprint: result.tlsFingerprint,
|
|
58
|
+
identity,
|
|
59
|
+
detail: identity && protocolVersion === PEER_PROTOCOL_VERSION
|
|
60
|
+
? "Peer identity endpoint is reachable."
|
|
61
|
+
: "Endpoint responded, but did not return the expected peer identity payload.",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
status: "unreachable",
|
|
68
|
+
url: target,
|
|
69
|
+
latencyMs: Date.now() - startedAt,
|
|
70
|
+
detail: error instanceof Error ? error.message : String(error),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
7
74
|
export async function pairPeer(options, identity, store = new PeerStore()) {
|
|
8
75
|
const timestamp = new Date().toISOString();
|
|
9
76
|
const payload = createPairingSignaturePayload(identity.public.nodeId, timestamp, options.code);
|
|
@@ -34,7 +101,7 @@ export async function pairPeer(options, identity, store = new PeerStore()) {
|
|
|
34
101
|
nodeId: result.data.identity.nodeId,
|
|
35
102
|
publicKey: result.data.identity.publicKey,
|
|
36
103
|
fingerprint: result.data.identity.fingerprint,
|
|
37
|
-
tlsFingerprint: result.tlsFingerprint,
|
|
104
|
+
tlsFingerprint: result.tlsFingerprint ?? null,
|
|
38
105
|
secret: result.data.secret,
|
|
39
106
|
enabled: true,
|
|
40
107
|
direction: "outbound",
|
|
@@ -209,10 +276,11 @@ async function requestJson(options) {
|
|
|
209
276
|
reject(new Error(message));
|
|
210
277
|
return;
|
|
211
278
|
}
|
|
212
|
-
resolve({ data: data, tlsFingerprint });
|
|
279
|
+
resolve({ data: data, statusCode: res.statusCode, tlsFingerprint });
|
|
213
280
|
});
|
|
214
281
|
});
|
|
215
282
|
req.on("error", reject);
|
|
283
|
+
req.setTimeout(options.timeoutMs ?? 15_000, () => req.destroy(new Error(`Peer request timed out after ${options.timeoutMs ?? 15_000}ms.`)));
|
|
216
284
|
if (bodyText)
|
|
217
285
|
req.write(bodyText);
|
|
218
286
|
req.end();
|
|
@@ -254,3 +322,23 @@ function normalizePeerUrl(value) {
|
|
|
254
322
|
function normalizeFingerprint(value) {
|
|
255
323
|
return value?.trim().toLowerCase();
|
|
256
324
|
}
|
|
325
|
+
function parsePeerIdentity(value) {
|
|
326
|
+
if (!value || typeof value !== "object") {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
const record = value;
|
|
330
|
+
if (typeof record.nodeId !== "string" ||
|
|
331
|
+
typeof record.name !== "string" ||
|
|
332
|
+
typeof record.publicKey !== "string" ||
|
|
333
|
+
typeof record.fingerprint !== "string" ||
|
|
334
|
+
typeof record.createdAt !== "string") {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
return {
|
|
338
|
+
nodeId: record.nodeId,
|
|
339
|
+
name: record.name,
|
|
340
|
+
publicKey: record.publicKey,
|
|
341
|
+
fingerprint: record.fingerprint,
|
|
342
|
+
createdAt: record.createdAt,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import net from "node:net";
|
|
2
|
+
export async function buildPeerReadiness(config) {
|
|
3
|
+
const listenUrl = peerListenUrl(config);
|
|
4
|
+
const localListening = await checkLocalPort(config.peerHost, config.peerPort);
|
|
5
|
+
const loopbackOnly = isLoopbackUrl(listenUrl);
|
|
6
|
+
const bindLoopbackOnly = isLoopbackHost(config.peerHost);
|
|
7
|
+
const warnings = [];
|
|
8
|
+
if (!config.peerEnabled) {
|
|
9
|
+
warnings.push("Peer server is disabled. Invites can be created, but pairing will fail until NORDRELAY_PEER_ENABLED=true and NordRelay is restarted.");
|
|
10
|
+
}
|
|
11
|
+
if (config.peerEnabled && !localListening) {
|
|
12
|
+
warnings.push(`Peer server is enabled, but no listener was detected on ${connectHostForBindHost(config.peerHost)}:${config.peerPort}.`);
|
|
13
|
+
}
|
|
14
|
+
if (loopbackOnly) {
|
|
15
|
+
warnings.push("Listen URL uses a loopback host. Other machines cannot reach this URL unless they run on the same host.");
|
|
16
|
+
}
|
|
17
|
+
if (bindLoopbackOnly && !loopbackOnly) {
|
|
18
|
+
warnings.push("Peer server is bound to loopback. Remote access requires a local tunnel, reverse proxy, or port forward to this host.");
|
|
19
|
+
}
|
|
20
|
+
if (!config.peerTlsEnabled && (!loopbackOnly || !bindLoopbackOnly)) {
|
|
21
|
+
warnings.push("Peer TLS is disabled. Use TLS for non-loopback or internet-reachable peer endpoints.");
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
enabled: config.peerEnabled,
|
|
25
|
+
listenUrl,
|
|
26
|
+
bindHost: config.peerHost,
|
|
27
|
+
port: config.peerPort,
|
|
28
|
+
tlsEnabled: config.peerTlsEnabled,
|
|
29
|
+
requireTls: config.peerRequireTls,
|
|
30
|
+
localListening,
|
|
31
|
+
loopbackOnly,
|
|
32
|
+
bindLoopbackOnly,
|
|
33
|
+
manualCheckCommand: `nordrelay peer check ${listenUrl}`,
|
|
34
|
+
warnings,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
export function peerListenUrl(config) {
|
|
38
|
+
if (config.peerPublicUrl)
|
|
39
|
+
return config.peerPublicUrl;
|
|
40
|
+
const scheme = config.peerTlsEnabled ? "https" : "http";
|
|
41
|
+
const host = config.peerHost === "0.0.0.0" || config.peerHost === "::" ? "127.0.0.1" : config.peerHost;
|
|
42
|
+
const displayHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
43
|
+
return `${scheme}://${displayHost}:${config.peerPort}`;
|
|
44
|
+
}
|
|
45
|
+
function checkLocalPort(host, port) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const socket = net.createConnection({ host: connectHostForBindHost(host), port });
|
|
48
|
+
const finish = (ok) => {
|
|
49
|
+
socket.removeAllListeners();
|
|
50
|
+
socket.destroy();
|
|
51
|
+
resolve(ok);
|
|
52
|
+
};
|
|
53
|
+
socket.setTimeout(1_500);
|
|
54
|
+
socket.once("connect", () => finish(true));
|
|
55
|
+
socket.once("timeout", () => finish(false));
|
|
56
|
+
socket.once("error", () => finish(false));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function connectHostForBindHost(host) {
|
|
60
|
+
if (!host || host === "0.0.0.0")
|
|
61
|
+
return "127.0.0.1";
|
|
62
|
+
if (host === "::")
|
|
63
|
+
return "::1";
|
|
64
|
+
return host;
|
|
65
|
+
}
|
|
66
|
+
function isLoopbackUrl(value) {
|
|
67
|
+
try {
|
|
68
|
+
return isLoopbackHost(new URL(value).hostname);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function isLoopbackHost(host) {
|
|
75
|
+
const normalized = host.replace(/^\[|\]$/g, "").toLowerCase();
|
|
76
|
+
return normalized === "localhost" || normalized === "::1" || normalized === "0:0:0:0:0:0:0:1" || normalized.startsWith("127.");
|
|
77
|
+
}
|
|
@@ -6,6 +6,7 @@ import { permissionForWebRequest } from "./access-control.js";
|
|
|
6
6
|
import { listChannelDescriptors } from "./channel-adapter.js";
|
|
7
7
|
import { friendlyErrorText } from "./error-messages.js";
|
|
8
8
|
import { getPackageVersion } from "./operations.js";
|
|
9
|
+
import { checkPeerEndpoint } from "./peer-client.js";
|
|
9
10
|
export class PeerRuntimeService {
|
|
10
11
|
config;
|
|
11
12
|
runtime;
|
|
@@ -26,6 +27,10 @@ export class PeerRuntimeService {
|
|
|
26
27
|
this.assertScope(peer, "inspect");
|
|
27
28
|
return { ok: true, status: "online", version: await getPackageVersion(), at: new Date().toISOString() };
|
|
28
29
|
}
|
|
30
|
+
if (request.type === "peer.probe") {
|
|
31
|
+
this.assertScope(peer, "inspect");
|
|
32
|
+
return await this.handlePeerProbe(peer, request.payload);
|
|
33
|
+
}
|
|
29
34
|
throw new Error(`Unsupported peer RPC type: ${request.type}`);
|
|
30
35
|
}
|
|
31
36
|
subscribe(peer, sourceContextKey, send) {
|
|
@@ -333,6 +338,16 @@ export class PeerRuntimeService {
|
|
|
333
338
|
return runtime.restartConnector(remoteActor);
|
|
334
339
|
throw new Error(`Remote endpoint is not implemented: ${method} ${path}`);
|
|
335
340
|
}
|
|
341
|
+
async handlePeerProbe(peer, payload) {
|
|
342
|
+
const requestedUrl = stringValue(objectRecord(payload).url);
|
|
343
|
+
if (!peer.url) {
|
|
344
|
+
throw new Error("Remote probe refused because this peer has no registered URL. Pair with --public-url or set the peer URL first.");
|
|
345
|
+
}
|
|
346
|
+
if (requestedUrl && normalizePeerUrl(requestedUrl) !== normalizePeerUrl(peer.url)) {
|
|
347
|
+
throw new Error("Remote probe refused because the requested URL does not match this peer's registered URL.");
|
|
348
|
+
}
|
|
349
|
+
return await checkPeerEndpoint(peer.url, { expectedTlsFingerprint: peer.tlsFingerprint });
|
|
350
|
+
}
|
|
336
351
|
assertScope(peer, permission) {
|
|
337
352
|
if (!peer.scopes.includes(permission)) {
|
|
338
353
|
throw new Error(`Peer permission denied: ${permission}`);
|
|
@@ -545,6 +560,13 @@ function normalizePath(value) {
|
|
|
545
560
|
}
|
|
546
561
|
return path;
|
|
547
562
|
}
|
|
563
|
+
function normalizePeerUrl(value) {
|
|
564
|
+
const url = new URL(value);
|
|
565
|
+
url.pathname = "";
|
|
566
|
+
url.search = "";
|
|
567
|
+
url.hash = "";
|
|
568
|
+
return url.toString().replace(/\/$/, "");
|
|
569
|
+
}
|
|
548
570
|
function objectRecord(value) {
|
|
549
571
|
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
|
|
550
572
|
}
|
package/dist/peer-server.js
CHANGED
|
@@ -4,6 +4,7 @@ import { URL } from "node:url";
|
|
|
4
4
|
import { friendlyErrorText } from "./error-messages.js";
|
|
5
5
|
import { createPairingSignaturePayload, createSharedSecret, ensurePeerTlsFiles, fingerprintForPublicKey, loadOrCreatePeerIdentity, verifyPeerPayload, } from "./peer-identity.js";
|
|
6
6
|
import { header, PeerNonceCache, verifyPeerRequest } from "./peer-auth.js";
|
|
7
|
+
import { checkPeerIdentityEndpoint } from "./peer-client.js";
|
|
7
8
|
import { peerRuntimeContextKey } from "./peer-context.js";
|
|
8
9
|
import { PeerStore } from "./peer-store.js";
|
|
9
10
|
import { PeerRuntimeService, peerError } from "./peer-runtime-service.js";
|
|
@@ -78,7 +79,7 @@ export async function startPeerServer(options) {
|
|
|
78
79
|
if (req.method === "POST" && url.pathname === "/peer/pair") {
|
|
79
80
|
const bodyText = await readBody(req, 128 * 1024);
|
|
80
81
|
const body = parseJson(bodyText);
|
|
81
|
-
const response = handlePair(body);
|
|
82
|
+
const response = await handlePair(body);
|
|
82
83
|
sendJson(res, 201, response);
|
|
83
84
|
return;
|
|
84
85
|
}
|
|
@@ -123,7 +124,7 @@ export async function startPeerServer(options) {
|
|
|
123
124
|
sendJson(res, status, { error: friendlyErrorText(error) });
|
|
124
125
|
}
|
|
125
126
|
}
|
|
126
|
-
function handlePair(body) {
|
|
127
|
+
async function handlePair(body) {
|
|
127
128
|
if (!body?.identity?.nodeId || !body.identity.publicKey || !body.code || !body.signature || !body.timestamp) {
|
|
128
129
|
throw new Error("Invalid peer pairing request.");
|
|
129
130
|
}
|
|
@@ -140,17 +141,20 @@ export async function startPeerServer(options) {
|
|
|
140
141
|
if (!verifyPeerPayload(body.identity.publicKey, signaturePayload, body.signature)) {
|
|
141
142
|
throw new Error("Invalid peer pairing signature.");
|
|
142
143
|
}
|
|
144
|
+
const publicUrl = body.publicUrl?.trim() || undefined;
|
|
145
|
+
const publicUrlTlsFingerprint = publicUrl ? await verifyPairingPublicUrl(publicUrl, body.identity) : undefined;
|
|
143
146
|
const invitation = store.consumeInvitation(body.code, body.identity.nodeId);
|
|
144
147
|
const secret = createSharedSecret();
|
|
145
148
|
const peer = store.upsertPeer({
|
|
146
149
|
name: body.name?.trim() || body.identity.name || invitation.name,
|
|
147
|
-
url:
|
|
150
|
+
url: publicUrl,
|
|
148
151
|
nodeId: body.identity.nodeId,
|
|
149
152
|
publicKey: body.identity.publicKey,
|
|
150
153
|
fingerprint: body.identity.fingerprint,
|
|
154
|
+
tlsFingerprint: publicUrl ? publicUrlTlsFingerprint ?? null : undefined,
|
|
151
155
|
secret,
|
|
152
156
|
enabled: true,
|
|
153
|
-
direction:
|
|
157
|
+
direction: publicUrl ? "bidirectional" : "inbound",
|
|
154
158
|
scopes: invitation.scopes,
|
|
155
159
|
allowedAgents: invitation.allowedAgents,
|
|
156
160
|
allowedWorkspaceRoots: invitation.allowedWorkspaceRoots,
|
|
@@ -167,6 +171,18 @@ export async function startPeerServer(options) {
|
|
|
167
171
|
workspaceAliases: peer.workspaceAliases,
|
|
168
172
|
};
|
|
169
173
|
}
|
|
174
|
+
async function verifyPairingPublicUrl(publicUrl, expectedIdentity) {
|
|
175
|
+
const probe = await checkPeerIdentityEndpoint(publicUrl, { timeoutMs: 4_000 });
|
|
176
|
+
if (!probe.ok || !probe.identity) {
|
|
177
|
+
throw new Error(`Peer public URL is not reachable or does not expose a valid NordRelay identity: ${probe.detail}`);
|
|
178
|
+
}
|
|
179
|
+
if (probe.identity.nodeId !== expectedIdentity.nodeId ||
|
|
180
|
+
probe.identity.publicKey !== expectedIdentity.publicKey ||
|
|
181
|
+
probe.identity.fingerprint !== expectedIdentity.fingerprint) {
|
|
182
|
+
throw new Error("Peer public URL identity does not match the pairing identity.");
|
|
183
|
+
}
|
|
184
|
+
return probe.tlsFingerprint;
|
|
185
|
+
}
|
|
170
186
|
function authenticate(req, method, pathname, body) {
|
|
171
187
|
const peerId = header(req, "x-nordrelay-peer-id");
|
|
172
188
|
const peer = peerId ? store.get(peerId) : null;
|
package/dist/peer-store.js
CHANGED
|
@@ -21,6 +21,7 @@ export class PeerStore {
|
|
|
21
21
|
enabled: options.enabled,
|
|
22
22
|
listenUrl: options.listenUrl,
|
|
23
23
|
requireTls: options.requireTls,
|
|
24
|
+
readiness: options.readiness,
|
|
24
25
|
peers: payload.peers.map(publicPeer),
|
|
25
26
|
invitations: payload.invitations.map(publicInvitation),
|
|
26
27
|
};
|
|
@@ -90,7 +91,9 @@ export class PeerStore {
|
|
|
90
91
|
existing.url = input.url ?? existing.url;
|
|
91
92
|
existing.publicKey = input.publicKey;
|
|
92
93
|
existing.fingerprint = input.fingerprint;
|
|
93
|
-
|
|
94
|
+
if (input.tlsFingerprint !== undefined) {
|
|
95
|
+
existing.tlsFingerprint = input.tlsFingerprint || undefined;
|
|
96
|
+
}
|
|
94
97
|
existing.secret = input.secret;
|
|
95
98
|
existing.enabled = input.enabled ?? existing.enabled;
|
|
96
99
|
existing.direction = mergeDirection(existing.direction, input.direction ?? existing.direction);
|
|
@@ -110,7 +113,7 @@ export class PeerStore {
|
|
|
110
113
|
nodeId: input.nodeId,
|
|
111
114
|
publicKey: input.publicKey,
|
|
112
115
|
fingerprint: input.fingerprint,
|
|
113
|
-
tlsFingerprint: input.tlsFingerprint,
|
|
116
|
+
tlsFingerprint: input.tlsFingerprint || undefined,
|
|
114
117
|
secret: input.secret,
|
|
115
118
|
enabled: input.enabled ?? true,
|
|
116
119
|
direction: input.direction ?? "outbound",
|
|
@@ -180,6 +183,18 @@ export class PeerStore {
|
|
|
180
183
|
});
|
|
181
184
|
return removed;
|
|
182
185
|
}
|
|
186
|
+
deleteInvitation(id) {
|
|
187
|
+
let removed = null;
|
|
188
|
+
this.mutatePayload((payload) => {
|
|
189
|
+
const index = payload.invitations.findIndex((invitation) => invitation.id === id);
|
|
190
|
+
if (index < 0) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const [invitation] = payload.invitations.splice(index, 1);
|
|
194
|
+
removed = invitation;
|
|
195
|
+
});
|
|
196
|
+
return removed ? publicInvitation(removed) : null;
|
|
197
|
+
}
|
|
183
198
|
patchPeer(id, patch) {
|
|
184
199
|
this.mutatePayload((payload) => {
|
|
185
200
|
const peer = payload.peers.find((candidate) => candidate.id === id || candidate.nodeId === id);
|
|
@@ -131,7 +131,9 @@ export function promptActivityToUnifiedJob(event) {
|
|
|
131
131
|
? "Telegram"
|
|
132
132
|
: event.source === "discord"
|
|
133
133
|
? "Discord"
|
|
134
|
-
: "
|
|
134
|
+
: event.source === "slack"
|
|
135
|
+
? "Slack"
|
|
136
|
+
: "CLI";
|
|
135
137
|
const promptKey = event.threadId ?? event.contextKey ?? event.id;
|
|
136
138
|
return {
|
|
137
139
|
id: `prompt:${event.source}:${promptKey}:${event.id}`,
|
package/dist/relay-runtime.js
CHANGED
|
@@ -29,6 +29,8 @@ import { activeSessionPriority, activityToUnifiedJob, agentUpdateStatusToUnified
|
|
|
29
29
|
import { renderSessionInfoPlain, renderSessionUsageRows } from "./session-format.js";
|
|
30
30
|
import { SessionLockStore } from "./session-locks.js";
|
|
31
31
|
import { SessionRegistry } from "./session-registry.js";
|
|
32
|
+
import { collectSlackDiagnostics } from "./slack-diagnostics.js";
|
|
33
|
+
import { getSlackRateLimitMetrics } from "./slack-rate-limit.js";
|
|
32
34
|
import { createSupportBundle } from "./support-bundle.js";
|
|
33
35
|
import { transcribeAudio } from "./voice.js";
|
|
34
36
|
import { WebActivityStore, WebChatStore, } from "./web-state.js";
|
|
@@ -330,6 +332,11 @@ export class RelayRuntime {
|
|
|
330
332
|
queuePaused: this.queueService.isPaused(),
|
|
331
333
|
externalMirror: this.externalActivityMonitor.snapshot(),
|
|
332
334
|
agentDiagnostics: getAgentDiagnostics(session, this.config),
|
|
335
|
+
slackDiagnostics: await collectSlackDiagnostics({
|
|
336
|
+
config: this.config,
|
|
337
|
+
timeoutMs: 2_500,
|
|
338
|
+
rateLimit: getSlackRateLimitMetrics(),
|
|
339
|
+
}),
|
|
333
340
|
},
|
|
334
341
|
};
|
|
335
342
|
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
export async function runSettingsWizardTest(channel, settings) {
|
|
2
|
+
const parsedChannel = parseSettingsWizardChannel(channel);
|
|
3
|
+
const checks = parsedChannel === "telegram"
|
|
4
|
+
? await testTelegram(settings)
|
|
5
|
+
: parsedChannel === "discord"
|
|
6
|
+
? await testDiscord(settings)
|
|
7
|
+
: await testSlack(settings);
|
|
8
|
+
return { channel: parsedChannel, checkedAt: new Date().toISOString(), checks };
|
|
9
|
+
}
|
|
10
|
+
export function mergeSettingsWizardTestSettings(activeSettings, submittedSettings) {
|
|
11
|
+
const merged = {};
|
|
12
|
+
for (const [key, value] of Object.entries(activeSettings)) {
|
|
13
|
+
if (value !== undefined) {
|
|
14
|
+
merged[key] = value;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
for (const [key, value] of Object.entries(submittedSettings)) {
|
|
18
|
+
if (typeof value !== "string" || isMaskedSecret(value)) {
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
merged[key] = value;
|
|
22
|
+
}
|
|
23
|
+
return merged;
|
|
24
|
+
}
|
|
25
|
+
function parseSettingsWizardChannel(value) {
|
|
26
|
+
if (value === "telegram" || value === "discord" || value === "slack") {
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
throw new Error("Invalid settings wizard channel.");
|
|
30
|
+
}
|
|
31
|
+
async function testTelegram(settings) {
|
|
32
|
+
const token = settings.TELEGRAM_BOT_TOKEN ?? "";
|
|
33
|
+
const transport = settings.TELEGRAM_TRANSPORT || "polling";
|
|
34
|
+
const checks = [
|
|
35
|
+
tokenCheck("Telegram bot token", token, /^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/),
|
|
36
|
+
{
|
|
37
|
+
label: "Telegram transport",
|
|
38
|
+
status: transport === "polling" || transport === "webhook" ? "ok" : "error",
|
|
39
|
+
detail: transport === "webhook" ? "Webhook mode selected." : "Polling mode selected.",
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
if (transport === "webhook") {
|
|
43
|
+
checks.push({
|
|
44
|
+
label: "Webhook public URL",
|
|
45
|
+
status: /^https:\/\//.test(settings.TELEGRAM_WEBHOOK_URL ?? "") ? "ok" : "error",
|
|
46
|
+
detail: settings.TELEGRAM_WEBHOOK_URL ? "HTTPS URL configured." : "Webhook mode requires a public HTTPS URL.",
|
|
47
|
+
}, {
|
|
48
|
+
label: "Webhook bind endpoint",
|
|
49
|
+
status: settings.TELEGRAM_WEBHOOK_HOST && Number.isFinite(Number(settings.TELEGRAM_WEBHOOK_PORT)) && String(settings.TELEGRAM_WEBHOOK_PATH ?? "").startsWith("/") ? "ok" : "error",
|
|
50
|
+
detail: "Host, port, and path must describe the local webhook listener.",
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (isUsableSecret(token, /^[0-9]{5,}:[A-Za-z0-9_-]{20,}$/)) {
|
|
54
|
+
checks.push(await fetchTelegramIdentity(token));
|
|
55
|
+
}
|
|
56
|
+
return checks;
|
|
57
|
+
}
|
|
58
|
+
async function testDiscord(settings) {
|
|
59
|
+
const token = settings.DISCORD_BOT_TOKEN ?? "";
|
|
60
|
+
const clientId = settings.DISCORD_CLIENT_ID ?? "";
|
|
61
|
+
const commandMode = settings.DISCORD_COMMAND_MODE || "both";
|
|
62
|
+
const checks = [
|
|
63
|
+
tokenCheck("Discord bot token", token, /^.{20,}$/),
|
|
64
|
+
{
|
|
65
|
+
label: "Discord client ID",
|
|
66
|
+
status: isSnowflake(clientId) ? "ok" : "error",
|
|
67
|
+
detail: isSnowflake(clientId) ? "Application ID looks valid." : "Copy Application ID from Discord Developer Portal > General Information.",
|
|
68
|
+
},
|
|
69
|
+
listCheck("Discord guild IDs", settings.DISCORD_GUILD_IDS, isSnowflake),
|
|
70
|
+
listCheck("Allowed Discord guilds", settings.DISCORD_ALLOWED_GUILD_IDS, isSnowflake),
|
|
71
|
+
listCheck("Allowed Discord channels", settings.DISCORD_ALLOWED_CHANNEL_IDS, isSnowflake),
|
|
72
|
+
{
|
|
73
|
+
label: "Discord command mode",
|
|
74
|
+
status: commandMode === "slash" || commandMode === "message" || commandMode === "both" ? "ok" : "error",
|
|
75
|
+
detail: "Supported values are slash, message, or both.",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
if ((commandMode === "message" || commandMode === "both") && !truthy(settings.DISCORD_MESSAGE_CONTENT_ENABLED)) {
|
|
79
|
+
checks.push({
|
|
80
|
+
label: "Message Content Intent",
|
|
81
|
+
status: "warn",
|
|
82
|
+
detail: "Message command mode needs Message Content Intent enabled in the Discord Developer Portal.",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (isUsableSecret(token, /^.{20,}$/)) {
|
|
86
|
+
checks.push(await fetchDiscordIdentity(token));
|
|
87
|
+
}
|
|
88
|
+
return checks;
|
|
89
|
+
}
|
|
90
|
+
async function testSlack(settings) {
|
|
91
|
+
const botToken = settings.SLACK_BOT_TOKEN ?? "";
|
|
92
|
+
const appToken = settings.SLACK_APP_TOKEN ?? "";
|
|
93
|
+
const socketMode = truthy(settings.SLACK_SOCKET_MODE);
|
|
94
|
+
const checks = [
|
|
95
|
+
tokenCheck("Slack bot token", botToken, /^xoxb-/),
|
|
96
|
+
{
|
|
97
|
+
label: "Slack command",
|
|
98
|
+
status: !settings.SLACK_COMMAND || settings.SLACK_COMMAND.startsWith("/") ? "ok" : "error",
|
|
99
|
+
detail: settings.SLACK_COMMAND || "/nordrelay",
|
|
100
|
+
},
|
|
101
|
+
listCheck("Allowed Slack teams", settings.SLACK_ALLOWED_TEAM_IDS, isSlackId),
|
|
102
|
+
listCheck("Allowed Slack channels", settings.SLACK_ALLOWED_CHANNEL_IDS, isSlackId),
|
|
103
|
+
];
|
|
104
|
+
if (socketMode) {
|
|
105
|
+
checks.push(tokenCheck("Slack app token", appToken, /^xapp-/));
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
checks.push({
|
|
109
|
+
label: "Slack signing secret",
|
|
110
|
+
status: settings.SLACK_SIGNING_SECRET ? "ok" : "error",
|
|
111
|
+
detail: settings.SLACK_SIGNING_SECRET ? "Signing secret configured." : "HTTP Events mode requires the Slack signing secret.",
|
|
112
|
+
}, {
|
|
113
|
+
label: "Slack HTTP port",
|
|
114
|
+
status: Number.isFinite(Number(settings.SLACK_PORT)) ? "ok" : "error",
|
|
115
|
+
detail: settings.SLACK_PORT || "Not configured.",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (isUsableSecret(botToken, /^xoxb-/)) {
|
|
119
|
+
checks.push(await fetchSlackIdentity(botToken));
|
|
120
|
+
}
|
|
121
|
+
return checks;
|
|
122
|
+
}
|
|
123
|
+
function tokenCheck(label, value, pattern) {
|
|
124
|
+
if (!value) {
|
|
125
|
+
return { label, status: "error", detail: "Required value is missing." };
|
|
126
|
+
}
|
|
127
|
+
if (isMaskedSecret(value)) {
|
|
128
|
+
return { label, status: "warn", detail: "Secret is already configured. Paste the real value to run a live API test." };
|
|
129
|
+
}
|
|
130
|
+
return pattern.test(value)
|
|
131
|
+
? { label, status: "ok", detail: "Format looks valid." }
|
|
132
|
+
: { label, status: "error", detail: "Value does not match the expected format." };
|
|
133
|
+
}
|
|
134
|
+
function listCheck(label, value, predicate) {
|
|
135
|
+
const items = parseList(value);
|
|
136
|
+
const invalid = items.filter((item) => !predicate(item));
|
|
137
|
+
if (invalid.length > 0) {
|
|
138
|
+
return { label, status: "error", detail: `Invalid values: ${invalid.join(", ")}` };
|
|
139
|
+
}
|
|
140
|
+
return { label, status: "ok", detail: items.length ? `${items.length} value(s) configured.` : "No allow-list configured." };
|
|
141
|
+
}
|
|
142
|
+
async function fetchTelegramIdentity(token) {
|
|
143
|
+
try {
|
|
144
|
+
const data = await fetchJson(`https://api.telegram.org/bot${token}/getMe`);
|
|
145
|
+
if (data.ok === true) {
|
|
146
|
+
const result = data.result;
|
|
147
|
+
return { label: "Telegram API", status: "ok", detail: `Bot reachable: ${result?.username ?? result?.first_name ?? "configured bot"}.` };
|
|
148
|
+
}
|
|
149
|
+
return { label: "Telegram API", status: "error", detail: String(data.description ?? "Telegram rejected the token.") };
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return { label: "Telegram API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async function fetchDiscordIdentity(token) {
|
|
156
|
+
try {
|
|
157
|
+
const data = await fetchJson("https://discord.com/api/v10/users/@me", {
|
|
158
|
+
headers: { authorization: `Bot ${token}` },
|
|
159
|
+
});
|
|
160
|
+
if (typeof data.id === "string") {
|
|
161
|
+
return { label: "Discord API", status: "ok", detail: `Bot reachable: ${data.username ?? data.id}.` };
|
|
162
|
+
}
|
|
163
|
+
return { label: "Discord API", status: "error", detail: String(data.message ?? "Discord rejected the bot token.") };
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
return { label: "Discord API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
async function fetchSlackIdentity(token) {
|
|
170
|
+
try {
|
|
171
|
+
const data = await fetchJson("https://slack.com/api/auth.test", {
|
|
172
|
+
headers: { authorization: `Bearer ${token}` },
|
|
173
|
+
});
|
|
174
|
+
if (data.ok === true) {
|
|
175
|
+
return { label: "Slack API", status: "ok", detail: `Bot reachable in ${data.team ?? "workspace"} as ${data.user ?? data.bot_id ?? "bot"}.` };
|
|
176
|
+
}
|
|
177
|
+
return { label: "Slack API", status: "error", detail: String(data.error ?? "Slack rejected the bot token.") };
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
return { label: "Slack API", status: "warn", detail: `Live check failed: ${errorText(error)}` };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
async function fetchJson(url, init = {}) {
|
|
184
|
+
const response = await fetch(url, {
|
|
185
|
+
...init,
|
|
186
|
+
signal: AbortSignal.timeout(5_000),
|
|
187
|
+
});
|
|
188
|
+
const text = await response.text();
|
|
189
|
+
try {
|
|
190
|
+
return JSON.parse(text);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return { ok: response.ok, status: response.status, description: text.slice(0, 200) };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
function isUsableSecret(value, pattern) {
|
|
197
|
+
return Boolean(value) && !isMaskedSecret(value) && pattern.test(value);
|
|
198
|
+
}
|
|
199
|
+
function isMaskedSecret(value) {
|
|
200
|
+
return /^\*+$/.test(value) || value.includes("...");
|
|
201
|
+
}
|
|
202
|
+
function parseList(value) {
|
|
203
|
+
return String(value ?? "").split(",").map((item) => item.trim()).filter(Boolean);
|
|
204
|
+
}
|
|
205
|
+
function isSnowflake(value) {
|
|
206
|
+
return /^[0-9]{5,32}$/.test(value);
|
|
207
|
+
}
|
|
208
|
+
function isSlackId(value) {
|
|
209
|
+
return /^[A-Z0-9]{2,64}$/.test(value);
|
|
210
|
+
}
|
|
211
|
+
function truthy(value) {
|
|
212
|
+
return ["true", "1", "yes", "on"].includes(String(value ?? "").toLowerCase());
|
|
213
|
+
}
|
|
214
|
+
function errorText(error) {
|
|
215
|
+
return error instanceof Error ? error.message : String(error);
|
|
216
|
+
}
|