@nordbyte/nordrelay 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.env.example +9 -0
  2. package/README.md +81 -1197
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +2 -2
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
  40. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  41. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  42. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  43. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
  44. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  45. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  46. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  47. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  48. package/dist/{bot.js → channels/telegram/bot.js} +178 -427
  49. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  50. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  51. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  52. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  53. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  54. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  55. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  56. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  57. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  58. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  59. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  60. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  61. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  62. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  63. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  64. package/dist/{config.js → core/config.js} +11 -3
  65. package/dist/index.js +27 -23
  66. package/dist/{peer-client.js → peers/peer-client.js} +57 -1
  67. package/dist/peers/peer-discovery-jobs.js +206 -0
  68. package/dist/peers/peer-discovery.js +223 -0
  69. package/dist/peers/peer-health-monitor.js +49 -0
  70. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  71. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  72. package/dist/{peer-server.js → peers/peer-server.js} +23 -6
  73. package/dist/{peer-store.js → peers/peer-store.js} +84 -11
  74. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  75. package/dist/peers/peer-web-proxy-contract.js +127 -0
  76. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  77. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  78. package/dist/runtime/relay-auth-service.js +63 -0
  79. package/dist/runtime/relay-dashboard-service.js +139 -0
  80. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  81. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  82. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  83. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  84. package/dist/runtime/relay-runtime-sessions.js +623 -0
  85. package/dist/runtime/relay-runtime-types.js +1 -0
  86. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  87. package/dist/runtime/relay-runtime.js +451 -0
  88. package/dist/runtime/runtime-cache.js +117 -0
  89. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  90. package/dist/{operations.js → support/operations.js} +7 -7
  91. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  92. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  93. package/dist/web/web-api-types.js +1 -0
  94. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  95. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  96. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  97. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  98. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  99. package/dist/web/web-dashboard-security.js +14 -0
  100. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  101. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  102. package/dist/web/web-performance.js +60 -0
  103. package/dist/web/web-rate-limit.js +19 -0
  104. package/dist/{web-state.js → web/web-state.js} +74 -5
  105. package/dist/webui-assets/dashboard.css +171 -10
  106. package/dist/webui-assets/dashboard.js +515 -48
  107. package/dist/webui-assets/favicon.ico +0 -0
  108. package/dist/webui-assets/favicon.png +0 -0
  109. package/dist/webui-assets/logo.png +0 -0
  110. package/package.json +4 -3
  111. package/plugins/nordrelay/scripts/nordrelay.mjs +17 -5
  112. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  113. package/dist/relay-runtime.js +0 -1916
  114. package/dist/runtime-cache.js +0 -57
  115. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  116. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  117. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  118. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  119. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  120. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  121. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  122. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  123. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  124. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  125. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  126. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  127. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  128. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  129. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  130. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  131. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  132. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  133. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  134. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  135. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  136. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  137. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  138. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  139. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  140. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  141. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  142. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  143. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  144. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  145. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  146. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  147. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  148. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  149. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  150. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  151. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  152. /package/dist/{format.js → core/format.js} +0 -0
  153. /package/dist/{logger.js → core/logger.js} +0 -0
  154. /package/dist/{redaction.js → core/redaction.js} +0 -0
  155. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  156. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  157. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  158. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  159. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  160. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  161. /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
  162. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  163. /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
  164. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  165. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  166. /package/dist/{job-store.js → state/job-store.js} +0 -0
  167. /package/dist/{persistence.js → state/persistence.js} +0 -0
  168. /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
  169. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  170. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
  171. /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
  172. /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
  173. /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
@@ -1,5 +1,5 @@
1
- import { ALL_PERMISSIONS } from "./access-control.js";
2
- import { publicUser, publicUserSnapshot, } from "./user-management.js";
1
+ import { ALL_PERMISSIONS } from "../access/access-control.js";
2
+ import { publicUser, publicUserSnapshot, } from "../access/user-management.js";
3
3
  import { arrayNumberField, arrayStringField, numberField, numberParam, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, stringField, } from "./web-dashboard-http.js";
4
4
  export async function handleDashboardAccessRoute(req, res, url, options) {
5
5
  const { users, runtime, authUser } = options;
@@ -6,6 +6,7 @@ const clientSources = [
6
6
  "client/core/api-routes.generated.js",
7
7
  "client/core/api-client.js",
8
8
  "client/core/runtime.js",
9
+ "client/core/components.js",
9
10
  "client/overview.js",
10
11
  "client/events.js",
11
12
  "client/workflows.js",
@@ -26,13 +27,34 @@ export function dashboardJs() {
26
27
  export function dashboardCss() {
27
28
  return readDashboardAsset("dashboard.css", styleSources);
28
29
  }
30
+ const staticAssetTypes = {
31
+ "favicon.ico": "image/x-icon",
32
+ "favicon.png": "image/png",
33
+ "logo.png": "image/png",
34
+ };
35
+ export function dashboardStaticAsset(assetName) {
36
+ const contentType = staticAssetTypes[assetName];
37
+ if (!contentType) {
38
+ return null;
39
+ }
40
+ const filePath = dashboardStaticAssetPath(assetName);
41
+ return filePath ? { filePath, contentType } : null;
42
+ }
29
43
  function readDashboardAsset(assetName, sourceFiles) {
30
- const builtAsset = path.join(moduleDir, "webui-assets", assetName);
44
+ const builtAsset = path.resolve(moduleDir, "..", "webui-assets", assetName);
31
45
  if (existsSync(builtAsset)) {
32
46
  return readFileSync(builtAsset, "utf8");
33
47
  }
34
- const sourceDir = path.join(moduleDir, "webui");
48
+ const sourceDir = path.join(moduleDir, "ui");
35
49
  return sourceFiles
36
50
  .map((file) => readFileSync(path.join(sourceDir, file), "utf8"))
37
51
  .join("\n");
38
52
  }
53
+ function dashboardStaticAssetPath(assetName) {
54
+ const builtAsset = path.resolve(moduleDir, "..", "webui-assets", assetName);
55
+ if (existsSync(builtAsset)) {
56
+ return builtAsset;
57
+ }
58
+ const sourceAsset = path.join(moduleDir, "ui", "assets", assetName);
59
+ return existsSync(sourceAsset) ? sourceAsset : null;
60
+ }
@@ -1,5 +1,12 @@
1
1
  import { createReadStream } from "node:fs";
2
- const JSON_HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
2
+ const DEFAULT_JSON_BODY_LIMIT = 64 * 1024 * 1024;
3
+ const BASE_SECURITY_HEADERS = {
4
+ "x-content-type-options": "nosniff",
5
+ "x-frame-options": "DENY",
6
+ "referrer-policy": "no-referrer",
7
+ "permissions-policy": "camera=(), microphone=(), geolocation=()",
8
+ };
9
+ const JSON_HEADERS = { ...webSecurityHeaders(), "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
3
10
  export function parseCookies(cookieHeader) {
4
11
  const cookies = {};
5
12
  for (const part of cookieHeader.split(";")) {
@@ -9,10 +16,22 @@ export function parseCookies(cookieHeader) {
9
16
  }
10
17
  return cookies;
11
18
  }
12
- export async function readJsonBody(req) {
19
+ export class RequestBodyTooLargeError extends Error {
20
+ statusCode = 413;
21
+ }
22
+ export function isRequestBodyTooLargeError(error) {
23
+ return error instanceof RequestBodyTooLargeError;
24
+ }
25
+ export async function readJsonBody(req, maxBytes = DEFAULT_JSON_BODY_LIMIT) {
13
26
  const chunks = [];
27
+ let size = 0;
14
28
  for await (const chunk of req) {
15
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
29
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
30
+ size += buffer.length;
31
+ if (size > maxBytes) {
32
+ throw new RequestBodyTooLargeError(`Request body is too large. Max ${Math.round(maxBytes / 1024 / 1024)} MB.`);
33
+ }
34
+ chunks.push(buffer);
16
35
  }
17
36
  const text = Buffer.concat(chunks).toString("utf8").trim();
18
37
  if (!text) {
@@ -24,17 +43,26 @@ export function sendJson(res, status, value) {
24
43
  res.writeHead(status, JSON_HEADERS);
25
44
  res.end(`${JSON.stringify(value)}\n`);
26
45
  }
27
- export function sendText(res, status, text, contentType) {
28
- res.writeHead(status, { "content-type": contentType, "cache-control": "no-store" });
46
+ export function sendText(res, status, text, contentType, options = {}) {
47
+ res.writeHead(status, { ...webSecurityHeaders(options.cspNonce), "content-type": contentType, "cache-control": "no-store" });
29
48
  res.end(text);
30
49
  }
31
50
  export function sendFile(res, filePath, filename) {
32
51
  res.writeHead(200, {
52
+ ...webSecurityHeaders(),
33
53
  "content-type": "application/octet-stream",
34
54
  "content-disposition": `attachment; filename="${filename.replace(/"/g, "")}"`,
35
55
  });
36
56
  createReadStream(filePath).pipe(res);
37
57
  }
58
+ export function sendStaticFile(res, filePath, contentType) {
59
+ res.writeHead(200, {
60
+ ...webSecurityHeaders(),
61
+ "content-type": contentType,
62
+ "cache-control": "public, max-age=86400",
63
+ });
64
+ createReadStream(filePath).pipe(res);
65
+ }
38
66
  export function stringField(value, key) {
39
67
  const field = value[key];
40
68
  if (typeof field !== "string" || !field.trim()) {
@@ -141,3 +169,11 @@ function stripDataUrlPrefix(value) {
141
169
  const comma = value.indexOf(",");
142
170
  return value.startsWith("data:") && comma !== -1 ? value.slice(comma + 1) : value;
143
171
  }
172
+ export function webSecurityHeaders(cspNonce) {
173
+ const scriptSrc = cspNonce ? `'self' 'nonce-${cspNonce}'` : "'self'";
174
+ const styleSrc = cspNonce ? `'self' 'nonce-${cspNonce}'` : "'self'";
175
+ return {
176
+ ...BASE_SECURITY_HEADERS,
177
+ "content-security-policy": `default-src 'self'; script-src ${scriptSrc}; style-src ${styleSrc}; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'`,
178
+ };
179
+ }
@@ -1,12 +1,18 @@
1
1
  import { renderDashboardNav } from "./web-dashboard-ui.js";
2
+ const faviconLinks = `
3
+ <link rel="icon" href="/favicon.ico" sizes="any">
4
+ <link rel="icon" type="image/png" href="/assets/favicon.png">
5
+ <link rel="apple-touch-icon" href="/assets/logo.png">`;
2
6
  export function renderLoginPage(options) {
7
+ const nonce = nonceAttr(options.cspNonce);
3
8
  return `<!doctype html>
4
9
  <html lang="en">
5
10
  <head>
6
11
  <meta charset="utf-8">
7
12
  <meta name="viewport" content="width=device-width, initial-scale=1">
8
13
  <title>NordRelay Login</title>
9
- <style>
14
+ ${faviconLinks}
15
+ <style${nonce}>
10
16
  body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
11
17
  form{width:min(420px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
12
18
  h1{font-size:24px;margin:0 0 8px}
@@ -26,7 +32,7 @@ export function renderLoginPage(options) {
26
32
  <button ${options.adminConfigured ? "" : "disabled"}>Sign in</button>
27
33
  <div class="error" id="error"></div>
28
34
  </form>
29
- <script>
35
+ <script${nonce}>
30
36
  document.getElementById('login').addEventListener('submit', async (event) => {
31
37
  event.preventDefault();
32
38
  const payload = {
@@ -45,13 +51,15 @@ export function renderLoginPage(options) {
45
51
  </html>`;
46
52
  }
47
53
  export function renderFirstRunSetupPage(options) {
54
+ const nonce = nonceAttr(options.cspNonce);
48
55
  return `<!doctype html>
49
56
  <html lang="en">
50
57
  <head>
51
58
  <meta charset="utf-8">
52
59
  <meta name="viewport" content="width=device-width, initial-scale=1">
53
60
  <title>NordRelay First Run</title>
54
- <style>
61
+ ${faviconLinks}
62
+ <style${nonce}>
55
63
  body{margin:0;min-height:100vh;display:grid;place-items:center;background:#f4f5f2;color:#181c19;font-family:Inter,system-ui,-apple-system,Segoe UI,sans-serif}
56
64
  form{width:min(460px,calc(100vw - 32px));background:white;border:1px solid #dfe3dc;border-radius:8px;padding:24px;box-shadow:0 20px 60px rgba(20,30,24,.08)}
57
65
  h1{font-size:24px;margin:0 0 8px}
@@ -75,7 +83,7 @@ export function renderFirstRunSetupPage(options) {
75
83
  <button>Create admin</button>
76
84
  <div class="error" id="error"></div>
77
85
  </form>
78
- <script>
86
+ <script${nonce}>
79
87
  document.getElementById('setup').addEventListener('submit', async (event) => {
80
88
  event.preventDefault();
81
89
  const payload = {
@@ -96,20 +104,23 @@ export function renderFirstRunSetupPage(options) {
96
104
  </body>
97
105
  </html>`;
98
106
  }
99
- export function renderDashboardApp() {
107
+ export function renderDashboardApp(options = {}) {
108
+ const nonce = nonceAttr(options.cspNonce);
100
109
  return `<!doctype html>
101
110
  <html lang="en">
102
111
  <head>
103
112
  <meta charset="utf-8">
104
113
  <meta name="viewport" content="width=device-width, initial-scale=1">
105
114
  <title>NordRelay Dashboard</title>
106
- <script>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
115
+ ${faviconLinks}
116
+ <script${nonce}>document.documentElement.dataset.theme = localStorage.getItem('nordrelayTheme') || 'light';</script>
107
117
  <link rel="stylesheet" href="/assets/dashboard.css">
108
118
  </head>
109
119
  <body>
110
120
  <div class="app">
111
121
  <aside class="sidebar" id="sidebar">
112
- <div class="brand"><span class="mark">NR</span><div><strong>NordRelay</strong><small>Remote control</small></div></div>
122
+ <div class="brand"><img class="brand-mark" src="/assets/logo.png" alt="" width="44" height="44" aria-hidden="true"><div><strong>NordRelay</strong><small>Remote control</small></div></div>
123
+ <div class="brand-separator" aria-hidden="true"></div>
113
124
  <nav>
114
125
  ${renderDashboardNav()}
115
126
  </nav>
@@ -143,7 +154,7 @@ export function renderDashboardApp() {
143
154
  </section>
144
155
 
145
156
  <section class="page" id="page-chat">
146
- <div class="chat-layout">
157
+ <div class="chat-layout tools-hidden" id="chatLayout">
147
158
  <div class="panel chat-panel">
148
159
  <div class="chat-toolbar">
149
160
  <button id="newSessionBtn">New session</button>
@@ -151,9 +162,19 @@ export function renderDashboardApp() {
151
162
  <button id="editLastBtn" class="secondary">Edit last</button>
152
163
  <button id="syncBtn" class="secondary">Sync</button>
153
164
  <button id="notifyBtn" class="secondary">Notify</button>
165
+ <label class="mirror-control" title="Mirror local CLI activity into this WebUI chat">
166
+ Mirror
167
+ <select id="mirrorModeSelect">
168
+ <option value="off">Off</option>
169
+ <option value="status">Status</option>
170
+ <option value="final">Final</option>
171
+ <option value="full">Full</option>
172
+ </select>
173
+ </label>
154
174
  <button id="clearChatBtn" class="secondary">Clear history</button>
155
175
  <button id="abortBtn">Abort</button>
156
176
  <button id="handbackBtn">Handback</button>
177
+ <button id="toggleToolsBtn" class="secondary" type="button" aria-controls="toolPanel" aria-expanded="false">Show Tools</button>
157
178
  </div>
158
179
  <div class="control-grid" id="sessionControls"></div>
159
180
  <div id="messages" class="messages"></div>
@@ -171,7 +192,7 @@ export function renderDashboardApp() {
171
192
  <button>Send</button>
172
193
  </form>
173
194
  </div>
174
- <div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
195
+ <div class="panel side-panel" id="toolPanel" hidden><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
175
196
  </div>
176
197
  </section>
177
198
 
@@ -233,10 +254,13 @@ export function renderDashboardApp() {
233
254
 
234
255
  <section class="page" id="page-peers">
235
256
  <div class="panel">
236
- <div class="row"><button id="loadPeersBtn">Reload peers</button><button id="createPeerInviteBtn">Create invite</button><button id="addPeerBtn" class="secondary">Add peer</button></div>
257
+ <div class="row"><button id="loadPeersBtn">Reload peers</button><button id="createPeerInviteBtn">Create invite</button><button id="addPeerBtn" class="secondary">Add peer</button><button id="discoverPeersBtn" class="secondary">Discover LAN peers</button><button id="cancelPeerDiscoveryBtn" class="secondary">Cancel discovery</button><button id="exportPeerIdentityBtn" class="secondary">Export identity</button><button id="restorePeerIdentityBtn" class="secondary">Restore identity</button></div>
258
+ <div class="row"><input id="peerDiscoveryTargets" placeholder="Optional targets: 192.168.178.0/24, 192.168.178.10-50, host.local, https://host:31979"><input id="peerDiscoveryMaxHosts" type="number" min="1" max="65536" value="512" title="Max hosts"><input id="peerDiscoveryConcurrency" type="number" min="1" max="128" value="32" title="Concurrency"></div>
237
259
  <div id="peerStatus" class="list"></div>
238
260
  <h2>Configured peers</h2>
239
261
  <div id="peersList" class="list"></div>
262
+ <h2>LAN discovery</h2>
263
+ <div id="peerDiscovery" class="list"></div>
240
264
  <h2>Open invitations</h2>
241
265
  <div id="peerInvites" class="list"></div>
242
266
  </div>
@@ -361,3 +385,6 @@ export function renderDashboardApp() {
361
385
  </body>
362
386
  </html>`;
363
387
  }
388
+ function nonceAttr(cspNonce) {
389
+ return cspNonce ? ` nonce="${cspNonce.replace(/"/g, "")}"` : "";
390
+ }
@@ -1,10 +1,11 @@
1
- import { isPermission } from "./access-control.js";
2
- import { AGENT_IDS, isAgentId } from "./agent.js";
3
- import { ensurePeerTlsFiles, loadOrCreatePeerIdentity, } from "./peer-identity.js";
4
- import { checkPeerEndpoint, pairPeer, RemoteRelayClient } from "./peer-client.js";
5
- import { buildPeerReadiness, peerListenUrl } from "./peer-readiness.js";
6
- import { PeerStore } from "./peer-store.js";
7
- import { publicPeer } from "./peer-types.js";
1
+ import { isPermission } from "../access/access-control.js";
2
+ import { AGENT_IDS, isAgentId } from "../agents/shared/agent.js";
3
+ import { exportPeerIdentityBackup, ensurePeerTlsFiles, loadOrCreatePeerIdentity, restorePeerIdentityBackup, } from "../peers/peer-identity.js";
4
+ import { checkPeerEndpoint, checkPeerIdentityEndpoint, pairPeer, RemoteRelayClient } from "../peers/peer-client.js";
5
+ import { buildPeerReadiness, peerListenUrl } from "../peers/peer-readiness.js";
6
+ import { discoverLanPeers } from "../peers/peer-discovery.js";
7
+ import { PeerStore } from "../peers/peer-store.js";
8
+ import { publicPeer } from "../peers/peer-types.js";
8
9
  import { arrayStringField, objectRecord, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, } from "./web-dashboard-http.js";
9
10
  export async function handleDashboardPeerRoute(req, res, url, options) {
10
11
  const store = new PeerStore(options.home);
@@ -25,6 +26,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
25
26
  const readiness = await buildPeerReadiness(options.config);
26
27
  const created = store.createInvitation({
27
28
  name: optionalStringField(body, "name"),
29
+ group: optionalStringField(body, "group"),
28
30
  expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
29
31
  scopes: parseScopes(arrayStringField(body, "scopes")),
30
32
  allowedAgents: parseAgents(arrayStringField(body, "allowedAgents")),
@@ -53,11 +55,63 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
53
55
  if (peerId) {
54
56
  const probe = await new RemoteRelayClient(store).rpc(peerId, "peer.probe", {}, options.activityActor);
55
57
  sendJson(res, 200, { type: "remote", peerId, readiness, probe });
58
+ options.auditPeerAction?.("peer_probe", peerId);
56
59
  return true;
57
60
  }
58
61
  const expectedTlsFingerprint = options.config.peerPublicUrl ? undefined : tls?.fingerprint;
59
62
  const probe = await checkPeerEndpoint(readiness.listenUrl, { expectedTlsFingerprint });
60
63
  sendJson(res, 200, { type: "local", readiness, probe });
64
+ options.auditPeerAction?.("peer_probe", readiness.listenUrl);
65
+ return true;
66
+ }
67
+ if (req.method === "GET" && url.pathname === "/api/peers/discover") {
68
+ const result = await discoverLanPeers(options.config, discoveryOptionsFromQuery(url));
69
+ sendJson(res, 200, result);
70
+ options.auditPeerAction?.("peer_discovery_started", `sync scan ${result.scanned} targets`);
71
+ return true;
72
+ }
73
+ if (req.method === "GET" && url.pathname === "/api/peers/discovery-jobs") {
74
+ sendJson(res, 200, { jobs: options.discoveryJobs?.list() ?? [] });
75
+ return true;
76
+ }
77
+ if (req.method === "POST" && url.pathname === "/api/peers/discovery-jobs") {
78
+ const body = await readJsonBody(req);
79
+ const job = await options.discoveryJobs.start(discoveryOptionsFromBody(body));
80
+ sendJson(res, 202, { job });
81
+ options.auditPeerAction?.("peer_discovery_started", job.id);
82
+ return true;
83
+ }
84
+ const discoveryJobMatch = url.pathname.match(/^\/api\/peers\/discovery-jobs\/([^/]+)(?:\/(cancel|log))?$/);
85
+ if (discoveryJobMatch?.[1]) {
86
+ const id = decodeURIComponent(discoveryJobMatch[1]);
87
+ const action = discoveryJobMatch[2];
88
+ if (req.method === "GET" && action === "log") {
89
+ sendJson(res, 200, { id, plain: options.discoveryJobs?.log(id) ?? "" });
90
+ return true;
91
+ }
92
+ if (req.method === "POST" && action === "cancel") {
93
+ const job = options.discoveryJobs?.cancel(id);
94
+ sendJson(res, 200, { job });
95
+ options.auditPeerAction?.("peer_discovery_cancelled", id);
96
+ return true;
97
+ }
98
+ if (req.method === "GET" && !action) {
99
+ sendJson(res, 200, { job: options.discoveryJobs?.get(id) ?? null });
100
+ return true;
101
+ }
102
+ }
103
+ if (req.method === "GET" && url.pathname === "/api/peers/identity/backup") {
104
+ const backup = exportPeerIdentityBackup(options.home, options.config.peerName);
105
+ sendJson(res, 200, { backup });
106
+ options.auditPeerAction?.("peer_identity_backup_exported", backup.identity.nodeId);
107
+ return true;
108
+ }
109
+ if (req.method === "POST" && url.pathname === "/api/peers/identity/restore") {
110
+ const body = await readJsonBody(req);
111
+ const backup = objectRecord(body.backup);
112
+ const restored = restorePeerIdentityBackup(backup, options.home);
113
+ sendJson(res, 200, { identity: restored.public });
114
+ options.auditPeerAction?.("peer_identity_restored", restored.public.nodeId);
61
115
  return true;
62
116
  }
63
117
  const invitationMatch = url.pathname.match(/^\/api\/peers\/invitations\/([^/]+)$/);
@@ -86,6 +140,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
86
140
  const body = await readJsonBody(req);
87
141
  const peer = store.updatePeer(decodeURIComponent(peerMatch[1]), {
88
142
  name: optionalStringField(body, "name"),
143
+ group: optionalStringField(body, "group"),
89
144
  url: optionalStringField(body, "url"),
90
145
  enabled: optionalBooleanField(body, "enabled"),
91
146
  scopes: body.scopes === undefined ? undefined : parseScopes(arrayStringField(body, "scopes")),
@@ -97,6 +152,25 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
97
152
  options.auditPeerAction?.("peer_updated", `${peer.name} (${peer.id})`);
98
153
  return true;
99
154
  }
155
+ const repinMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/repin$/);
156
+ if (repinMatch?.[1] && req.method === "POST") {
157
+ const peerId = decodeURIComponent(repinMatch[1]);
158
+ const peer = store.get(peerId);
159
+ if (!peer?.url) {
160
+ throw new Error("Peer URL is required before TLS re-pin.");
161
+ }
162
+ const probe = await checkPeerIdentityEndpoint(peer.url, { timeoutMs: options.config.peerDiscoveryTimeoutMs });
163
+ if (!probe.ok || !probe.identity) {
164
+ throw new Error(`Peer identity could not be verified: ${probe.detail}`);
165
+ }
166
+ if (probe.identity.nodeId !== peer.nodeId || probe.identity.publicKey !== peer.publicKey || probe.identity.fingerprint !== peer.fingerprint) {
167
+ throw new Error("Peer identity changed. Re-pair this peer instead of re-pinning TLS.");
168
+ }
169
+ const updated = store.updatePeerTlsFingerprint(peer.id, probe.tlsFingerprint);
170
+ sendJson(res, 200, { peer: publicPeer(updated), probe });
171
+ options.auditPeerAction?.("peer_tls_repinned", `${updated.name} (${updated.id})`);
172
+ return true;
173
+ }
100
174
  if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
101
175
  const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
102
176
  const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
@@ -152,6 +226,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
152
226
  const peerId = decodeURIComponent(healthMatch[1]);
153
227
  const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
154
228
  sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
229
+ options.auditPeerAction?.("peer_health_checked", peerId);
155
230
  return true;
156
231
  }
157
232
  const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
@@ -189,6 +264,26 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
189
264
  function parseScopes(values) {
190
265
  return values.filter(isPermission);
191
266
  }
267
+ function discoveryOptionsFromQuery(url) {
268
+ return {
269
+ targets: url.searchParams.getAll("target").concat((url.searchParams.get("targets") ?? "").split(/[\n,]/)).map((value) => value.trim()).filter(Boolean),
270
+ timeoutMs: optionalPositiveNumber(url.searchParams.get("timeoutMs")),
271
+ concurrency: optionalPositiveNumber(url.searchParams.get("concurrency")),
272
+ maxHosts: optionalPositiveNumber(url.searchParams.get("maxHosts")),
273
+ };
274
+ }
275
+ function discoveryOptionsFromBody(body) {
276
+ return {
277
+ targets: arrayStringField(body, "targets"),
278
+ timeoutMs: optionalNumberField(body, "timeoutMs"),
279
+ concurrency: optionalNumberField(body, "concurrency"),
280
+ maxHosts: optionalNumberField(body, "maxHosts"),
281
+ };
282
+ }
283
+ function optionalPositiveNumber(value) {
284
+ const parsed = Number(value);
285
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
286
+ }
192
287
  function parseAgents(values) {
193
288
  const parsed = values.filter(isAgentId);
194
289
  return parsed.length > 0 ? parsed : [...AGENT_IDS];
@@ -0,0 +1,14 @@
1
+ import { randomBytes } from "node:crypto";
2
+ export function createCspNonce() {
3
+ return randomBytes(16).toString("base64url");
4
+ }
5
+ export function requiresWebCsrf(method, pathname) {
6
+ const verb = (method ?? "GET").toUpperCase();
7
+ if (verb === "GET" || verb === "HEAD" || verb === "OPTIONS") {
8
+ return false;
9
+ }
10
+ return pathname.startsWith("/api/");
11
+ }
12
+ export function isMutatingWebApiRequest(method, pathname) {
13
+ return requiresWebCsrf(method, pathname);
14
+ }
@@ -1,4 +1,4 @@
1
- import { isAgentId } from "./agent.js";
1
+ import { isAgentId } from "../agents/shared/agent.js";
2
2
  import { numberParam, optionalBooleanField, optionalStringField, parseUploadFiles, readJsonBody, requiredSearch, sendJson, stringField, } from "./web-dashboard-http.js";
3
3
  export async function handleDashboardSessionRoute(req, res, url, options) {
4
4
  const { runtime, authUser } = options;
@@ -190,6 +190,17 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
190
190
  sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
191
191
  return true;
192
192
  }
193
+ if (req.method === "GET" && url.pathname === "/api/chat/mirror") {
194
+ await options.assertCurrentSessionScope(authUser);
195
+ sendJson(res, 200, await runtime.webMirrorPreference(""));
196
+ return true;
197
+ }
198
+ if (req.method === "POST" && url.pathname === "/api/chat/mirror") {
199
+ const body = await readJsonBody(req);
200
+ await options.assertCurrentSessionScope(authUser);
201
+ sendJson(res, 200, await runtime.webMirrorPreference(optionalStringField(body, "argument") ?? optionalStringField(body, "mode") ?? "", options.activityActor));
202
+ return true;
203
+ }
193
204
  if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
194
205
  await options.assertCurrentSessionScope(authUser);
195
206
  sendJson(res, 200, await runtime.clearChatHistory(options.activityActor));