@nordbyte/nordrelay 0.8.1 → 0.8.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/.env.example +9 -0
  2. package/README.md +84 -1205
  3. package/dist/{access-control.js → access/access-control.js} +1 -1
  4. package/dist/{audit-log.js → access/audit-log.js} +32 -15
  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} +176 -451
  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/channels/shared/channel-prompt-queue.js +37 -0
  40. package/dist/{channel-turn-service.js → channels/shared/channel-turn-service.js} +25 -11
  41. package/dist/{context-key.js → channels/shared/context-key.js} +1 -1
  42. package/dist/{session-format.js → channels/shared/session-format.js} +2 -2
  43. package/dist/{slack-artifacts.js → channels/slack/slack-artifacts.js} +4 -4
  44. package/dist/{slack-bot.js → channels/slack/slack-bot.js} +171 -309
  45. package/dist/{slack-channel-runtime.js → channels/slack/slack-channel-runtime.js} +2 -2
  46. package/dist/{slack-command-surface.js → channels/slack/slack-command-surface.js} +2 -2
  47. package/dist/{slack-diagnostics.js → channels/slack/slack-diagnostics.js} +2 -2
  48. package/dist/{bot-ui.js → channels/telegram/bot-ui.js} +1 -1
  49. package/dist/{bot.js → channels/telegram/bot.js} +195 -430
  50. package/dist/{telegram-access-commands.js → channels/telegram/telegram-access-commands.js} +3 -3
  51. package/dist/{telegram-access-middleware.js → channels/telegram/telegram-access-middleware.js} +4 -4
  52. package/dist/{telegram-agent-commands.js → channels/telegram/telegram-agent-commands.js} +9 -9
  53. package/dist/{telegram-artifact-commands.js → channels/telegram/telegram-artifact-commands.js} +4 -4
  54. package/dist/{telegram-channel-runtime.js → channels/telegram/telegram-channel-runtime.js} +2 -2
  55. package/dist/{telegram-command-menu.js → channels/telegram/telegram-command-menu.js} +1 -1
  56. package/dist/{telegram-diagnostics-command.js → channels/telegram/telegram-diagnostics-command.js} +7 -7
  57. package/dist/{telegram-general-commands.js → channels/telegram/telegram-general-commands.js} +4 -4
  58. package/dist/{telegram-operational-commands.js → channels/telegram/telegram-operational-commands.js} +5 -5
  59. package/dist/{telegram-output.js → channels/telegram/telegram-output.js} +2 -2
  60. package/dist/{telegram-preference-commands.js → channels/telegram/telegram-preference-commands.js} +3 -3
  61. package/dist/{telegram-queue-commands.js → channels/telegram/telegram-queue-commands.js} +6 -6
  62. package/dist/{telegram-support-command.js → channels/telegram/telegram-support-command.js} +4 -4
  63. package/dist/{telegram-update-commands.js → channels/telegram/telegram-update-commands.js} +5 -5
  64. package/dist/{config-metadata.js → core/config-metadata.js} +8 -0
  65. package/dist/{config.js → core/config.js} +11 -3
  66. package/dist/core/pagination.js +22 -0
  67. package/dist/index.js +27 -23
  68. package/dist/peers/peer-discovery-jobs.js +206 -0
  69. package/dist/peers/peer-discovery.js +223 -0
  70. package/dist/peers/peer-health-monitor.js +49 -0
  71. package/dist/{peer-identity.js → peers/peer-identity.js} +50 -1
  72. package/dist/{peer-runtime-service.js → peers/peer-runtime-service.js} +29 -7
  73. package/dist/{peer-server.js → peers/peer-server.js} +3 -2
  74. package/dist/{peer-store.js → peers/peer-store.js} +96 -9
  75. package/dist/{peer-types.js → peers/peer-types.js} +28 -0
  76. package/dist/peers/peer-web-proxy-contract.js +129 -0
  77. package/dist/{metrics.js → runtime/metrics.js} +5 -3
  78. package/dist/{relay-artifact-service.js → runtime/relay-artifact-service.js} +1 -1
  79. package/dist/runtime/relay-auth-service.js +63 -0
  80. package/dist/runtime/relay-dashboard-service.js +139 -0
  81. package/dist/{relay-external-activity-monitor.js → runtime/relay-external-activity-monitor.js} +155 -53
  82. package/dist/{relay-queue-service.js → runtime/relay-queue-service.js} +1 -0
  83. package/dist/runtime/relay-runtime-active-sessions.js +387 -0
  84. package/dist/runtime/relay-runtime-dashboard.js +204 -0
  85. package/dist/{relay-runtime-helpers.js → runtime/relay-runtime-helpers.js} +3 -0
  86. package/dist/runtime/relay-runtime-prompt-queue-artifacts.js +311 -0
  87. package/dist/runtime/relay-runtime-sessions.js +631 -0
  88. package/dist/runtime/relay-runtime-trace.js +92 -0
  89. package/dist/runtime/relay-runtime-types.js +1 -0
  90. package/dist/runtime/relay-runtime-updates-jobs.js +366 -0
  91. package/dist/runtime/relay-runtime.js +461 -0
  92. package/dist/runtime/runtime-cache.js +117 -0
  93. package/dist/{prompt-store.js → state/prompt-store.js} +13 -1
  94. package/dist/{session-registry.js → state/session-registry.js} +3 -3
  95. package/dist/{operations.js → support/operations.js} +7 -7
  96. package/dist/{support-bundle.js → support/support-bundle.js} +1 -1
  97. package/dist/{web-api-contract.js → web/web-api-contract.js} +19 -3
  98. package/dist/web/web-api-types.js +1 -0
  99. package/dist/{web-dashboard-access-routes.js → web/web-dashboard-access-routes.js} +17 -14
  100. package/dist/{web-dashboard-artifact-routes.js → web/web-dashboard-artifact-routes.js} +6 -2
  101. package/dist/{web-dashboard-assets.js → web/web-dashboard-assets.js} +25 -2
  102. package/dist/{web-dashboard-http.js → web/web-dashboard-http.js} +41 -5
  103. package/dist/{web-dashboard-pages.js → web/web-dashboard-pages.js} +95 -30
  104. package/dist/{web-dashboard-peer-routes.js → web/web-dashboard-peer-routes.js} +121 -7
  105. package/dist/{web-dashboard-runtime-routes.js → web/web-dashboard-runtime-routes.js} +8 -1
  106. package/dist/web/web-dashboard-security.js +14 -0
  107. package/dist/{web-dashboard-session-routes.js → web/web-dashboard-session-routes.js} +29 -13
  108. package/dist/web/web-dashboard-ui.js +56 -0
  109. package/dist/{web-dashboard.js → web/web-dashboard.js} +132 -48
  110. package/dist/web/web-performance.js +62 -0
  111. package/dist/web/web-rate-limit.js +19 -0
  112. package/dist/{web-state.js → web/web-state.js} +107 -9
  113. package/dist/webui-assets/dashboard.css +398 -49
  114. package/dist/webui-assets/dashboard.js +1239 -103
  115. package/dist/webui-assets/favicon.ico +0 -0
  116. package/dist/webui-assets/favicon.png +0 -0
  117. package/dist/webui-assets/logo.png +0 -0
  118. package/package.json +6 -3
  119. package/plugins/nordrelay/scripts/nordrelay.mjs +346 -12
  120. package/plugins/nordrelay/scripts/service-installer.mjs +183 -0
  121. package/{launchd/start.sh → scripts/launchd-start.sh} +1 -1
  122. package/scripts/postinstall.mjs +122 -0
  123. package/dist/relay-runtime.js +0 -1916
  124. package/dist/runtime-cache.js +0 -57
  125. package/dist/web-dashboard-ui.js +0 -20
  126. /package/dist/{user-management-crypto.js → access/user-management-crypto.js} +0 -0
  127. /package/dist/{user-management-normalize.js → access/user-management-normalize.js} +0 -0
  128. /package/dist/{user-management-types.js → access/user-management-types.js} +0 -0
  129. /package/dist/{claude-code-auth.js → agents/claude-code/claude-code-auth.js} +0 -0
  130. /package/dist/{claude-code-launch.js → agents/claude-code/claude-code-launch.js} +0 -0
  131. /package/dist/{claude-code-state.js → agents/claude-code/claude-code-state.js} +0 -0
  132. /package/dist/{codex-auth.js → agents/codex/codex-auth.js} +0 -0
  133. /package/dist/{codex-config.js → agents/codex/codex-config.js} +0 -0
  134. /package/dist/{codex-launch.js → agents/codex/codex-launch.js} +0 -0
  135. /package/dist/{codex-state.js → agents/codex/codex-state.js} +0 -0
  136. /package/dist/{hermes-api.js → agents/hermes/hermes-api.js} +0 -0
  137. /package/dist/{hermes-auth.js → agents/hermes/hermes-auth.js} +0 -0
  138. /package/dist/{hermes-state.js → agents/hermes/hermes-state.js} +0 -0
  139. /package/dist/{openclaw-auth.js → agents/openclaw/openclaw-auth.js} +0 -0
  140. /package/dist/{openclaw-gateway.js → agents/openclaw/openclaw-gateway.js} +0 -0
  141. /package/dist/{openclaw-state.js → agents/openclaw/openclaw-state.js} +0 -0
  142. /package/dist/{pi-auth.js → agents/pi/pi-auth.js} +0 -0
  143. /package/dist/{pi-rpc.js → agents/pi/pi-rpc.js} +0 -0
  144. /package/dist/{pi-state.js → agents/pi/pi-state.js} +0 -0
  145. /package/dist/{agent-adapter.js → agents/shared/agent-adapter.js} +0 -0
  146. /package/dist/{agent.js → agents/shared/agent.js} +0 -0
  147. /package/dist/{artifacts.js → artifacts/artifacts.js} +0 -0
  148. /package/dist/{attachments.js → artifacts/attachments.js} +0 -0
  149. /package/dist/{voice.js → artifacts/voice.js} +0 -0
  150. /package/dist/{discord-rate-limit.js → channels/discord/discord-rate-limit.js} +0 -0
  151. /package/dist/{channel-adapter.js → channels/shared/channel-adapter.js} +0 -0
  152. /package/dist/{relay-runtime-types.js → channels/shared/channel-bridge-state.js} +0 -0
  153. /package/dist/{channel-command-catalog.js → channels/shared/channel-command-catalog.js} +0 -0
  154. /package/dist/{channel-command-core.js → channels/shared/channel-command-core.js} +0 -0
  155. /package/dist/{channel-prompt-engine.js → channels/shared/channel-prompt-engine.js} +0 -0
  156. /package/dist/{channel-runtime.js → channels/shared/channel-runtime.js} +0 -0
  157. /package/dist/{channel-turn-lifecycle.js → channels/shared/channel-turn-lifecycle.js} +0 -0
  158. /package/dist/{slack-rate-limit.js → channels/slack/slack-rate-limit.js} +0 -0
  159. /package/dist/{telegram-command-types.js → channels/telegram/telegram-command-types.js} +0 -0
  160. /package/dist/{telegram-rate-limit.js → channels/telegram/telegram-rate-limit.js} +0 -0
  161. /package/dist/{activity-events.js → core/activity-events.js} +0 -0
  162. /package/dist/{error-messages.js → core/error-messages.js} +0 -0
  163. /package/dist/{format.js → core/format.js} +0 -0
  164. /package/dist/{logger.js → core/logger.js} +0 -0
  165. /package/dist/{redaction.js → core/redaction.js} +0 -0
  166. /package/dist/{settings-service.js → core/settings-service.js} +0 -0
  167. /package/dist/{settings-wizard-test.js → core/settings-wizard-test.js} +0 -0
  168. /package/dist/{workspace-policy.js → core/workspace-policy.js} +0 -0
  169. /package/dist/{peer-auth.js → peers/peer-auth.js} +0 -0
  170. /package/dist/{peer-client.js → peers/peer-client.js} +0 -0
  171. /package/dist/{peer-context.js → peers/peer-context.js} +0 -0
  172. /package/dist/{peer-readiness.js → peers/peer-readiness.js} +0 -0
  173. /package/dist/{web-api-types.js → runtime/relay-runtime-delegate.js} +0 -0
  174. /package/dist/{remote-prompt.js → runtime/remote-prompt.js} +0 -0
  175. /package/dist/{bot-preferences.js → state/bot-preferences.js} +0 -0
  176. /package/dist/{job-store.js → state/job-store.js} +0 -0
  177. /package/dist/{persistence.js → state/persistence.js} +0 -0
  178. /package/dist/{state-backend.js → state/state-backend.js} +0 -0
  179. /package/dist/{zip-writer.js → support/zip-writer.js} +0 -0
@@ -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,44 @@ 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
+ }
174
+ const rotateMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/rotate$/);
175
+ if (rotateMatch?.[1] && req.method === "POST") {
176
+ const body = await readJsonBody(req);
177
+ const readiness = await buildPeerReadiness(options.config);
178
+ const created = store.createRotationInvitation(decodeURIComponent(rotateMatch[1]), {
179
+ expiresInMs: (optionalNumberField(body, "expiresMinutes") ?? 10) * 60 * 1000,
180
+ });
181
+ const command = `nordrelay peer add ${readiness.listenUrl} --code ${created.code}`;
182
+ sendJson(res, 201, {
183
+ peer: created.peer,
184
+ invitation: created.invitation,
185
+ code: created.code,
186
+ command,
187
+ readiness,
188
+ warnings: readiness.warnings,
189
+ });
190
+ options.auditPeerAction?.("peer_rotation_invite_created", `${created.peer.name} (${created.peer.id})`);
191
+ return true;
192
+ }
100
193
  if (req.method === "GET" && url.pathname === "/api/peers/global-sessions") {
101
194
  const query = optionalStringField(Object.fromEntries(url.searchParams), "query") ?? "";
102
195
  const agent = parseAgent(optionalStringField(Object.fromEntries(url.searchParams), "agent"));
@@ -152,6 +245,7 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
152
245
  const peerId = decodeURIComponent(healthMatch[1]);
153
246
  const data = await new RemoteRelayClient(store).rpc(peerId, "peer.ping", undefined, options.activityActor);
154
247
  sendJson(res, 200, { data, peer: publicPeer(store.get(peerId)) });
248
+ options.auditPeerAction?.("peer_health_checked", peerId);
155
249
  return true;
156
250
  }
157
251
  const eventsMatch = url.pathname.match(/^\/api\/peers\/([^/]+)\/events$/);
@@ -189,6 +283,26 @@ export async function handleDashboardPeerRoute(req, res, url, options) {
189
283
  function parseScopes(values) {
190
284
  return values.filter(isPermission);
191
285
  }
286
+ function discoveryOptionsFromQuery(url) {
287
+ return {
288
+ targets: url.searchParams.getAll("target").concat((url.searchParams.get("targets") ?? "").split(/[\n,]/)).map((value) => value.trim()).filter(Boolean),
289
+ timeoutMs: optionalPositiveNumber(url.searchParams.get("timeoutMs")),
290
+ concurrency: optionalPositiveNumber(url.searchParams.get("concurrency")),
291
+ maxHosts: optionalPositiveNumber(url.searchParams.get("maxHosts")),
292
+ };
293
+ }
294
+ function discoveryOptionsFromBody(body) {
295
+ return {
296
+ targets: arrayStringField(body, "targets"),
297
+ timeoutMs: optionalNumberField(body, "timeoutMs"),
298
+ concurrency: optionalNumberField(body, "concurrency"),
299
+ maxHosts: optionalNumberField(body, "maxHosts"),
300
+ };
301
+ }
302
+ function optionalPositiveNumber(value) {
303
+ const parsed = Number(value);
304
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
305
+ }
192
306
  function parseAgents(values) {
193
307
  const parsed = values.filter(isAgentId);
194
308
  return parsed.length > 0 ? parsed : [...AGENT_IDS];
@@ -63,7 +63,14 @@ export async function handleDashboardRuntimeRoute(req, res, url, options) {
63
63
  return true;
64
64
  }
65
65
  if (req.method === "GET" && url.pathname === "/api/jobs") {
66
- sendJson(res, 200, await runtime.jobs());
66
+ sendJson(res, 200, await runtime.jobs({
67
+ limit: numberParam(url, "limit", 100),
68
+ cursor: url.searchParams.get("cursor") || undefined,
69
+ }));
70
+ return true;
71
+ }
72
+ if (req.method === "GET" && url.pathname === "/api/trace") {
73
+ sendJson(res, 200, await runtime.trace(stringField(Object.fromEntries(url.searchParams), "correlationId")));
67
74
  return true;
68
75
  }
69
76
  const jobLogMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/log$/);
@@ -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,5 +1,6 @@
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
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
3
4
  export async function handleDashboardSessionRoute(req, res, url, options) {
4
5
  const { runtime, authUser } = options;
5
6
  if (req.method === "GET" && url.pathname === "/api/locks") {
@@ -190,25 +191,40 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
190
191
  sendJson(res, 200, { messages: await runtime.chatHistory(numberParam(url, "limit", 200)) });
191
192
  return true;
192
193
  }
194
+ if (req.method === "GET" && url.pathname === "/api/chat/mirror") {
195
+ await options.assertCurrentSessionScope(authUser);
196
+ sendJson(res, 200, await runtime.webMirrorPreference(""));
197
+ return true;
198
+ }
199
+ if (req.method === "POST" && url.pathname === "/api/chat/mirror") {
200
+ const body = await readJsonBody(req);
201
+ await options.assertCurrentSessionScope(authUser);
202
+ sendJson(res, 200, await runtime.webMirrorPreference(optionalStringField(body, "argument") ?? optionalStringField(body, "mode") ?? "", options.activityActor));
203
+ return true;
204
+ }
193
205
  if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
194
206
  await options.assertCurrentSessionScope(authUser);
195
207
  sendJson(res, 200, await runtime.clearChatHistory(options.activityActor));
196
208
  return true;
197
209
  }
198
210
  if (req.method === "GET" && url.pathname === "/api/activity") {
211
+ const limit = normalizeCursorLimit(numberParam(url, "limit", 100), 100, 500);
212
+ const scoped = options.filterActivityByScope(authUser, runtime.activity({
213
+ limit: 500,
214
+ source: (url.searchParams.get("source") || "all"),
215
+ status: (url.searchParams.get("status") || "all"),
216
+ category: (url.searchParams.get("category") || "all"),
217
+ actor: url.searchParams.get("actor") || undefined,
218
+ agentId: url.searchParams.get("agent") || "all",
219
+ threadId: url.searchParams.get("thread") || undefined,
220
+ workspace: url.searchParams.get("workspace") || undefined,
221
+ type: url.searchParams.get("type") || undefined,
222
+ since: url.searchParams.get("since") || undefined,
223
+ }));
224
+ const scopedPage = cursorPage(scoped, url.searchParams.get("cursor") || undefined, limit, (event) => event.id);
199
225
  sendJson(res, 200, {
200
- events: options.filterActivityByScope(authUser, runtime.activity({
201
- limit: numberParam(url, "limit", 100),
202
- source: (url.searchParams.get("source") || "all"),
203
- status: (url.searchParams.get("status") || "all"),
204
- category: (url.searchParams.get("category") || "all"),
205
- actor: url.searchParams.get("actor") || undefined,
206
- agentId: url.searchParams.get("agent") || "all",
207
- threadId: url.searchParams.get("thread") || undefined,
208
- workspace: url.searchParams.get("workspace") || undefined,
209
- type: url.searchParams.get("type") || undefined,
210
- since: url.searchParams.get("since") || undefined,
211
- })),
226
+ events: scopedPage.items,
227
+ pagination: scopedPage.pagination,
212
228
  });
213
229
  return true;
214
230
  }
@@ -0,0 +1,56 @@
1
+ export const DASHBOARD_PRIMARY_NAV_PAGES = [
2
+ { id: "overview", label: "Overview", permission: "inspect" },
3
+ { id: "chat", label: "Chat", permission: "sessions.read" },
4
+ { id: "sessions", label: "Sessions", permission: "sessions.read" },
5
+ { id: "queue", label: "Queue", permission: "queue.read" },
6
+ { id: "tasks", label: "Tasks", permission: "inspect" },
7
+ { id: "activity", label: "Activity", permission: "sessions.read" },
8
+ { id: "trace", label: "Trace", permission: "sessions.read" },
9
+ { id: "artifacts", label: "Artifacts", permission: "files.read" },
10
+ ];
11
+ export const DASHBOARD_NAV_SECTIONS = [
12
+ {
13
+ id: "operations",
14
+ label: "Operations",
15
+ pages: [
16
+ { id: "metrics", label: "Metrics", permission: "inspect" },
17
+ { id: "adapters", label: "Adapters", permission: "inspect" },
18
+ { id: "version", label: "Version", permission: "inspect" },
19
+ { id: "logs", label: "Logs", permission: "logs.read" },
20
+ { id: "diagnostics", label: "Diagnostics", permission: "diagnostics.read" },
21
+ ],
22
+ },
23
+ {
24
+ id: "administration",
25
+ label: "Administration",
26
+ pages: [
27
+ { id: "access", label: "Users", permission: "users.read" },
28
+ { id: "settings", label: "Settings", permission: "settings.read" },
29
+ { id: "peers", label: "Peers", permission: "peers.read" },
30
+ ],
31
+ },
32
+ ];
33
+ export const DASHBOARD_PAGES = [
34
+ ...DASHBOARD_PRIMARY_NAV_PAGES,
35
+ ...DASHBOARD_NAV_SECTIONS.flatMap((section) => section.pages),
36
+ ];
37
+ function renderDashboardPageButton(page, activePage) {
38
+ return `<button type="button" data-page="${page.id}" data-permission="${page.permission}"${page.id === activePage ? ' class="active"' : ""}>${page.label}</button>`;
39
+ }
40
+ function renderDashboardNavSection(section, activePage) {
41
+ const isOpen = section.defaultOpen === true || section.pages.some((page) => page.id === activePage);
42
+ const itemsId = `nav-section-${section.id}`;
43
+ return `<div class="nav-section" data-nav-section="${section.id}" data-nav-open="${isOpen ? "true" : "false"}" data-nav-default-open="${section.defaultOpen === true ? "true" : "false"}">
44
+ <button type="button" class="nav-section-toggle" data-nav-toggle="${section.id}" aria-expanded="${isOpen ? "true" : "false"}" aria-controls="${itemsId}">${section.label}</button>
45
+ <div class="nav-section-items" id="${itemsId}"${isOpen ? "" : " hidden"}>
46
+ ${section.pages.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
47
+ </div>
48
+ </div>`;
49
+ }
50
+ export function renderDashboardNav(activePage = "overview") {
51
+ const primary = `<div class="nav-primary">
52
+ ${DASHBOARD_PRIMARY_NAV_PAGES.map((page) => renderDashboardPageButton(page, activePage)).join("\n ")}
53
+ </div>`;
54
+ const sections = DASHBOARD_NAV_SECTIONS.map((section) => renderDashboardNavSection(section, activePage)).join("\n ");
55
+ return `${primary}\n ${sections}`;
56
+ }
@@ -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),