@nordbyte/nordrelay 0.8.1 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/.env.example +9 -0
  2. package/README.md +81 -1206
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +2 -2
  5. package/dist/{session-locks.js → access/session-locks.js} +1 -1
  6. package/dist/{user-management.js → access/user-management.js} +1 -1
  7. package/dist/{claude-code-cli.js → agents/claude-code/claude-code-cli.js} +2 -2
  8. package/dist/{claude-code-session.js → agents/claude-code/claude-code-session.js} +1 -1
  9. package/dist/{codex-cli.js → agents/codex/codex-cli.js} +14 -5
  10. package/dist/{codex-session.js → agents/codex/codex-session.js} +2 -4
  11. package/dist/{hermes-cli.js → agents/hermes/hermes-cli.js} +2 -2
  12. package/dist/{hermes-launch.js → agents/hermes/hermes-launch.js} +1 -1
  13. package/dist/{hermes-session.js → agents/hermes/hermes-session.js} +1 -1
  14. package/dist/{openclaw-cli.js → agents/openclaw/openclaw-cli.js} +2 -2
  15. package/dist/{openclaw-launch.js → agents/openclaw/openclaw-launch.js} +1 -1
  16. package/dist/{openclaw-session.js → agents/openclaw/openclaw-session.js} +1 -1
  17. package/dist/{pi-cli.js → agents/pi/pi-cli.js} +2 -2
  18. package/dist/{pi-launch.js → agents/pi/pi-launch.js} +1 -1
  19. package/dist/{pi-session.js → agents/pi/pi-session.js} +1 -1
  20. package/dist/{adapter-conformance.js → agents/shared/adapter-conformance.js} +2 -2
  21. package/dist/{agent-activity.js → agents/shared/agent-activity.js} +5 -5
  22. package/dist/agents/shared/agent-auth-commands.js +30 -0
  23. package/dist/{agent-factory.js → agents/shared/agent-factory.js} +5 -5
  24. package/dist/{agent-feature-matrix.js → agents/shared/agent-feature-matrix.js} +2 -2
  25. package/dist/{agent-updates.js → agents/shared/agent-updates.js} +7 -7
  26. package/dist/{discord-artifacts.js → channels/discord/discord-artifacts.js} +4 -4
  27. package/dist/{discord-bot.js → channels/discord/discord-bot.js} +164 -424
  28. package/dist/{discord-channel-runtime.js → channels/discord/discord-channel-runtime.js} +2 -2
  29. package/dist/{discord-command-surface.js → channels/discord/discord-command-surface.js} +3 -3
  30. package/dist/{bot-rendering.js → channels/shared/bot-rendering.js} +6 -6
  31. package/dist/{channel-actions.js → channels/shared/channel-actions.js} +4 -4
  32. package/dist/channels/shared/channel-bridge-controller.js +69 -0
  33. package/dist/channels/shared/channel-cli-artifacts.js +51 -0
  34. package/dist/{channel-command-service.js → channels/shared/channel-command-service.js} +51 -28
  35. package/dist/channels/shared/channel-external-mirror-controller.js +193 -0
  36. package/dist/channels/shared/channel-external-monitor.js +52 -0
  37. package/dist/{channel-mirror-registry.js → channels/shared/channel-mirror-registry.js} +14 -6
  38. package/dist/{channel-peer-prompt.js → channels/shared/channel-peer-prompt.js} +3 -3
  39. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +2 -2
  40. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  41. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  42. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  43. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +159 -294
  44. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  45. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  46. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  47. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  48. package/dist/{bot.js → channels/telegram/bot.js} +178 -427
  49. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  50. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  51. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  52. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  53. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  54. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  55. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  56. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  57. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  58. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  59. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  60. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  61. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  62. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  63. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  64. package/dist/{config.js → core/config.js} +11 -3
  65. package/dist/index.js +27 -23
  66. package/dist/peers/peer-discovery-jobs.js +206 -0
  67. package/dist/peers/peer-discovery.js +223 -0
  68. package/dist/peers/peer-health-monitor.js +49 -0
  69. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  70. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  71. package/dist/{peer-server.js → peers/peer-server.js} +3 -2
  72. package/dist/{peer-store.js → peers/peer-store.js} +80 -9
  73. package/dist/{peer-types.js → peers/peer-types.js} +9 -0
  74. package/dist/peers/peer-web-proxy-contract.js +127 -0
  75. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  76. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  77. package/dist/runtime/relay-auth-service.js +63 -0
  78. package/dist/runtime/relay-dashboard-service.js +139 -0
  79. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +140 -53
  80. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  81. package/dist/runtime/relay-runtime-dashboard.js +201 -0
  82. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +307 -0
  83. package/dist/runtime/relay-runtime-sessions.js +623 -0
  84. package/dist/runtime/relay-runtime-types.js +1 -0
  85. package/dist/runtime/relay-runtime-updates-jobs.js +360 -0
  86. package/dist/runtime/relay-runtime.js +451 -0
  87. package/dist/runtime/runtime-cache.js +117 -0
  88. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  89. package/dist/{operations.js → support/operations.js} +7 -7
  90. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  91. package/dist/{web-api-contract.js → web/web-api-contract.js} +17 -3
  92. package/dist/web/web-api-types.js +1 -0
  93. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +2 -2
  94. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +24 -2
  95. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  96. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +37 -10
  97. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +102 -7
  98. package/dist/web/web-dashboard-security.js +14 -0
  99. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +12 -1
  100. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  101. package/dist/web/web-performance.js +60 -0
  102. package/dist/web/web-rate-limit.js +19 -0
  103. package/dist/{web-state.js → web/web-state.js} +74 -5
  104. package/dist/webui-assets/dashboard.css +171 -10
  105. package/dist/webui-assets/dashboard.js +514 -48
  106. package/dist/webui-assets/favicon.ico +0 -0
  107. package/dist/webui-assets/favicon.png +0 -0
  108. package/dist/webui-assets/logo.png +0 -0
  109. package/package.json +4 -3
  110. package/plugins/nordrelay/scripts/nordrelay.mjs +13 -4
  111. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  112. package/dist/relay-runtime.js +0 -1916
  113. package/dist/runtime-cache.js +0 -57
  114. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  115. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  116. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  117. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  118. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  119. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  120. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  121. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  122. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  123. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  124. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  125. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  126. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  127. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  128. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  129. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  130. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  131. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  132. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  133. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  134. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  135. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  136. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  137. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  138. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  139. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  140. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  141. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  142. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  143. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  144. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  145. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  146. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  147. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  148. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  149. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  150. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  151. /package/dist/{format.js → core/format.js} +0 -0
  152. /package/dist/{logger.js → core/logger.js} +0 -0
  153. /package/dist/{redaction.js → core/redaction.js} +0 -0
  154. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  155. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  156. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  157. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  158. /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
  159. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  160. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  161. /package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +0 -0
  162. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  163. /package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +0 -0
  164. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  165. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  166. /package/dist/{job-store.js → state/job-store.js} +0 -0
  167. /package/dist/{persistence.js → state/persistence.js} +0 -0
  168. /package/dist/{prompt-store.js → state/prompt-store.js} +0 -0
  169. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  170. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
  171. /package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +0 -0
  172. /package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +0 -0
  173. /package/dist/{web-dashboard-ui.js → web/web-dashboard-ui.js} +0 -0
@@ -1,47 +1,68 @@
1
1
  import { createServer } from "node:http";
2
- import { randomBytes } from "node:crypto";
2
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { URL } from "node:url";
6
- import { enabledAgents } from "./agent-factory.js";
7
- import { buildAdapterConformanceMatrix } from "./adapter-conformance.js";
8
- import { listAgentAdapterDescriptors } from "./agent-adapter.js";
9
- import { isAgentId } from "./agent.js";
10
- import { AuditLogStore } from "./audit-log.js";
11
- import { listChannelDescriptors } from "./channel-adapter.js";
12
- import { permissionForWebRequest } from "./access-control.js";
13
- import { loadConfig } from "./config.js";
14
- import { friendlyErrorText } from "./error-messages.js";
15
- import { RelayRuntime } from "./relay-runtime.js";
16
- import { resolveDashboardEnvPath, SettingsService } from "./settings-service.js";
17
- import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "./settings-wizard-test.js";
18
- import { UserStore, publicUser } from "./user-management.js";
6
+ import { enabledAgents } from "../agents/shared/agent-factory.js";
7
+ import { buildAdapterConformanceMatrix } from "../agents/shared/adapter-conformance.js";
8
+ import { listAgentAdapterDescriptors } from "../agents/shared/agent-adapter.js";
9
+ import { isAgentId } from "../agents/shared/agent.js";
10
+ import { AuditLogStore } from "../access/audit-log.js";
11
+ import { listChannelDescriptors } from "../channels/shared/channel-adapter.js";
12
+ import { permissionForWebRequest } from "../access/access-control.js";
13
+ import { loadConfig } from "../core/config.js";
14
+ import { friendlyErrorText } from "../core/error-messages.js";
15
+ import { RelayRuntime } from "../runtime/relay-runtime.js";
16
+ import { resolveDashboardEnvPath, SettingsService } from "../core/settings-service.js";
17
+ import { mergeSettingsWizardTestSettings, runSettingsWizardTest } from "../core/settings-wizard-test.js";
18
+ import { UserStore, publicUser } from "../access/user-management.js";
19
19
  import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
20
20
  import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
21
- import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
22
- import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
21
+ import { dashboardCss, dashboardJs, dashboardStaticAsset } from "./web-dashboard-assets.js";
22
+ import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, sendStaticFile, isRequestBodyTooLargeError, } from "./web-dashboard-http.js";
23
23
  import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
24
24
  import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
25
25
  import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
26
26
  import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
27
+ import { PeerDiscoveryJobManager } from "../peers/peer-discovery-jobs.js";
28
+ import { recordWebApiMetric } from "./web-performance.js";
29
+ import { createCspNonce, isMutatingWebApiRequest, requiresWebCsrf } from "./web-dashboard-security.js";
30
+ import { consumeRateLimit, resetRateLimit } from "./web-rate-limit.js";
27
31
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
32
+ const WEB_API_MUTATION_LIMIT = 240;
33
+ const WEB_API_MUTATION_WINDOW_MS = 60_000;
34
+ const WEB_API_MUTATION_BLOCK_MS = 60_000;
28
35
  const options = parseOptions(process.argv.slice(2));
29
36
  const config = loadConfig();
30
37
  const runtime = new RelayRuntime(config);
31
38
  const settings = new SettingsService(resolveDashboardEnvPath(options.home));
32
39
  const users = new UserStore(options.home);
33
40
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
41
+ const peerDiscoveryJobs = new PeerDiscoveryJobManager(config, options.home);
34
42
  const loginAttempts = new Map();
43
+ const apiMutationAttempts = new Map();
35
44
  const firstRunSetupToken = users.hasAdminUser() ? undefined : randomBytes(18).toString("base64url");
36
45
  const firstRunSetupRequiresToken = !isLoopbackHost(options.host);
46
+ const csrfSecret = randomBytes(32).toString("base64url");
37
47
  if (firstRunSetupToken) {
38
48
  console.log(`NordRelay first-run setup token: ${firstRunSetupToken}`);
39
49
  }
40
50
  class AccessDeniedError extends Error {
41
51
  }
42
52
  const server = createServer((req, res) => {
53
+ const startedAt = Date.now();
54
+ const pathName = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`).pathname;
55
+ res.on("finish", () => {
56
+ recordWebApiMetric({
57
+ method: req.method ?? "GET",
58
+ path: pathName,
59
+ statusCode: res.statusCode,
60
+ durationMs: Date.now() - startedAt,
61
+ });
62
+ });
43
63
  void handleRequest(req, res).catch((error) => {
44
- sendJson(res, error instanceof AccessDeniedError ? 403 : 500, { error: friendlyErrorText(error) });
64
+ const status = error instanceof AccessDeniedError ? 403 : isRequestBodyTooLargeError(error) ? 413 : 500;
65
+ sendJson(res, status, { error: friendlyErrorText(error) });
45
66
  });
46
67
  });
47
68
  await new Promise((resolve) => server.listen(options.port, options.host, resolve));
@@ -62,27 +83,52 @@ async function handleRequest(req, res) {
62
83
  handleLogout(req, res);
63
84
  return;
64
85
  }
86
+ if (servePublicDashboardAsset(url.pathname, res)) {
87
+ return;
88
+ }
65
89
  const authenticated = authenticateRequest(req);
66
90
  if (url.pathname === "/api/auth/me" && req.method === "GET") {
67
91
  if (!authenticated) {
68
92
  sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
69
93
  return;
70
94
  }
71
- sendJson(res, 200, currentUserDto(authenticated));
95
+ sendJson(res, 200, currentUserDto(authenticated, req));
72
96
  return;
73
97
  }
74
98
  if (!authenticated) {
75
99
  if (url.pathname === "/" || url.pathname === "/index.html") {
100
+ const cspNonce = createCspNonce();
76
101
  if (!users.hasAdminUser()) {
77
- sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req) }), "text/html; charset=utf-8");
102
+ sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req), cspNonce }), "text/html; charset=utf-8", { cspNonce });
78
103
  return;
79
104
  }
80
- sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
105
+ sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser(), cspNonce }), "text/html; charset=utf-8", { cspNonce });
81
106
  return;
82
107
  }
83
108
  sendJson(res, 401, { error: "Authentication required", adminConfigured: users.hasAdminUser() });
84
109
  return;
85
110
  }
111
+ if (isMutatingWebApiRequest(req.method, url.pathname)) {
112
+ const limited = consumeRateLimit(apiMutationAttempts, `${req.socket.remoteAddress ?? "unknown"}:${authenticated.user.id}`, WEB_API_MUTATION_LIMIT, WEB_API_MUTATION_WINDOW_MS, WEB_API_MUTATION_BLOCK_MS);
113
+ if (limited.limited) {
114
+ sendJson(res, 429, { error: "Too many API changes. Try again later.", retryAfterMs: limited.retryAfterMs });
115
+ return;
116
+ }
117
+ }
118
+ if (requiresCsrf(req, url) && !verifyCsrf(req)) {
119
+ audit({
120
+ action: "permission_denied",
121
+ status: "denied",
122
+ channelId: "web",
123
+ contextKey: "web",
124
+ actor: webActivityActor(authenticated),
125
+ actorId: authenticated.user.id,
126
+ actorRole: authenticated.groups.map((group) => group.name).join(", "),
127
+ description: `Invalid CSRF token for ${req.method ?? "GET"} ${url.pathname}`,
128
+ });
129
+ sendJson(res, 403, { error: "Invalid CSRF token." });
130
+ return;
131
+ }
86
132
  if (url.pathname === "/healthz") {
87
133
  if (!users.hasPermission(authenticated, "inspect")) {
88
134
  sendText(res, 403, "access denied\n", "text/plain; charset=utf-8");
@@ -92,7 +138,8 @@ async function handleRequest(req, res) {
92
138
  return;
93
139
  }
94
140
  if (url.pathname === "/" || url.pathname === "/index.html") {
95
- sendText(res, 200, renderDashboardApp(), "text/html; charset=utf-8");
141
+ const cspNonce = createCspNonce();
142
+ sendText(res, 200, renderDashboardApp({ cspNonce }), "text/html; charset=utf-8", { cspNonce });
96
143
  return;
97
144
  }
98
145
  if (url.pathname === "/assets/dashboard.css") {
@@ -113,6 +160,25 @@ async function handleRequest(req, res) {
113
160
  }
114
161
  await handleApi(req, res, url, authenticated);
115
162
  }
163
+ function servePublicDashboardAsset(pathname, res) {
164
+ const assetName = pathname === "/favicon.ico"
165
+ ? "favicon.ico"
166
+ : pathname === "/assets/favicon.png"
167
+ ? "favicon.png"
168
+ : pathname === "/assets/logo.png"
169
+ ? "logo.png"
170
+ : null;
171
+ if (!assetName) {
172
+ return false;
173
+ }
174
+ const asset = dashboardStaticAsset(assetName);
175
+ if (!asset) {
176
+ sendText(res, 404, "not found\n", "text/plain; charset=utf-8");
177
+ return true;
178
+ }
179
+ sendStaticFile(res, asset.filePath, asset.contentType);
180
+ return true;
181
+ }
116
182
  async function handleApi(req, res, url, authUser) {
117
183
  const permission = permissionForWebRequest(req.method, url.pathname);
118
184
  if (!permission) {
@@ -160,7 +226,7 @@ async function handleApi(req, res, url, authUser) {
160
226
  if (req.method === "GET" && url.pathname === "/api/bootstrap") {
161
227
  await assertCurrentSessionScope(authUser);
162
228
  sendJson(res, 200, {
163
- auth: currentUserDto(authUser),
229
+ auth: currentUserDto(authUser, req),
164
230
  channels: listChannelDescriptors(),
165
231
  agentAdapters: listAgentAdapterDescriptors().filter((adapter) => users.canUseAgent(authUser, adapter.id)),
166
232
  adapterConformance: scopedAdapterConformance(authUser),
@@ -192,6 +258,7 @@ async function handleApi(req, res, url, authUser) {
192
258
  config,
193
259
  home: options.home,
194
260
  runtime,
261
+ discoveryJobs: peerDiscoveryJobs,
195
262
  activityActor: webActivityActor(authUser),
196
263
  auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
197
264
  })) {
@@ -330,8 +397,8 @@ async function handleFirstRunSetup(req, res) {
330
397
  actor: webActivityActor(authUser),
331
398
  detail: authUser.user.email,
332
399
  });
333
- setSessionCookie(res, session.token);
334
- sendJson(res, 201, currentUserDto(authUser));
400
+ setSessionCookie(res, session.token, req);
401
+ sendJson(res, 201, currentUserDto(authUser, undefined, session.token));
335
402
  }
336
403
  async function handleLogin(req, res) {
337
404
  const body = await readJsonBody(req);
@@ -387,8 +454,8 @@ async function handleLogin(req, res) {
387
454
  actor: webActivityActor(authUser),
388
455
  detail: authUser.user.email,
389
456
  });
390
- setSessionCookie(res, session.token);
391
- sendJson(res, 200, currentUserDto(authUser));
457
+ setSessionCookie(res, session.token, req);
458
+ sendJson(res, 200, currentUserDto(authUser, undefined, session.token));
392
459
  }
393
460
  function isLoopbackRequest(req) {
394
461
  const address = req.socket.remoteAddress ?? "";
@@ -402,6 +469,10 @@ function isLoopbackHost(host) {
402
469
  }
403
470
  function handleLogout(req, res) {
404
471
  const authUser = authenticateRequest(req);
472
+ if (authUser && !verifyCsrf(req)) {
473
+ sendJson(res, 403, { error: "Invalid CSRF token." });
474
+ return;
475
+ }
405
476
  users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
406
477
  if (authUser) {
407
478
  auditUserAction(authUser, "auth_logout", authUser.user.email);
@@ -431,19 +502,49 @@ function authenticateRequest(req) {
431
502
  const cookies = parseCookies(req.headers.cookie ?? "");
432
503
  return users.resolveWebSession(cookies.nr_session);
433
504
  }
434
- function setSessionCookie(res, token) {
435
- res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/`);
505
+ function setSessionCookie(res, token, req) {
506
+ const secure = req && isHttpsRequest(req) ? "; Secure" : "";
507
+ res.setHeader("set-cookie", `nr_session=${encodeURIComponent(token)}; HttpOnly; SameSite=Strict; Path=/${secure}`);
436
508
  }
437
509
  function clearSessionCookie(res) {
438
510
  res.setHeader("set-cookie", "nr_session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0");
439
511
  }
440
- function currentUserDto(authUser) {
512
+ function isHttpsRequest(req) {
513
+ return Boolean(req.socket.encrypted) ||
514
+ String(req.headers["x-forwarded-proto"] ?? "").split(",")[0]?.trim().toLowerCase() === "https";
515
+ }
516
+ function currentUserDto(authUser, req, sessionToken) {
517
+ const token = sessionToken ?? (req ? parseCookies(req.headers.cookie ?? "").nr_session : undefined);
441
518
  return {
442
519
  user: publicUser(authUser.user),
443
520
  groups: authUser.groups,
444
521
  permissions: authUser.permissions,
522
+ csrfToken: token ? csrfTokenForSession(token) : undefined,
445
523
  };
446
524
  }
525
+ function requiresCsrf(req, url) {
526
+ return requiresWebCsrf(req.method, url.pathname);
527
+ }
528
+ function verifyCsrf(req) {
529
+ const sessionToken = parseCookies(req.headers.cookie ?? "").nr_session;
530
+ const supplied = headerValue(req, "x-nordrelay-csrf");
531
+ if (!sessionToken || !supplied) {
532
+ return false;
533
+ }
534
+ return safeEqualString(supplied, csrfTokenForSession(sessionToken));
535
+ }
536
+ function csrfTokenForSession(sessionToken) {
537
+ return createHmac("sha256", csrfSecret).update(sessionToken).digest("base64url");
538
+ }
539
+ function headerValue(req, name) {
540
+ const value = req.headers[name.toLowerCase()];
541
+ return Array.isArray(value) ? value[0] ?? "" : value ?? "";
542
+ }
543
+ function safeEqualString(left, right) {
544
+ const leftBuffer = Buffer.from(left);
545
+ const rightBuffer = Buffer.from(right);
546
+ return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
547
+ }
447
548
  function audit(event) {
448
549
  try {
449
550
  auditLog.append(event);
@@ -612,25 +713,6 @@ async function assertCurrentSessionScope(authUser) {
612
713
  function objectValue(value) {
613
714
  return value && typeof value === "object" && !Array.isArray(value) ? value : null;
614
715
  }
615
- function consumeRateLimit(buckets, key, limit, windowMs, blockMs) {
616
- const now = Date.now();
617
- const existing = buckets.get(key);
618
- if (existing?.blockedUntil && existing.blockedUntil > now) {
619
- return { limited: true, retryAfterMs: existing.blockedUntil - now };
620
- }
621
- const bucket = !existing || existing.resetAt <= now ? { count: 0, resetAt: now + windowMs } : existing;
622
- bucket.count += 1;
623
- if (bucket.count > limit) {
624
- bucket.blockedUntil = now + blockMs;
625
- buckets.set(key, bucket);
626
- return { limited: true, retryAfterMs: blockMs };
627
- }
628
- buckets.set(key, bucket);
629
- return { limited: false };
630
- }
631
- function resetRateLimit(buckets, key) {
632
- buckets.delete(key);
633
- }
634
716
  function parseAgentId(value) {
635
717
  if (!value) {
636
718
  return undefined;
@@ -742,6 +824,8 @@ function activeSettingsValues(current) {
742
824
  TELEGRAM_EDIT_MIN_INTERVAL_MS: String(current.telegramEditMinIntervalMs),
743
825
  NORDRELAY_CLI_MIRROR_MODE: current.mirrorMode,
744
826
  NORDRELAY_CLI_MIRROR_MIN_UPDATE_MS: String(current.mirrorMinUpdateMs),
827
+ NORDRELAY_WEB_CLI_MIRROR_MODE: current.webMirrorMode === current.mirrorMode ? "" : current.webMirrorMode,
828
+ NORDRELAY_WEB_CLI_MIRROR_MIN_UPDATE_MS: current.webMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.webMirrorMinUpdateMs),
745
829
  NORDRELAY_NOTIFY_MODE: current.notifyMode,
746
830
  NORDRELAY_QUIET_HOURS: quietValue(current.quietHours),
747
831
  NORDRELAY_AUTO_SEND_ARTIFACTS: boolValue(current.autoSendArtifacts),
@@ -0,0 +1,60 @@
1
+ const recent = [];
2
+ const routeMetrics = new Map();
3
+ const MAX_RECENT = 200;
4
+ export function recordWebApiMetric(sample) {
5
+ const next = {
6
+ ...sample,
7
+ durationMs: Math.max(0, Math.round(sample.durationMs)),
8
+ at: sample.at ?? new Date().toISOString(),
9
+ };
10
+ recent.push(next);
11
+ if (recent.length > MAX_RECENT) {
12
+ recent.splice(0, recent.length - MAX_RECENT);
13
+ }
14
+ const key = `${next.method} ${routeKey(next.path)}`;
15
+ const existing = routeMetrics.get(key);
16
+ if (!existing) {
17
+ routeMetrics.set(key, {
18
+ method: next.method,
19
+ path: routeKey(next.path),
20
+ count: 1,
21
+ averageMs: next.durationMs,
22
+ maxMs: next.durationMs,
23
+ lastMs: next.durationMs,
24
+ lastStatusCode: next.statusCode,
25
+ lastAt: next.at,
26
+ totalMs: next.durationMs,
27
+ });
28
+ return;
29
+ }
30
+ existing.count += 1;
31
+ existing.totalMs += next.durationMs;
32
+ existing.averageMs = Math.round(existing.totalMs / existing.count);
33
+ existing.maxMs = Math.max(existing.maxMs, next.durationMs);
34
+ existing.lastMs = next.durationMs;
35
+ existing.lastStatusCode = next.statusCode;
36
+ existing.lastAt = next.at;
37
+ }
38
+ export function getWebApiPerformanceMetrics() {
39
+ return {
40
+ recent: [...recent].reverse().slice(0, 25),
41
+ slowest: [...recent].sort((left, right) => right.durationMs - left.durationMs).slice(0, 10),
42
+ routes: [...routeMetrics.values()]
43
+ .map(({ totalMs: _totalMs, ...metric }) => ({ ...metric }))
44
+ .sort((left, right) => right.averageMs - left.averageMs)
45
+ .slice(0, 25),
46
+ };
47
+ }
48
+ function routeKey(path) {
49
+ return path
50
+ .replace(/\/api\/peers\/[^/]+\/proxy$/, "/api/peers/:id/proxy")
51
+ .replace(/\/api\/peers\/[^/]+\/events$/, "/api/peers/:id/events")
52
+ .replace(/\/api\/peers\/[^/]+\/health$/, "/api/peers/:id/health")
53
+ .replace(/\/api\/peers\/[^/]+\/repin$/, "/api/peers/:id/repin")
54
+ .replace(/\/api\/agent-update\/[^/]+\/(log|input|cancel)$/, "/api/agent-update/:id/$1")
55
+ .replace(/\/api\/jobs\/[^/]+\/(log|action)$/, "/api/jobs/:id/$1")
56
+ .replace(/\/api\/users\/[^/]+\/sessions\/[^/]+$/, "/api/users/:id/sessions/:sessionId")
57
+ .replace(/\/api\/users\/[^/]+\/(password|telegram|discord|slack|sessions)$/, "/api/users/:id/$1")
58
+ .replace(/\/api\/peers\/discovery-jobs\/[^/]+\/(cancel|log)$/, "/api/peers/discovery-jobs/:id/$1")
59
+ .replace(/\/api\/peers\/discovery-jobs\/[^/]+$/, "/api/peers/discovery-jobs/:id");
60
+ }
@@ -0,0 +1,19 @@
1
+ export function consumeRateLimit(buckets, key, maxAttempts, windowMs, blockMs, now = Date.now()) {
2
+ const existing = buckets.get(key);
3
+ if (existing?.blockedUntil && existing.blockedUntil > now) {
4
+ return { limited: true, retryAfterMs: existing.blockedUntil - now };
5
+ }
6
+ if (!existing || existing.resetAt <= now) {
7
+ buckets.set(key, { count: 1, resetAt: now + windowMs });
8
+ return { limited: false };
9
+ }
10
+ existing.count += 1;
11
+ if (existing.count > maxAttempts) {
12
+ existing.blockedUntil = now + blockMs;
13
+ return { limited: true, retryAfterMs: blockMs };
14
+ }
15
+ return { limited: false };
16
+ }
17
+ export function resetRateLimit(buckets, key) {
18
+ buckets.delete(key);
19
+ }
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { activityActorLabel, activityCategoryForType, } from "./activity-events.js";
3
- import { createDocumentStore } from "./state-backend.js";
2
+ import { activityActorLabel, activityCategoryForType, } from "../core/activity-events.js";
3
+ import { createDocumentStore } from "../state/state-backend.js";
4
4
  const DEFAULT_CHAT_LIMIT = 300;
5
5
  const DEFAULT_ACTIVITY_LIMIT = 1000;
6
6
  export class WebChatStore {
@@ -16,9 +16,16 @@ export class WebChatStore {
16
16
  this.maxMessages = maxMessages;
17
17
  }
18
18
  append(input) {
19
+ return this.appendWithResult(input).message;
20
+ }
21
+ appendWithResult(input) {
19
22
  const payload = this.readPayload();
20
23
  const threadId = input.threadId || "pending";
21
24
  const messages = payload.messagesByThread[threadId] ?? [];
25
+ const duplicate = findDuplicateWebChatMessage(messages, { ...input, threadId });
26
+ if (duplicate) {
27
+ return { message: duplicate, inserted: false };
28
+ }
22
29
  const message = {
23
30
  id: randomId(),
24
31
  timestamp: input.timestamp ?? new Date().toISOString(),
@@ -31,7 +38,37 @@ export class WebChatStore {
31
38
  }
32
39
  payload.messagesByThread[threadId] = messages;
33
40
  this.store.write(payload);
34
- return message;
41
+ return { message, inserted: true };
42
+ }
43
+ upsertByKey(input) {
44
+ const payload = this.readPayload();
45
+ const threadId = input.threadId || "pending";
46
+ const messages = payload.messagesByThread[threadId] ?? [];
47
+ const now = new Date().toISOString();
48
+ const existing = messages.find((message) => message.key === input.key);
49
+ if (existing) {
50
+ existing.role = input.role;
51
+ existing.text = input.text;
52
+ existing.source = input.source;
53
+ existing.turnId = input.turnId;
54
+ existing.timestamp = input.timestamp ?? now;
55
+ existing.key = input.key;
56
+ this.store.write(payload);
57
+ return { message: existing, inserted: false, updated: true };
58
+ }
59
+ const message = {
60
+ id: randomId(),
61
+ timestamp: input.timestamp ?? now,
62
+ ...input,
63
+ threadId,
64
+ };
65
+ messages.push(message);
66
+ if (messages.length > this.maxMessages) {
67
+ messages.splice(0, messages.length - this.maxMessages);
68
+ }
69
+ payload.messagesByThread[threadId] = messages;
70
+ this.store.write(payload);
71
+ return { message, inserted: true, updated: false };
35
72
  }
36
73
  list(threadId, limit = 200) {
37
74
  const messages = this.readPayload().messagesByThread[threadId || "pending"] ?? [];
@@ -53,7 +90,7 @@ export class WebChatStore {
53
90
  const messagesByThread = {};
54
91
  for (const [threadId, messages] of Object.entries(payload.messagesByThread)) {
55
92
  if (Array.isArray(messages)) {
56
- messagesByThread[threadId] = messages.filter(isWebChatMessage).slice(-this.maxMessages);
93
+ messagesByThread[threadId] = dedupeWebChatMessages(messages.filter(isWebChatMessage)).slice(-this.maxMessages);
57
94
  }
58
95
  }
59
96
  return { version: 1, messagesByThread };
@@ -122,6 +159,7 @@ function isWebChatMessage(value) {
122
159
  typeof candidate.threadId === "string" &&
123
160
  typeof candidate.text === "string" &&
124
161
  typeof candidate.timestamp === "string" &&
162
+ (candidate.key === undefined || typeof candidate.key === "string") &&
125
163
  ["user", "agent", "system", "tool"].includes(candidate.role) &&
126
164
  ["web", "telegram", "discord", "slack", "cli"].includes(candidate.source);
127
165
  }
@@ -159,4 +197,35 @@ function activityActorMatches(actor, query) {
159
197
  function randomId() {
160
198
  return randomUUID().replace(/-/g, "").slice(0, 12);
161
199
  }
162
- export { activityActorLabel, activityCategoryForType, auditCategoryForAction, } from "./activity-events.js";
200
+ function dedupeWebChatMessages(messages) {
201
+ const seen = new Set();
202
+ return messages.filter((message) => {
203
+ const key = webChatDedupKey(message);
204
+ if (!key) {
205
+ return true;
206
+ }
207
+ if (seen.has(key)) {
208
+ return false;
209
+ }
210
+ seen.add(key);
211
+ return true;
212
+ });
213
+ }
214
+ function findDuplicateWebChatMessage(messages, input) {
215
+ const key = webChatDedupKey(input);
216
+ return key ? messages.find((message) => webChatDedupKey(message) === key) : undefined;
217
+ }
218
+ function webChatDedupKey(message) {
219
+ const threadId = message.threadId || "pending";
220
+ if (message.key) {
221
+ return [threadId, message.key].join("\0");
222
+ }
223
+ if (message.turnId) {
224
+ return [threadId, message.role, message.source, message.turnId, message.text].join("\0");
225
+ }
226
+ if (message.timestamp) {
227
+ return [threadId, message.role, message.source, message.timestamp, message.text].join("\0");
228
+ }
229
+ return null;
230
+ }
231
+ export { activityActorLabel, activityCategoryForType, auditCategoryForAction, } from "../core/activity-events.js";