@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
@@ -9,6 +9,7 @@ export const WEB_API_ROUTE_DEFINITIONS = [
9
9
  exact("/api/progress", ["GET"], "inspect"),
10
10
  exact("/api/metrics", ["GET"], "inspect"),
11
11
  exact("/api/jobs", ["GET"], "inspect"),
12
+ exact("/api/trace", ["GET"], "sessions.read"),
12
13
  dynamic("/api/jobs/:id/log", "^/api/jobs/[^/]+/log$", ["GET"], "inspect", `/api/jobs/${stringToken}/log`),
13
14
  dynamic("/api/jobs/:id/action", "^/api/jobs/[^/]+/action$", ["POST"], "inspect", `/api/jobs/${stringToken}/action`),
14
15
  exact("/api/active-sessions", ["GET"], "sessions.read"),
@@ -25,8 +26,17 @@ export const WEB_API_ROUTE_DEFINITIONS = [
25
26
  exact("/api/peers/invite", ["POST"], "peers.write"),
26
27
  exact("/api/peers/pair", ["POST"], "peers.write"),
27
28
  exact("/api/peers/probe", ["POST"], "peers.connect"),
29
+ exact("/api/peers/discover", ["GET"], "peers.connect"),
30
+ exact("/api/peers/discovery-jobs", ["GET", "POST"], readWrite("peers.connect", "peers.connect")),
31
+ dynamic("/api/peers/discovery-jobs/:id", "^/api/peers/discovery-jobs/[^/]+$", ["GET"], "peers.connect", `/api/peers/discovery-jobs/${stringToken}`),
32
+ dynamic("/api/peers/discovery-jobs/:id/cancel", "^/api/peers/discovery-jobs/[^/]+/cancel$", ["POST"], "peers.connect", `/api/peers/discovery-jobs/${stringToken}/cancel`),
33
+ dynamic("/api/peers/discovery-jobs/:id/log", "^/api/peers/discovery-jobs/[^/]+/log$", ["GET"], "peers.connect", `/api/peers/discovery-jobs/${stringToken}/log`),
34
+ exact("/api/peers/identity/backup", ["GET"], "peers.write"),
35
+ exact("/api/peers/identity/restore", ["POST"], "peers.write"),
28
36
  exact("/api/peers/global-sessions", ["GET"], "sessions.read"),
29
37
  dynamic("/api/peers/invitations/:id", "^/api/peers/invitations/[^/]+$", ["DELETE"], "peers.write", `/api/peers/invitations/${stringToken}`),
38
+ dynamic("/api/peers/:id/repin", "^/api/peers/[^/]+/repin$", ["POST"], "peers.write", `/api/peers/${stringToken}/repin`),
39
+ dynamic("/api/peers/:id/rotate", "^/api/peers/[^/]+/rotate$", ["POST"], "peers.write", `/api/peers/${stringToken}/rotate`),
30
40
  dynamic("/api/peers/:id/health", "^/api/peers/[^/]+/health$", ["GET"], "peers.connect", `/api/peers/${stringToken}/health`),
31
41
  dynamic("/api/peers/:id", "^/api/peers/[^/]+$", ["PATCH", "DELETE"], "peers.write", `/api/peers/${stringToken}`),
32
42
  dynamic("/api/peers/:id/proxy", "^/api/peers/[^/]+/proxy$", ["POST"], "peers.connect", `/api/peers/${stringToken}/proxy`),
@@ -79,6 +89,7 @@ export const WEB_API_ROUTE_DEFINITIONS = [
79
89
  exact("/api/sync", ["POST"], "sessions.write"),
80
90
  exact("/api/queue", ["GET", "POST"], readWrite("queue.read", "queue.write")),
81
91
  exact("/api/chat/history", ["GET", "DELETE"], readWrite("sessions.read", "sessions.write")),
92
+ exact("/api/chat/mirror", ["GET", "POST"], readWrite("sessions.read", "settings.write")),
82
93
  exact("/api/activity", ["GET"], "sessions.read"),
83
94
  exact("/api/artifacts", ["GET", "DELETE"], readWrite("files.read", "files.write")),
84
95
  exact("/api/artifacts/bulk", ["POST"], "files.write"),
@@ -96,11 +107,16 @@ export const WEB_API_STATIC_PATHS = WEB_API_ROUTE_DEFINITIONS
96
107
  .map((route) => route.path);
97
108
  export const WEB_API_DYNAMIC_TYPE_PATHS = WEB_API_ROUTE_DEFINITIONS
98
109
  .flatMap((route) => route.dynamicType ? [route.dynamicType] : []);
110
+ export function routeForWebRequest(method, pathname) {
111
+ const verb = normalizeMethod(method);
112
+ const route = WEB_API_ROUTE_DEFINITIONS.find((candidate) => (candidate.pattern ? new RegExp(candidate.pattern).test(pathname) : candidate.path === pathname));
113
+ const methods = route?.methods ?? [];
114
+ return route && methods.includes(verb) ? route : null;
115
+ }
99
116
  export function permissionForWebRequestFromContract(method, pathname) {
100
117
  const verb = normalizeMethod(method);
101
- const rule = WEB_API_ROUTE_DEFINITIONS.find((candidate) => (candidate.pattern ? new RegExp(candidate.pattern).test(pathname) : candidate.path === pathname));
102
- const methods = rule?.methods ?? [];
103
- if (!rule || !methods.includes(verb)) {
118
+ const rule = routeForWebRequest(verb, pathname);
119
+ if (!rule) {
104
120
  return null;
105
121
  }
106
122
  return resolvePermission(rule.permissions, verb);
@@ -0,0 +1 @@
1
+ export {};
@@ -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;
@@ -278,19 +278,22 @@ export async function handleDashboardAccessRoute(req, res, url, options) {
278
278
  return true;
279
279
  }
280
280
  if (req.method === "GET" && url.pathname === "/api/audit") {
281
+ const page = runtime.auditPage({
282
+ limit: numberParam(url, "limit", 50),
283
+ cursor: url.searchParams.get("cursor") || undefined,
284
+ channelId: (url.searchParams.get("channel") || "all"),
285
+ category: (url.searchParams.get("category") || "all"),
286
+ status: (url.searchParams.get("status") || "all"),
287
+ action: url.searchParams.get("action") || "all",
288
+ actor: url.searchParams.get("actor") || undefined,
289
+ agentId: url.searchParams.get("agent") || "all",
290
+ threadId: url.searchParams.get("thread") || undefined,
291
+ workspace: url.searchParams.get("workspace") || undefined,
292
+ since: url.searchParams.get("since") || undefined,
293
+ });
281
294
  sendJson(res, 200, {
282
- events: runtime.audit({
283
- limit: numberParam(url, "limit", 50),
284
- channelId: (url.searchParams.get("channel") || "all"),
285
- category: (url.searchParams.get("category") || "all"),
286
- status: (url.searchParams.get("status") || "all"),
287
- action: url.searchParams.get("action") || "all",
288
- actor: url.searchParams.get("actor") || undefined,
289
- agentId: url.searchParams.get("agent") || "all",
290
- threadId: url.searchParams.get("thread") || undefined,
291
- workspace: url.searchParams.get("workspace") || undefined,
292
- since: url.searchParams.get("since") || undefined,
293
- }),
295
+ events: page.items,
296
+ pagination: page.pagination,
294
297
  });
295
298
  return true;
296
299
  }
@@ -1,9 +1,13 @@
1
- import { readJsonBody, requiredSearch, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
1
+ import { cursorPage, normalizeCursorLimit } from "../core/pagination.js";
2
+ import { numberParam, readJsonBody, requiredSearch, sendFile, sendJson, stringField, } from "./web-dashboard-http.js";
2
3
  export async function handleDashboardArtifactRoute(req, res, url, options) {
3
4
  const { runtime, authUser } = options;
4
5
  if (req.method === "GET" && url.pathname === "/api/artifacts") {
5
6
  await options.assertCurrentSessionScope(authUser);
6
- sendJson(res, 200, { reports: await runtime.artifacts() });
7
+ const limit = normalizeCursorLimit(numberParam(url, "limit", 50), 50, 200);
8
+ const reports = await runtime.artifacts(500);
9
+ const page = cursorPage(reports, url.searchParams.get("cursor") || undefined, limit, (report) => report.turnId);
10
+ sendJson(res, 200, { reports: page.items, pagination: page.pagination });
7
11
  return true;
8
12
  }
9
13
  if (req.method === "DELETE" && url.pathname === "/api/artifacts") {
@@ -6,12 +6,14 @@ 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",
12
13
  "client/jobs.js",
13
14
  "client/metrics.js",
14
15
  "client/admin.js",
16
+ "client/users.js",
15
17
  "client/settings-wizard.js",
16
18
  ];
17
19
  const styleSources = [
@@ -26,13 +28,34 @@ export function dashboardJs() {
26
28
  export function dashboardCss() {
27
29
  return readDashboardAsset("dashboard.css", styleSources);
28
30
  }
31
+ const staticAssetTypes = {
32
+ "favicon.ico": "image/x-icon",
33
+ "favicon.png": "image/png",
34
+ "logo.png": "image/png",
35
+ };
36
+ export function dashboardStaticAsset(assetName) {
37
+ const contentType = staticAssetTypes[assetName];
38
+ if (!contentType) {
39
+ return null;
40
+ }
41
+ const filePath = dashboardStaticAssetPath(assetName);
42
+ return filePath ? { filePath, contentType } : null;
43
+ }
29
44
  function readDashboardAsset(assetName, sourceFiles) {
30
- const builtAsset = path.join(moduleDir, "webui-assets", assetName);
45
+ const builtAsset = path.resolve(moduleDir, "..", "webui-assets", assetName);
31
46
  if (existsSync(builtAsset)) {
32
47
  return readFileSync(builtAsset, "utf8");
33
48
  }
34
- const sourceDir = path.join(moduleDir, "webui");
49
+ const sourceDir = path.join(moduleDir, "ui");
35
50
  return sourceFiles
36
51
  .map((file) => readFileSync(path.join(sourceDir, file), "utf8"))
37
52
  .join("\n");
38
53
  }
54
+ function dashboardStaticAssetPath(assetName) {
55
+ const builtAsset = path.resolve(moduleDir, "..", "webui-assets", assetName);
56
+ if (existsSync(builtAsset)) {
57
+ return builtAsset;
58
+ }
59
+ const sourceAsset = path.join(moduleDir, "ui", "assets", assetName);
60
+ return existsSync(sourceAsset) ? sourceAsset : null;
61
+ }
@@ -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>
@@ -126,7 +137,6 @@ export function renderDashboardApp() {
126
137
  <select id="peerSelect" title="NordRelay target"></select>
127
138
  <select id="agentSelect"></select>
128
139
  <button id="themeBtn" class="secondary" title="Toggle dark theme">Dark</button>
129
- <button id="refreshBtn">Refresh</button>
130
140
  <button id="logoutBtn" class="secondary">Logout</button>
131
141
  </div>
132
142
  </header>
@@ -143,7 +153,7 @@ export function renderDashboardApp() {
143
153
  </section>
144
154
 
145
155
  <section class="page" id="page-chat">
146
- <div class="chat-layout">
156
+ <div class="chat-layout tools-hidden" id="chatLayout">
147
157
  <div class="panel chat-panel">
148
158
  <div class="chat-toolbar">
149
159
  <button id="newSessionBtn">New session</button>
@@ -151,9 +161,19 @@ export function renderDashboardApp() {
151
161
  <button id="editLastBtn" class="secondary">Edit last</button>
152
162
  <button id="syncBtn" class="secondary">Sync</button>
153
163
  <button id="notifyBtn" class="secondary">Notify</button>
164
+ <label class="mirror-control" title="Mirror local CLI activity into this WebUI chat">
165
+ Mirror
166
+ <select id="mirrorModeSelect">
167
+ <option value="off">Off</option>
168
+ <option value="status">Status</option>
169
+ <option value="final">Final</option>
170
+ <option value="full">Full</option>
171
+ </select>
172
+ </label>
154
173
  <button id="clearChatBtn" class="secondary">Clear history</button>
155
174
  <button id="abortBtn">Abort</button>
156
175
  <button id="handbackBtn">Handback</button>
176
+ <button id="toggleToolsBtn" class="secondary" type="button" aria-controls="toolPanel" aria-expanded="false">Show Tools</button>
157
177
  </div>
158
178
  <div class="control-grid" id="sessionControls"></div>
159
179
  <div id="messages" class="messages"></div>
@@ -171,7 +191,7 @@ export function renderDashboardApp() {
171
191
  <button>Send</button>
172
192
  </form>
173
193
  </div>
174
- <div class="panel side-panel"><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
194
+ <div class="panel side-panel" id="toolPanel" hidden><h2>Tools / Plan</h2><div id="toolStream" class="tool-stream"></div></div>
175
195
  </div>
176
196
  </section>
177
197
 
@@ -179,6 +199,7 @@ export function renderDashboardApp() {
179
199
  <div class="panel">
180
200
  <div class="row"><button id="reloadTasksBtn">Reload tasks</button></div>
181
201
  <div id="tasksList" class="list"></div>
202
+ <div id="jobsPager" class="pager"></div>
182
203
  </div>
183
204
  </section>
184
205
 
@@ -211,6 +232,14 @@ export function renderDashboardApp() {
211
232
  <div class="panel">
212
233
  <div class="row"><select id="activitySource"><option value="all">All sources</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option><option value="cli">CLI</option></select><select id="activityCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="activityStatus"><option value="all">All statuses</option><option value="queued">Queued</option><option value="running">Running</option><option value="completed">Completed</option><option value="failed">Failed</option><option value="aborted">Aborted</option><option value="info">Info</option></select><input id="activityActor" placeholder="Actor"><input id="activityAgent" placeholder="Agent"><input id="activityThread" placeholder="Thread ID"><input id="activityWorkspace" placeholder="Workspace"><input id="activityType" placeholder="Type"><input id="activitySince" type="datetime-local"><input id="activityLimit" type="number" value="100" min="1" max="500"><button id="loadActivityBtn">Load activity</button><button id="exportActivityBtn" class="secondary">Export</button></div>
213
234
  <div id="activityList" class="list"></div>
235
+ <div id="activityPager" class="pager"></div>
236
+ </div>
237
+ </section>
238
+
239
+ <section class="page" id="page-trace">
240
+ <div class="panel">
241
+ <div class="row"><input id="traceCorrelationId" placeholder="Correlation ID"><button id="loadTraceBtn">Load trace</button></div>
242
+ <div id="traceDetail" class="list"></div>
214
243
  </div>
215
244
  </section>
216
245
 
@@ -219,6 +248,7 @@ export function renderDashboardApp() {
219
248
  <div class="row"><button id="reloadArtifactsBtn">Reload artifacts</button><input id="artifactSearch" placeholder="Search artifacts"><select id="artifactKind"><option value="all">All files</option><option value="images">Images</option><option value="docs">Docs/code</option></select><button id="zipSelectedArtifactsBtn" class="secondary">ZIP selected</button><button id="deleteSelectedArtifactsBtn" class="danger">Delete selected</button></div>
220
249
  <div id="artifactPreview" class="preview"></div>
221
250
  <div id="artifactList" class="list"></div>
251
+ <div id="artifactPager" class="pager"></div>
222
252
  </div>
223
253
  </section>
224
254
 
@@ -233,10 +263,13 @@ export function renderDashboardApp() {
233
263
 
234
264
  <section class="page" id="page-peers">
235
265
  <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>
266
+ <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>
267
+ <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
268
  <div id="peerStatus" class="list"></div>
238
269
  <h2>Configured peers</h2>
239
270
  <div id="peersList" class="list"></div>
271
+ <h2>LAN discovery</h2>
272
+ <div id="peerDiscovery" class="list"></div>
240
273
  <h2>Open invitations</h2>
241
274
  <div id="peerInvites" class="list"></div>
242
275
  </div>
@@ -244,49 +277,71 @@ export function renderDashboardApp() {
244
277
 
245
278
  <section class="page" id="page-access">
246
279
  <div class="panel">
247
- <div class="row"><button id="loadAccessBtn">Reload users</button><button id="createUserBtn">Create user</button><button id="createGroupBtn" class="secondary">Create group</button><button id="createChatBtn" class="secondary">Add Telegram chat</button><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button><button id="createSlackChannelBtn" class="secondary">Add Slack channel</button><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
248
- <div id="accessTabs" class="tabs access-tabs">
249
- <button type="button" data-access-tab="users" class="active">Users</button>
250
- <button type="button" data-access-tab="groups">Groups</button>
251
- <button type="button" data-access-tab="telegram">Telegram</button>
252
- <button type="button" data-access-tab="discord">Discord</button>
253
- <button type="button" data-access-tab="slack">Slack</button>
254
- <button type="button" data-access-tab="locks">Locks</button>
255
- <button type="button" data-access-tab="audit">Audit</button>
280
+ <div class="section-header access-section-header">
281
+ <div id="accessTabs" class="section-tabs access-tabs" role="tablist" aria-label="Users sections">
282
+ <button type="button" role="tab" aria-selected="true" tabindex="0" data-access-tab="users" class="active">Users</button>
283
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="groups">Groups</button>
284
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="telegram">Telegram</button>
285
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="discord">Discord</button>
286
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="slack">Slack</button>
287
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="locks">Locks</button>
288
+ <button type="button" role="tab" aria-selected="false" tabindex="-1" data-access-tab="audit">Audit</button>
289
+ </div>
256
290
  </div>
257
291
  <div class="access-tab active" data-access-tab-panel="users">
258
- <div id="accessPanel" class="settings-grid"></div>
292
+ <div class="access-tab-heading">
293
+ <div class="row access-heading-actions"><button id="loadAccessBtn" class="secondary">Reload</button><button id="createUserBtn">Create user</button></div>
294
+ <div class="access-filter-row">
295
+ <input id="userSearch" placeholder="Search users">
296
+ <select id="userStatusFilter"><option value="all">All statuses</option><option value="active">Active</option><option value="disabled">Disabled</option></select>
297
+ <select id="userGroupFilter"><option value="all">All groups</option></select>
298
+ <select id="userIdentityFilter"><option value="all">All identities</option><option value="telegram">Telegram linked</option><option value="discord">Discord linked</option><option value="slack">Slack linked</option><option value="web">Web sessions</option><option value="unlinked">No chat identity</option></select>
299
+ </div>
300
+ </div>
301
+ <div id="accessPanel" class="list user-list"></div>
302
+ <div id="usersPager" class="pager"></div>
259
303
  </div>
260
304
  <div class="access-tab" data-access-tab-panel="groups">
261
- <h2>Groups</h2>
305
+ <div class="access-tab-heading">
306
+ <div class="row access-heading-actions"><button id="createGroupBtn" class="secondary">Create group</button></div>
307
+ <div class="access-filter-row"><input id="groupSearch" placeholder="Search groups"></div>
308
+ </div>
262
309
  <div id="groupsList" class="list"></div>
263
310
  </div>
264
311
  <div class="access-tab" data-access-tab-panel="telegram">
265
- <h2>Telegram chats</h2>
312
+ <div class="access-tab-heading">
313
+ <div class="row access-heading-actions"><button id="createChatBtn" class="secondary">Add Telegram chat</button></div>
314
+ <input id="telegramChatSearch" placeholder="Search Telegram chats">
315
+ </div>
266
316
  <div id="telegramChatsList" class="list"></div>
267
317
  </div>
268
318
  <div class="access-tab" data-access-tab-panel="discord">
269
319
  <div class="access-tab-heading">
270
- <h2>Discord channels</h2>
320
+ <div class="row access-heading-actions"><button id="createDiscordChannelBtn" class="secondary">Add Discord channel</button></div>
271
321
  <input id="discordChannelSearch" placeholder="Search Discord channels">
272
322
  </div>
273
323
  <div id="discordChannelsList" class="list"></div>
274
324
  </div>
275
325
  <div class="access-tab" data-access-tab-panel="slack">
276
326
  <div class="access-tab-heading">
277
- <h2>Slack channels</h2>
327
+ <div class="row access-heading-actions"><button id="createSlackChannelBtn" class="secondary">Add Slack channel</button></div>
278
328
  <input id="slackChannelSearch" placeholder="Search Slack channels">
279
329
  </div>
280
330
  <div id="slackChannelsList" class="list"></div>
281
331
  </div>
282
332
  <div class="access-tab" data-access-tab-panel="locks">
283
- <h2>Locks</h2>
333
+ <div class="access-tab-heading">
334
+ <div class="row access-heading-actions"><button id="lockSessionBtn" class="secondary">Lock web session</button><button id="unlockSessionBtn" class="secondary">Unlock web session</button></div>
335
+ </div>
284
336
  <div id="locksList" class="list"></div>
285
337
  </div>
286
338
  <div class="access-tab" data-access-tab-panel="audit">
287
- <h2>Audit</h2>
288
- <div class="row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
339
+ <div class="access-tab-heading">
340
+ <div class="row access-heading-actions"><button id="loadAuditBtn">Load audit</button><button id="exportAuditBtn" class="secondary">Export</button></div>
341
+ </div>
342
+ <div class="row audit-filter-row"><select id="auditChannel"><option value="all">All channels</option><option value="web">Web</option><option value="telegram">Telegram</option><option value="discord">Discord</option><option value="slack">Slack</option></select><select id="auditCategory"><option value="all">All categories</option><option value="prompt">Prompt</option><option value="session">Session</option><option value="queue">Queue</option><option value="agent-update">Agent update</option><option value="artifact">Artifact</option><option value="system">System</option><option value="auth">Auth</option><option value="security">Security</option><option value="tool">Tool</option></select><select id="auditStatus"><option value="all">All statuses</option><option value="ok">OK</option><option value="failed">Failed</option><option value="denied">Denied</option></select><input id="auditActor" placeholder="Actor"><input id="auditAgent" placeholder="Agent"><input id="auditThread" placeholder="Thread ID"><input id="auditWorkspace" placeholder="Workspace"><input id="auditSince" type="datetime-local"><input id="auditLimit" type="number" value="50" min="1" max="500"></div>
289
343
  <div id="auditList" class="list"></div>
344
+ <div id="auditPager" class="pager"></div>
290
345
  </div>
291
346
  </div>
292
347
  </section>
@@ -302,8 +357,11 @@ export function renderDashboardApp() {
302
357
 
303
358
  <section class="page" id="page-settings">
304
359
  <div class="panel">
305
- <div class="row"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
306
- <div id="settingsTabs" class="tabs"></div>
360
+ <div id="settingsTabHeader" class="section-header settings-section-header">
361
+ <div id="settingsTabs" class="section-tabs settings-tabs" role="tablist" aria-label="Settings sections"></div>
362
+ </div>
363
+ <div id="settingsSubnav" class="settings-subnav" hidden></div>
364
+ <div id="settingsActions" class="row settings-actions"><button id="saveSettingsBtn">Save settings</button><button id="settingsWizardBtn" class="secondary">Setup wizard</button><button id="restartBtn" class="secondary">Restart NordRelay</button><span id="settingsStatus"></span></div>
307
365
  <div id="settingsForm" class="settings-grid"></div>
308
366
  </div>
309
367
  </section>
@@ -348,6 +406,10 @@ export function renderDashboardApp() {
348
406
  <div id="sessionDetail"></div>
349
407
  <div class="row dialog-actions"><button id="closeSessionDetailBtn" class="secondary">Close</button></div>
350
408
  </dialog>
409
+ <dialog id="userDetailDialog">
410
+ <div id="userDetail"></div>
411
+ <div class="row dialog-actions"><button id="closeUserDetailBtn" class="secondary">Close</button></div>
412
+ </dialog>
351
413
  <dialog id="adminDialog">
352
414
  <form method="dialog" id="adminDialogForm">
353
415
  <h2 id="adminDialogTitle">Edit</h2>
@@ -361,3 +423,6 @@ export function renderDashboardApp() {
361
423
  </body>
362
424
  </html>`;
363
425
  }
426
+ function nonceAttr(cspNonce) {
427
+ return cspNonce ? ` nonce="${cspNonce.replace(/"/g, "")}"` : "";
428
+ }