@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.
Files changed (47) hide show
  1. package/.env.example +35 -0
  2. package/README.md +118 -49
  3. package/dist/activity-events.js +2 -2
  4. package/dist/adapter-conformance.js +61 -0
  5. package/dist/bot.js +18 -31
  6. package/dist/channel-adapter.js +33 -6
  7. package/dist/channel-command-catalog.js +6 -0
  8. package/dist/channel-command-core.js +60 -0
  9. package/dist/channel-command-service.js +20 -4
  10. package/dist/channel-mirror-registry.js +9 -2
  11. package/dist/channel-prompt-engine.js +177 -0
  12. package/dist/channel-turn-lifecycle.js +73 -0
  13. package/dist/config-metadata.js +67 -8
  14. package/dist/config.js +48 -1
  15. package/dist/context-key.js +32 -0
  16. package/dist/discord-bot.js +99 -327
  17. package/dist/index.js +9 -0
  18. package/dist/metrics.js +2 -0
  19. package/dist/peer-client.js +90 -2
  20. package/dist/peer-readiness.js +77 -0
  21. package/dist/peer-runtime-service.js +22 -0
  22. package/dist/peer-server.js +20 -4
  23. package/dist/peer-store.js +17 -2
  24. package/dist/relay-runtime-helpers.js +3 -1
  25. package/dist/relay-runtime.js +7 -0
  26. package/dist/settings-wizard-test.js +216 -0
  27. package/dist/slack-artifacts.js +165 -0
  28. package/dist/slack-bot.js +1461 -0
  29. package/dist/slack-channel-runtime.js +147 -0
  30. package/dist/slack-command-surface.js +46 -0
  31. package/dist/slack-diagnostics.js +116 -0
  32. package/dist/slack-rate-limit.js +139 -0
  33. package/dist/user-management-crypto.js +38 -0
  34. package/dist/user-management-normalize.js +188 -0
  35. package/dist/user-management-types.js +1 -0
  36. package/dist/user-management.js +193 -196
  37. package/dist/web-api-contract.js +8 -0
  38. package/dist/web-dashboard-access-routes.js +62 -0
  39. package/dist/web-dashboard-assets.js +1 -0
  40. package/dist/web-dashboard-pages.js +14 -4
  41. package/dist/web-dashboard-peer-routes.js +32 -11
  42. package/dist/web-dashboard.js +34 -0
  43. package/dist/web-state.js +2 -2
  44. package/dist/webui-assets/dashboard.css +193 -0
  45. package/dist/webui-assets/dashboard.js +546 -145
  46. package/package.json +3 -1
  47. package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
@@ -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
  }
@@ -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: body.publicUrl,
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: body.publicUrl ? "bidirectional" : "inbound",
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;
@@ -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
- existing.tlsFingerprint = input.tlsFingerprint ?? existing.tlsFingerprint;
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
- : "CLI";
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}`,
@@ -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
+ }