@nordbyte/nordrelay 0.5.2 → 0.7.0

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 (71) hide show
  1. package/.env.example +80 -11
  2. package/README.md +154 -22
  3. package/dist/access-control.js +7 -1
  4. package/dist/activity-events.js +44 -0
  5. package/dist/audit-log.js +40 -2
  6. package/dist/bot-preferences.js +1 -0
  7. package/dist/bot-rendering.js +10 -7
  8. package/dist/bot.js +535 -11
  9. package/dist/channel-actions.js +7 -2
  10. package/dist/channel-adapter.js +40 -7
  11. package/dist/channel-command-catalog.js +88 -0
  12. package/dist/channel-command-service.js +369 -0
  13. package/dist/channel-mirror-registry.js +77 -0
  14. package/dist/channel-peer-prompt.js +95 -0
  15. package/dist/channel-runtime.js +12 -5
  16. package/dist/channel-turn-service.js +237 -0
  17. package/dist/codex-state.js +114 -78
  18. package/dist/config-metadata.js +93 -13
  19. package/dist/config.js +103 -8
  20. package/dist/context-key.js +87 -5
  21. package/dist/discord-artifacts.js +165 -0
  22. package/dist/discord-bot.js +2073 -0
  23. package/dist/discord-channel-runtime.js +133 -0
  24. package/dist/discord-command-surface.js +57 -0
  25. package/dist/discord-rate-limit.js +141 -0
  26. package/dist/index.js +36 -5
  27. package/dist/job-store.js +127 -0
  28. package/dist/metrics.js +87 -0
  29. package/dist/peer-auth.js +85 -0
  30. package/dist/peer-client.js +256 -0
  31. package/dist/peer-context.js +21 -0
  32. package/dist/peer-identity.js +127 -0
  33. package/dist/peer-runtime-service.js +636 -0
  34. package/dist/peer-server.js +220 -0
  35. package/dist/peer-store.js +294 -0
  36. package/dist/peer-types.js +52 -0
  37. package/dist/relay-external-activity-monitor.js +47 -6
  38. package/dist/relay-runtime-helpers.js +208 -0
  39. package/dist/relay-runtime.js +897 -394
  40. package/dist/remote-prompt.js +98 -0
  41. package/dist/runtime-cache.js +57 -0
  42. package/dist/session-locks.js +10 -7
  43. package/dist/support-bundle.js +1 -0
  44. package/dist/telegram-access-commands.js +15 -2
  45. package/dist/telegram-access-middleware.js +16 -3
  46. package/dist/telegram-agent-commands.js +25 -0
  47. package/dist/telegram-artifact-commands.js +46 -0
  48. package/dist/telegram-command-menu.js +3 -53
  49. package/dist/telegram-diagnostics-command.js +5 -50
  50. package/dist/telegram-general-commands.js +16 -6
  51. package/dist/telegram-operational-commands.js +14 -6
  52. package/dist/telegram-preference-commands.js +23 -127
  53. package/dist/telegram-queue-commands.js +74 -4
  54. package/dist/telegram-support-command.js +7 -0
  55. package/dist/telegram-update-commands.js +27 -0
  56. package/dist/user-management.js +208 -0
  57. package/dist/web-api-contract.js +17 -0
  58. package/dist/web-dashboard-access-routes.js +74 -1
  59. package/dist/web-dashboard-artifact-routes.js +3 -3
  60. package/dist/web-dashboard-assets.js +2 -0
  61. package/dist/web-dashboard-pages.js +109 -13
  62. package/dist/web-dashboard-peer-routes.js +204 -0
  63. package/dist/web-dashboard-runtime-routes.js +53 -8
  64. package/dist/web-dashboard-session-routes.js +27 -20
  65. package/dist/web-dashboard-ui.js +2 -0
  66. package/dist/web-dashboard.js +160 -6
  67. package/dist/web-state.js +33 -2
  68. package/dist/webui-assets/dashboard.css +75 -1
  69. package/dist/webui-assets/dashboard.js +779 -55
  70. package/package.json +5 -2
  71. package/plugins/nordrelay/scripts/nordrelay.mjs +578 -19
@@ -10,12 +10,12 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
10
10
  if (req.method === "POST" && url.pathname === "/api/locks") {
11
11
  const body = await readJsonBody(req);
12
12
  await options.assertCurrentSessionScope(authUser);
13
- sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName")), locks: runtime.locks() });
13
+ sendJson(res, 200, { lock: runtime.lockWebSession(optionalStringField(body, "ownerName"), options.activityActor), locks: runtime.locks() });
14
14
  return true;
15
15
  }
16
16
  if (req.method === "DELETE" && url.pathname === "/api/locks") {
17
17
  await options.assertCurrentSessionScope(authUser);
18
- sendJson(res, 200, runtime.unlockWebSession());
18
+ sendJson(res, 200, runtime.unlockWebSession(options.activityActor));
19
19
  return true;
20
20
  }
21
21
  if (req.method === "GET" && url.pathname === "/api/auth/status") {
@@ -28,14 +28,14 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
28
28
  const body = await readJsonBody(req);
29
29
  const agentId = options.parseAgentId(optionalStringField(body, "agentId"));
30
30
  options.assertScopedAgent(authUser, agentId);
31
- sendJson(res, 200, await runtime.login(agentId));
31
+ sendJson(res, 200, await runtime.login(agentId, options.activityActor));
32
32
  return true;
33
33
  }
34
34
  if (req.method === "POST" && url.pathname === "/api/auth/logout") {
35
35
  const body = await readJsonBody(req);
36
36
  const agentId = options.parseAgentId(optionalStringField(body, "agentId"));
37
37
  options.assertScopedAgent(authUser, agentId);
38
- sendJson(res, 200, await runtime.logout(agentId));
38
+ sendJson(res, 200, await runtime.logout(agentId, options.activityActor));
39
39
  return true;
40
40
  }
41
41
  if (req.method === "GET" && url.pathname === "/api/snapshot") {
@@ -62,7 +62,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
62
62
  throw new Error(`Invalid agent: ${agentId}`);
63
63
  }
64
64
  options.assertScopedAgent(authUser, agentId);
65
- sendJson(res, 200, { session: await runtime.setAgent(agentId) });
65
+ sendJson(res, 200, { session: await runtime.setAgent(agentId, options.activityActor) });
66
66
  return true;
67
67
  }
68
68
  if (req.method === "POST" && url.pathname === "/api/sessions/new") {
@@ -79,7 +79,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
79
79
  reasoningEffort: optionalStringField(body, "reasoningEffort"),
80
80
  launchProfileId: optionalStringField(body, "launchProfileId"),
81
81
  fastMode: optionalBooleanField(body, "fastMode"),
82
- }),
82
+ }, options.activityActor),
83
83
  });
84
84
  return true;
85
85
  }
@@ -90,14 +90,14 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
90
90
  if (detail.record && typeof detail.record === "object") {
91
91
  options.assertSessionScope(authUser, detail.record);
92
92
  }
93
- const session = await runtime.switchSession(threadId);
93
+ const session = await runtime.switchSession(threadId, options.activityActor);
94
94
  options.assertSessionScope(authUser, session);
95
95
  sendJson(res, 200, { session });
96
96
  return true;
97
97
  }
98
98
  if (req.method === "POST" && url.pathname === "/api/sessions/attach") {
99
99
  const body = await readJsonBody(req);
100
- const session = await runtime.attachSession(stringField(body, "threadId"));
100
+ const session = await runtime.attachSession(stringField(body, "threadId"), options.activityActor);
101
101
  options.assertSessionScope(authUser, session);
102
102
  sendJson(res, 200, { session });
103
103
  return true;
@@ -117,31 +117,31 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
117
117
  if (req.method === "POST" && url.pathname === "/api/session/model") {
118
118
  const body = await readJsonBody(req);
119
119
  await options.assertCurrentSessionScope(authUser);
120
- sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model")) });
120
+ sendJson(res, 200, { session: await runtime.setModel(stringField(body, "model"), options.activityActor) });
121
121
  return true;
122
122
  }
123
123
  if (req.method === "POST" && url.pathname === "/api/session/reasoning") {
124
124
  const body = await readJsonBody(req);
125
125
  await options.assertCurrentSessionScope(authUser);
126
- sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning")) });
126
+ sendJson(res, 200, { session: await runtime.setReasoningEffort(stringField(body, "reasoning"), options.activityActor) });
127
127
  return true;
128
128
  }
129
129
  if (req.method === "POST" && url.pathname === "/api/session/fast") {
130
130
  const body = await readJsonBody(req);
131
131
  await options.assertCurrentSessionScope(authUser);
132
- sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled)) });
132
+ sendJson(res, 200, { session: await runtime.setFastMode(Boolean(body?.enabled), options.activityActor) });
133
133
  return true;
134
134
  }
135
135
  if (req.method === "POST" && url.pathname === "/api/session/launch") {
136
136
  const body = await readJsonBody(req);
137
137
  await options.assertCurrentSessionScope(authUser);
138
- sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId")) });
138
+ sendJson(res, 200, { session: await runtime.setLaunchProfile(stringField(body, "profileId"), options.activityActor) });
139
139
  return true;
140
140
  }
141
141
  if (req.method === "POST" && url.pathname === "/api/prompt") {
142
142
  const body = await readJsonBody(req);
143
143
  await options.assertCurrentSessionScope(authUser);
144
- sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text")));
144
+ sendJson(res, 202, await runtime.sendPrompt(stringField(body, "text"), options.activityActor));
145
145
  return true;
146
146
  }
147
147
  if (req.method === "POST" && url.pathname === "/api/prompt/upload") {
@@ -150,28 +150,28 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
150
150
  sendJson(res, 202, await runtime.sendUploadPrompt({
151
151
  text: optionalStringField(body, "text"),
152
152
  files: parseUploadFiles(body.files),
153
- }));
153
+ }, options.activityActor));
154
154
  return true;
155
155
  }
156
156
  if (req.method === "POST" && (url.pathname === "/api/abort" || url.pathname === "/api/stop")) {
157
157
  await options.assertCurrentSessionScope(authUser);
158
- await runtime.abort();
158
+ await runtime.abort(options.activityActor);
159
159
  sendJson(res, 200, { ok: true });
160
160
  return true;
161
161
  }
162
162
  if (req.method === "POST" && url.pathname === "/api/handback") {
163
163
  await options.assertCurrentSessionScope(authUser);
164
- sendJson(res, 200, await runtime.handback());
164
+ sendJson(res, 200, await runtime.handback(options.activityActor));
165
165
  return true;
166
166
  }
167
167
  if (req.method === "POST" && url.pathname === "/api/retry") {
168
168
  await options.assertCurrentSessionScope(authUser);
169
- sendJson(res, 202, await runtime.retry());
169
+ sendJson(res, 202, await runtime.retry(options.activityActor));
170
170
  return true;
171
171
  }
172
172
  if (req.method === "POST" && url.pathname === "/api/sync") {
173
173
  await options.assertCurrentSessionScope(authUser);
174
- sendJson(res, 200, await runtime.sync());
174
+ sendJson(res, 200, await runtime.sync(options.activityActor));
175
175
  return true;
176
176
  }
177
177
  if (req.method === "GET" && url.pathname === "/api/queue") {
@@ -182,7 +182,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
182
182
  if (req.method === "POST" && url.pathname === "/api/queue") {
183
183
  const body = await readJsonBody(req);
184
184
  await options.assertCurrentSessionScope(authUser);
185
- sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id")), paused: runtime.queuePaused() });
185
+ sendJson(res, 200, { queue: runtime.queueAction(stringField(body, "action"), optionalStringField(body, "id"), options.activityActor), paused: runtime.queuePaused() });
186
186
  return true;
187
187
  }
188
188
  if (req.method === "GET" && url.pathname === "/api/chat/history") {
@@ -192,7 +192,7 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
192
192
  }
193
193
  if (req.method === "DELETE" && url.pathname === "/api/chat/history") {
194
194
  await options.assertCurrentSessionScope(authUser);
195
- sendJson(res, 200, await runtime.clearChatHistory());
195
+ sendJson(res, 200, await runtime.clearChatHistory(options.activityActor));
196
196
  return true;
197
197
  }
198
198
  if (req.method === "GET" && url.pathname === "/api/activity") {
@@ -201,6 +201,13 @@ export async function handleDashboardSessionRoute(req, res, url, options) {
201
201
  limit: numberParam(url, "limit", 100),
202
202
  source: (url.searchParams.get("source") || "all"),
203
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,
204
211
  })),
205
212
  });
206
213
  return true;
@@ -4,9 +4,11 @@ export const DASHBOARD_PAGES = [
4
4
  { id: "sessions", label: "Sessions", permission: "sessions.read" },
5
5
  { id: "queue", label: "Queue", permission: "queue.read" },
6
6
  { id: "tasks", label: "Tasks", permission: "inspect" },
7
+ { id: "metrics", label: "Metrics", permission: "inspect" },
7
8
  { id: "activity", label: "Activity", permission: "sessions.read" },
8
9
  { id: "artifacts", label: "Artifacts", permission: "files.read" },
9
10
  { id: "adapters", label: "Adapters", permission: "inspect" },
11
+ { id: "peers", label: "Peers", permission: "peers.read" },
10
12
  { id: "access", label: "Users", permission: "users.read" },
11
13
  { id: "version", label: "Version", permission: "inspect" },
12
14
  { id: "settings", label: "Settings", permission: "settings.read" },
@@ -1,4 +1,5 @@
1
1
  import { createServer } from "node:http";
2
+ import { randomBytes } from "node:crypto";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { URL } from "node:url";
@@ -17,9 +18,10 @@ import { handleDashboardAccessRoute } from "./web-dashboard-access-routes.js";
17
18
  import { handleDashboardArtifactRoute } from "./web-dashboard-artifact-routes.js";
18
19
  import { dashboardCss, dashboardJs } from "./web-dashboard-assets.js";
19
20
  import { objectRecord, optionalStringField, parseCookies, readJsonBody, sendJson, sendText, } from "./web-dashboard-http.js";
20
- import { renderDashboardApp, renderLoginPage } from "./web-dashboard-pages.js";
21
+ import { renderDashboardApp, renderFirstRunSetupPage, renderLoginPage } from "./web-dashboard-pages.js";
21
22
  import { handleDashboardRuntimeRoute } from "./web-dashboard-runtime-routes.js";
22
23
  import { handleDashboardSessionRoute } from "./web-dashboard-session-routes.js";
24
+ import { handleDashboardPeerRoute } from "./web-dashboard-peer-routes.js";
23
25
  const DEFAULT_HOME = path.join(os.homedir(), ".nordrelay");
24
26
  const options = parseOptions(process.argv.slice(2));
25
27
  const config = loadConfig();
@@ -28,6 +30,11 @@ const settings = new SettingsService(resolveDashboardEnvPath(options.home));
28
30
  const users = new UserStore(options.home);
29
31
  const auditLog = new AuditLogStore(config.workspace, config.stateBackend, config.auditMaxEvents);
30
32
  const loginAttempts = new Map();
33
+ const firstRunSetupToken = users.hasAdminUser() ? undefined : randomBytes(18).toString("base64url");
34
+ const firstRunSetupRequiresToken = !isLoopbackHost(options.host);
35
+ if (firstRunSetupToken) {
36
+ console.log(`NordRelay first-run setup token: ${firstRunSetupToken}`);
37
+ }
31
38
  class AccessDeniedError extends Error {
32
39
  }
33
40
  const server = createServer((req, res) => {
@@ -45,6 +52,10 @@ async function handleRequest(req, res) {
45
52
  await handleLogin(req, res);
46
53
  return;
47
54
  }
55
+ if (url.pathname === "/api/setup/admin" && req.method === "POST") {
56
+ await handleFirstRunSetup(req, res);
57
+ return;
58
+ }
48
59
  if (url.pathname === "/api/dashboard/logout" && req.method === "POST") {
49
60
  handleLogout(req, res);
50
61
  return;
@@ -60,6 +71,10 @@ async function handleRequest(req, res) {
60
71
  }
61
72
  if (!authenticated) {
62
73
  if (url.pathname === "/" || url.pathname === "/index.html") {
74
+ if (!users.hasAdminUser()) {
75
+ sendText(res, 200, renderFirstRunSetupPage({ tokenRequired: firstRunSetupRequiresToken || !isLoopbackRequest(req) }), "text/html; charset=utf-8");
76
+ return;
77
+ }
63
78
  sendText(res, 200, renderLoginPage({ adminConfigured: users.hasAdminUser() }), "text/html; charset=utf-8");
64
79
  return;
65
80
  }
@@ -104,6 +119,7 @@ async function handleApi(req, res, url, authUser) {
104
119
  status: "denied",
105
120
  channelId: "web",
106
121
  contextKey: "web",
122
+ actor: webActivityActor(authUser),
107
123
  actorId: authUser.user.id,
108
124
  actorRole: authUser.groups.map((group) => group.name).join(", "),
109
125
  description: `Denied unknown endpoint ${req.method ?? "GET"} ${url.pathname}`,
@@ -117,6 +133,7 @@ async function handleApi(req, res, url, authUser) {
117
133
  status: "denied",
118
134
  channelId: "web",
119
135
  contextKey: "web",
136
+ actor: webActivityActor(authUser),
120
137
  actorId: authUser.user.id,
121
138
  actorRole: authUser.groups.map((group) => group.name).join(", "),
122
139
  description: `${permission} required for ${req.method ?? "GET"} ${url.pathname}`,
@@ -133,6 +150,8 @@ async function handleApi(req, res, url, authUser) {
133
150
  assertAgentUpdateJobScope,
134
151
  assertCurrentSessionScope,
135
152
  scopedTasks,
153
+ scopedActiveSessions,
154
+ activityActor: webActivityActor(authUser),
136
155
  })) {
137
156
  return;
138
157
  }
@@ -162,6 +181,15 @@ async function handleApi(req, res, url, authUser) {
162
181
  })) {
163
182
  return;
164
183
  }
184
+ if (await handleDashboardPeerRoute(req, res, url, {
185
+ config,
186
+ home: options.home,
187
+ runtime,
188
+ activityActor: webActivityActor(authUser),
189
+ auditPeerAction: (action, description) => auditUserAction(authUser, action, description),
190
+ })) {
191
+ return;
192
+ }
165
193
  if (req.method === "GET" && url.pathname === "/api/settings") {
166
194
  sendJson(res, 200, await settings.snapshot(process.env, activeSettingsValues(config)));
167
195
  return;
@@ -182,6 +210,7 @@ async function handleApi(req, res, url, authUser) {
182
210
  assertSessionDetailScope,
183
211
  scopedSessionPage,
184
212
  filterActivityByScope,
213
+ activityActor: webActivityActor(authUser),
185
214
  })) {
186
215
  return;
187
216
  }
@@ -189,6 +218,7 @@ async function handleApi(req, res, url, authUser) {
189
218
  runtime,
190
219
  authUser,
191
220
  assertCurrentSessionScope,
221
+ activityActor: webActivityActor(authUser),
192
222
  })) {
193
223
  return;
194
224
  }
@@ -239,6 +269,58 @@ async function handleEvents(req, res) {
239
269
  unsubscribe();
240
270
  });
241
271
  }
272
+ async function handleFirstRunSetup(req, res) {
273
+ if (users.hasAdminUser()) {
274
+ sendJson(res, 409, { error: "Admin user already exists." });
275
+ return;
276
+ }
277
+ const body = await readJsonBody(req);
278
+ const email = optionalStringField(body, "email") ?? "";
279
+ const displayName = optionalStringField(body, "displayName") ?? email;
280
+ const password = optionalStringField(body, "password") ?? "";
281
+ const setupToken = optionalStringField(body, "setupToken") ?? "";
282
+ if ((firstRunSetupRequiresToken || !isLoopbackRequest(req)) && setupToken !== firstRunSetupToken) {
283
+ audit({
284
+ action: "auth_login_failed",
285
+ status: "denied",
286
+ channelId: "web",
287
+ contextKey: "web",
288
+ description: `Rejected remote first-run setup for ${email || "unknown"}`,
289
+ });
290
+ sendJson(res, 403, { error: "Setup token required." });
291
+ return;
292
+ }
293
+ if (setupToken && setupToken !== firstRunSetupToken) {
294
+ sendJson(res, 403, { error: "Invalid setup token." });
295
+ return;
296
+ }
297
+ if (!email || !password || password.length < 12) {
298
+ sendJson(res, 400, { error: "Email and a password with at least 12 characters are required." });
299
+ return;
300
+ }
301
+ const authUser = users.createAdmin({ email, displayName, password });
302
+ const session = users.createWebSession(authUser.user.id);
303
+ audit({
304
+ action: "user_created",
305
+ status: "ok",
306
+ channelId: "web",
307
+ contextKey: "web",
308
+ actor: webActivityActor(authUser),
309
+ actorId: authUser.user.id,
310
+ actorRole: authUser.groups.map((group) => group.name).join(", "),
311
+ description: `First admin created: ${authUser.user.email}`,
312
+ });
313
+ runtime.recordActivity({
314
+ source: "web",
315
+ status: "info",
316
+ type: "first_run_admin_created",
317
+ threadId: null,
318
+ actor: webActivityActor(authUser),
319
+ detail: authUser.user.email,
320
+ });
321
+ setSessionCookie(res, session.token);
322
+ sendJson(res, 201, currentUserDto(authUser));
323
+ }
242
324
  async function handleLogin(req, res) {
243
325
  const body = await readJsonBody(req);
244
326
  const email = optionalStringField(body, "email");
@@ -280,13 +362,32 @@ async function handleLogin(req, res) {
280
362
  status: "ok",
281
363
  channelId: "web",
282
364
  contextKey: "web",
365
+ actor: webActivityActor(authUser),
283
366
  actorId: authUser.user.id,
284
367
  actorRole: authUser.groups.map((group) => group.name).join(", "),
285
368
  description: `Login ${authUser.user.email}`,
286
369
  });
370
+ runtime.recordActivity({
371
+ source: "web",
372
+ status: "info",
373
+ type: "auth_login",
374
+ threadId: null,
375
+ actor: webActivityActor(authUser),
376
+ detail: authUser.user.email,
377
+ });
287
378
  setSessionCookie(res, session.token);
288
379
  sendJson(res, 200, currentUserDto(authUser));
289
380
  }
381
+ function isLoopbackRequest(req) {
382
+ const address = req.socket.remoteAddress ?? "";
383
+ return address === "127.0.0.1" ||
384
+ address === "::1" ||
385
+ address === "::ffff:127.0.0.1" ||
386
+ address === "localhost";
387
+ }
388
+ function isLoopbackHost(host) {
389
+ return host === "127.0.0.1" || host === "::1" || host === "localhost";
390
+ }
290
391
  function handleLogout(req, res) {
291
392
  const authUser = authenticateRequest(req);
292
393
  users.destroyWebSession(parseCookies(req.headers.cookie ?? "").nr_session);
@@ -345,10 +446,27 @@ function auditUserAction(authUser, action, description) {
345
446
  status: "ok",
346
447
  channelId: "web",
347
448
  contextKey: "web",
449
+ actor: webActivityActor(authUser),
348
450
  actorId: authUser.user.id,
349
451
  actorRole: authUser.groups.map((group) => group.name).join(", "),
350
452
  description,
351
453
  });
454
+ runtime.recordActivity({
455
+ source: "web",
456
+ status: "info",
457
+ type: action,
458
+ threadId: null,
459
+ actor: webActivityActor(authUser),
460
+ detail: description,
461
+ });
462
+ }
463
+ function webActivityActor(authUser) {
464
+ return {
465
+ channel: "web",
466
+ id: authUser.user.id,
467
+ label: authUser.user.displayName || authUser.user.email,
468
+ username: authUser.user.email,
469
+ };
352
470
  }
353
471
  function scopedControlOptions(authUser, options) {
354
472
  return {
@@ -372,6 +490,12 @@ async function scopedTasks(authUser, tasks) {
372
490
  recent: filterActivityByScope(authUser, tasks.recent),
373
491
  };
374
492
  }
493
+ function scopedActiveSessions(authUser, active) {
494
+ return {
495
+ ...active,
496
+ sessions: active.sessions.filter((session) => canUseSession(authUser, session)),
497
+ };
498
+ }
375
499
  async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => canUseCurrentSessionScope(authUser)) {
376
500
  switch (event.type) {
377
501
  case "snapshot":
@@ -380,6 +504,8 @@ async function scopeRelayEvent(authUser, event, canUseCurrentSession = () => can
380
504
  return canUseSession(authUser, event.session) ? event : null;
381
505
  case "activity_update":
382
506
  return { ...event, events: filterActivityByScope(authUser, event.events) };
507
+ case "active_sessions_update":
508
+ return { ...event, active: scopedActiveSessions(authUser, event.active) };
383
509
  case "agent_update":
384
510
  return users.canUseAgent(authUser, event.job.agentId) ? event : null;
385
511
  case "status":
@@ -504,6 +630,7 @@ function optionalEnv(key) {
504
630
  }
505
631
  function activeSettingsValues(current) {
506
632
  return {
633
+ TELEGRAM_ENABLED: boolValue(current.telegramEnabled),
507
634
  TELEGRAM_BOT_TOKEN: current.telegramBotToken,
508
635
  TELEGRAM_TRANSPORT: current.telegramTransport,
509
636
  TELEGRAM_WEBHOOK_URL: current.telegramWebhookUrl,
@@ -511,6 +638,20 @@ function activeSettingsValues(current) {
511
638
  TELEGRAM_WEBHOOK_PORT: String(current.telegramWebhookPort),
512
639
  TELEGRAM_WEBHOOK_PATH: current.telegramWebhookPath,
513
640
  TELEGRAM_WEBHOOK_SECRET: current.telegramWebhookSecret,
641
+ DISCORD_ENABLED: boolValue(current.discordEnabled),
642
+ DISCORD_BOT_TOKEN: current.discordBotToken,
643
+ DISCORD_CLIENT_ID: current.discordClientId,
644
+ DISCORD_GUILD_IDS: current.discordGuildIds.join(","),
645
+ DISCORD_ALLOWED_GUILD_IDS: current.discordAllowedGuildIds.join(","),
646
+ DISCORD_ALLOWED_CHANNEL_IDS: current.discordAllowedChannelIds.join(","),
647
+ DISCORD_MESSAGE_CONTENT_ENABLED: boolValue(current.discordMessageContentEnabled),
648
+ DISCORD_COMMAND_MODE: current.discordCommandMode,
649
+ DISCORD_AUTO_REGISTER_COMMANDS: boolValue(current.discordAutoRegisterCommands),
650
+ DISCORD_CLI_MIRROR_MODE: current.discordMirrorMode === current.mirrorMode ? "" : current.discordMirrorMode,
651
+ DISCORD_CLI_MIRROR_MIN_UPDATE_MS: current.discordMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.discordMirrorMinUpdateMs),
652
+ DISCORD_NOTIFY_MODE: current.discordNotifyMode === current.notifyMode ? "" : current.discordNotifyMode,
653
+ DISCORD_QUIET_HOURS: quietOverrideValue(current.discordQuietHours, current.quietHours),
654
+ DISCORD_AUTO_SEND_ARTIFACTS: current.discordAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.discordAutoSendArtifacts),
514
655
  NORDRELAY_CODEX_ENABLED: boolValue(current.codexEnabled),
515
656
  NORDRELAY_PI_ENABLED: boolValue(current.piEnabled),
516
657
  NORDRELAY_HERMES_ENABLED: boolValue(current.hermesEnabled),
@@ -565,10 +706,15 @@ function activeSettingsValues(current) {
565
706
  ENABLE_TELEGRAM_REACTIONS: boolValue(current.enableTelegramReactions),
566
707
  TELEGRAM_RATE_LIMIT_MIN_INTERVAL_MS: String(current.telegramRateLimitMinIntervalMs),
567
708
  TELEGRAM_EDIT_MIN_INTERVAL_MS: String(current.telegramEditMinIntervalMs),
568
- TELEGRAM_CLI_MIRROR_MODE: current.telegramMirrorMode,
569
- TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS: String(current.telegramMirrorMinUpdateMs),
570
- TELEGRAM_NOTIFY_MODE: current.telegramNotifyMode,
571
- TELEGRAM_QUIET_HOURS: current.telegramQuietHours ? `${current.telegramQuietHours.startHour}-${current.telegramQuietHours.endHour}` : "",
709
+ NORDRELAY_CLI_MIRROR_MODE: current.mirrorMode,
710
+ NORDRELAY_CLI_MIRROR_MIN_UPDATE_MS: String(current.mirrorMinUpdateMs),
711
+ NORDRELAY_NOTIFY_MODE: current.notifyMode,
712
+ NORDRELAY_QUIET_HOURS: quietValue(current.quietHours),
713
+ NORDRELAY_AUTO_SEND_ARTIFACTS: boolValue(current.autoSendArtifacts),
714
+ TELEGRAM_CLI_MIRROR_MODE: current.telegramMirrorMode === current.mirrorMode ? "" : current.telegramMirrorMode,
715
+ TELEGRAM_CLI_MIRROR_MIN_UPDATE_MS: current.telegramMirrorMinUpdateMs === current.mirrorMinUpdateMs ? "" : String(current.telegramMirrorMinUpdateMs),
716
+ TELEGRAM_NOTIFY_MODE: current.telegramNotifyMode === current.notifyMode ? "" : current.telegramNotifyMode,
717
+ TELEGRAM_QUIET_HOURS: quietOverrideValue(current.telegramQuietHours, current.quietHours),
572
718
  TELEGRAM_REDACT_PATTERNS: current.telegramRedactPatterns.join(","),
573
719
  NORDRELAY_UPDATE_METHOD: process.env.NORDRELAY_UPDATE_METHOD || "auto",
574
720
  MAX_FILE_SIZE: String(current.maxFileSize),
@@ -577,12 +723,14 @@ function activeSettingsValues(current) {
577
723
  ARTIFACT_MAX_INBOX_DIRS: String(current.artifactMaxInboxDirs),
578
724
  ARTIFACT_IGNORE_DIRS: current.artifactIgnoreDirs.join(","),
579
725
  ARTIFACT_IGNORE_GLOBS: current.artifactIgnoreGlobs.join(","),
580
- TELEGRAM_AUTO_SEND_ARTIFACTS: boolValue(current.telegramAutoSendArtifacts),
726
+ TELEGRAM_AUTO_SEND_ARTIFACTS: current.telegramAutoSendArtifacts === current.autoSendArtifacts ? "" : boolValue(current.telegramAutoSendArtifacts),
581
727
  WORKSPACE_ALLOWED_ROOTS: current.workspaceAllowedRoots.join(","),
582
728
  WORKSPACE_WARN_ROOTS: current.workspaceWarnRoots.join(","),
583
729
  NORDRELAY_STATE_BACKEND: current.stateBackend,
584
730
  NORDRELAY_AUDIT_MAX_EVENTS: String(current.auditMaxEvents),
585
731
  NORDRELAY_SESSION_LOCK_TTL_MS: String(current.sessionLockTtlMs),
732
+ NORDRELAY_DASHBOARD_CACHE_TTL_MS: String(current.dashboardCacheTtlMs),
733
+ NORDRELAY_UNIFIED_JOB_MAX_ITEMS: String(current.unifiedJobMaxItems),
586
734
  NORDRELAY_VERSION_CACHE_TTL_MS: process.env.NORDRELAY_VERSION_CACHE_TTL_MS,
587
735
  NORDRELAY_CLI_VERSION_CACHE_TTL_MS: process.env.NORDRELAY_CLI_VERSION_CACHE_TTL_MS,
588
736
  VOICE_PREFERRED_BACKEND: current.voicePreferredBackend,
@@ -601,6 +749,12 @@ function activeSettingsValues(current) {
601
749
  function boolValue(value) {
602
750
  return value ? "true" : "false";
603
751
  }
752
+ function quietValue(value) {
753
+ return value ? `${value.startHour}-${value.endHour}` : "";
754
+ }
755
+ function quietOverrideValue(channelValue, defaultValue) {
756
+ return quietValue(channelValue) === quietValue(defaultValue) ? "" : quietValue(channelValue);
757
+ }
604
758
  function requireArg(argv, index, flag) {
605
759
  const value = argv[index];
606
760
  if (!value || value.startsWith("--")) {
package/dist/web-state.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { activityActorLabel, activityCategoryForType, } from "./activity-events.js";
2
3
  import { createDocumentStore } from "./state-backend.js";
3
4
  const DEFAULT_CHAT_LIMIT = 300;
4
5
  const DEFAULT_ACTIVITY_LIMIT = 1000;
@@ -76,6 +77,7 @@ export class WebActivityStore {
76
77
  id: randomId(),
77
78
  timestamp: input.timestamp ?? new Date().toISOString(),
78
79
  ...input,
80
+ category: input.category ?? activityCategoryForType(input.type),
79
81
  };
80
82
  payload.events.push(event);
81
83
  if (payload.events.length > this.maxEvents) {
@@ -86,9 +88,17 @@ export class WebActivityStore {
86
88
  }
87
89
  list(options = {}) {
88
90
  const limit = Math.max(1, Math.min(500, options.limit ?? 100));
91
+ const since = normalizeSince(options.since);
89
92
  return this.readPayload().events
90
93
  .filter((event) => !options.source || options.source === "all" || event.source === options.source)
91
94
  .filter((event) => !options.status || options.status === "all" || event.status === options.status)
95
+ .filter((event) => !options.category || options.category === "all" || (event.category ?? activityCategoryForType(event.type)) === options.category)
96
+ .filter((event) => !options.agentId || options.agentId === "all" || event.agentId === options.agentId)
97
+ .filter((event) => !options.threadId || event.threadId === options.threadId)
98
+ .filter((event) => !options.workspace || event.workspace === options.workspace)
99
+ .filter((event) => !options.type || event.type.toLowerCase().includes(options.type.toLowerCase()))
100
+ .filter((event) => !options.actor || activityActorMatches(event.actor, options.actor))
101
+ .filter((event) => !since || Date.parse(event.timestamp) >= since)
92
102
  .slice(-limit)
93
103
  .reverse();
94
104
  }
@@ -113,7 +123,7 @@ function isWebChatMessage(value) {
113
123
  typeof candidate.text === "string" &&
114
124
  typeof candidate.timestamp === "string" &&
115
125
  ["user", "agent", "system", "tool"].includes(candidate.role) &&
116
- ["web", "cli"].includes(candidate.source);
126
+ ["web", "telegram", "discord", "cli"].includes(candidate.source);
117
127
  }
118
128
  function isWebActivityEvent(value) {
119
129
  if (!value || typeof value !== "object" || Array.isArray(value)) {
@@ -123,9 +133,30 @@ function isWebActivityEvent(value) {
123
133
  return typeof candidate.id === "string" &&
124
134
  typeof candidate.timestamp === "string" &&
125
135
  typeof candidate.type === "string" &&
126
- ["web", "cli"].includes(candidate.source) &&
136
+ ["web", "telegram", "discord", "cli"].includes(candidate.source) &&
127
137
  ["queued", "running", "completed", "failed", "aborted", "info"].includes(candidate.status);
128
138
  }
139
+ function normalizeSince(value) {
140
+ if (value === undefined || value === null || value === "") {
141
+ return null;
142
+ }
143
+ const time = typeof value === "number" ? value : Date.parse(value);
144
+ return Number.isFinite(time) ? time : null;
145
+ }
146
+ function activityActorMatches(actor, query) {
147
+ const needle = query.trim().toLowerCase();
148
+ if (!needle) {
149
+ return true;
150
+ }
151
+ return [
152
+ activityActorLabel(actor),
153
+ actor?.id,
154
+ actor?.username,
155
+ actor?.channelUserId,
156
+ actor?.channel,
157
+ ].some((value) => String(value ?? "").toLowerCase().includes(needle));
158
+ }
129
159
  function randomId() {
130
160
  return randomUUID().replace(/-/g, "").slice(0, 12);
131
161
  }
162
+ export { activityActorLabel, activityCategoryForType, auditCategoryForAction, } from "./activity-events.js";