@nordbyte/nordrelay 0.5.0 → 0.5.1

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 (38) hide show
  1. package/README.md +16 -10
  2. package/dist/access-control.js +2 -0
  3. package/dist/agent-updates.js +43 -8
  4. package/dist/bot-ui.js +1 -0
  5. package/dist/bot.js +108 -1063
  6. package/dist/channel-actions.js +8 -8
  7. package/dist/operations.js +63 -9
  8. package/dist/relay-artifact-service.js +126 -0
  9. package/dist/relay-external-activity-monitor.js +216 -0
  10. package/dist/relay-queue-service.js +66 -0
  11. package/dist/relay-runtime-types.js +1 -0
  12. package/dist/relay-runtime.js +77 -359
  13. package/dist/support-bundle.js +205 -0
  14. package/dist/telegram-agent-commands.js +212 -0
  15. package/dist/telegram-artifact-commands.js +139 -0
  16. package/dist/telegram-command-menu.js +1 -0
  17. package/dist/telegram-command-types.js +1 -0
  18. package/dist/telegram-diagnostics-command.js +102 -0
  19. package/dist/telegram-general-commands.js +52 -0
  20. package/dist/telegram-operational-commands.js +153 -0
  21. package/dist/telegram-preference-commands.js +198 -0
  22. package/dist/telegram-queue-commands.js +278 -0
  23. package/dist/telegram-support-command.js +53 -0
  24. package/dist/telegram-update-commands.js +6 -1
  25. package/dist/web-api-contract.js +79 -31
  26. package/dist/web-api-types.js +1 -0
  27. package/dist/web-dashboard-access-routes.js +163 -0
  28. package/dist/web-dashboard-artifact-routes.js +65 -0
  29. package/dist/web-dashboard-assets.js +2 -0
  30. package/dist/web-dashboard-http.js +143 -0
  31. package/dist/web-dashboard-pages.js +257 -0
  32. package/dist/web-dashboard-runtime-routes.js +92 -0
  33. package/dist/web-dashboard-session-routes.js +209 -0
  34. package/dist/web-dashboard.js +43 -882
  35. package/dist/webui-assets/dashboard.css +74 -4
  36. package/dist/webui-assets/dashboard.js +163 -24
  37. package/dist/zip-writer.js +83 -0
  38. package/package.json +10 -4
@@ -0,0 +1,278 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { renderQueueListAction, renderQueuedPromptDetailAction, } from "./channel-actions.js";
3
+ import { contextKeyFromCtx } from "./context-key.js";
4
+ import { friendlyErrorText } from "./error-messages.js";
5
+ import { escapeHTML } from "./format.js";
6
+ import { PromptStore, toPromptEnvelope } from "./prompt-store.js";
7
+ import { formatLocalDateTime } from "./bot-rendering.js";
8
+ import { safeEditMessage, safeReply, } from "./telegram-output.js";
9
+ export function queueCancelCallbackData(action, contextKey, queueId) {
10
+ return `queue_${action}:${contextKey}:${queueId}`;
11
+ }
12
+ export function createQueuedPromptCancelKeyboard(contextKey, queueId, label = "Cancel queued message") {
13
+ return new InlineKeyboard().text(label, queueCancelCallbackData("cancel", contextKey, queueId));
14
+ }
15
+ export function renderQueueList(promptStore, contextKey, queue) {
16
+ const paused = promptStore.isPaused(contextKey);
17
+ const rendered = renderQueueListAction(queue, paused);
18
+ if (queue.length === 0) {
19
+ return rendered;
20
+ }
21
+ const keyboard = new InlineKeyboard();
22
+ queue.forEach((item, index) => {
23
+ keyboard
24
+ .text(`Run ${index + 1}`, queueCancelCallbackData("run", contextKey, item.id))
25
+ .text("Top", queueCancelCallbackData("top", contextKey, item.id))
26
+ .text("Cancel", queueCancelCallbackData("remove", contextKey, item.id))
27
+ .row();
28
+ keyboard
29
+ .text("Up", queueCancelCallbackData("up", contextKey, item.id))
30
+ .text("Down", queueCancelCallbackData("down", contextKey, item.id))
31
+ .row();
32
+ });
33
+ return { ...rendered, keyboard };
34
+ }
35
+ export function registerTelegramQueueCommands(options) {
36
+ const { bot, promptStore } = options;
37
+ bot.command("queue", async (ctx) => {
38
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
39
+ if (!contextSession) {
40
+ return;
41
+ }
42
+ const chatId = ctx.chat?.id;
43
+ const { contextKey, session } = contextSession;
44
+ const rawText = ctx.message?.text ?? "";
45
+ const argument = rawText.replace(/^\/queue(?:@\w+)?\s*/i, "").trim();
46
+ const laterMatch = argument.match(/^later\s+(\d+)(?:m|min|minutes?)?\s+([\s\S]+)$/i);
47
+ if (laterMatch) {
48
+ const minutes = Math.min(7 * 24 * 60, Math.max(1, Number(laterMatch[1])));
49
+ const text = laterMatch[2].trim();
50
+ const notBefore = Date.now() + minutes * 60 * 1000;
51
+ const item = promptStore.enqueue(contextKey, toPromptEnvelope(text), { notBefore });
52
+ const message = `Queued prompt ${item.id} for ${formatLocalDateTime(new Date(notBefore))}.`;
53
+ await safeReply(ctx, escapeHTML(message), {
54
+ fallbackText: message,
55
+ replyMarkup: createQueuedPromptCancelKeyboard(contextKey, item.id),
56
+ });
57
+ options.auditContext(ctx, contextKey, session, {
58
+ action: "prompt_queued",
59
+ status: "ok",
60
+ promptId: item.id,
61
+ description: item.description,
62
+ detail: "scheduled",
63
+ });
64
+ return;
65
+ }
66
+ const inspectMatch = argument.match(/^inspect\s+([a-z0-9]+)$/i);
67
+ if (inspectMatch) {
68
+ const item = promptStore.get(contextKey, inspectMatch[1]);
69
+ if (!item) {
70
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${inspectMatch[1]}.`), {
71
+ fallbackText: `No queued prompt found with id ${inspectMatch[1]}.`,
72
+ });
73
+ return;
74
+ }
75
+ const rendered = renderQueuedPromptDetailAction(item);
76
+ await safeReply(ctx, rendered.html, { fallbackText: rendered.plain });
77
+ return;
78
+ }
79
+ if (/^pause$/i.test(argument)) {
80
+ promptStore.pause(contextKey);
81
+ const message = `Queue paused. ${promptStore.list(contextKey).length} queued.`;
82
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
83
+ await options.updateQueueStatusMessage(contextKey, message);
84
+ return;
85
+ }
86
+ if (/^resume$/i.test(argument)) {
87
+ promptStore.resume(contextKey);
88
+ const message = `Queue resumed. ${promptStore.list(contextKey).length} queued.`;
89
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
90
+ if (chatId) {
91
+ void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
92
+ console.error("Failed to drain queue after resume:", error);
93
+ });
94
+ }
95
+ return;
96
+ }
97
+ const moveMatch = argument.match(/^move\s+([a-z0-9]+)\s+(top|up|down)$/i);
98
+ if (moveMatch) {
99
+ const direction = moveMatch[2].toLowerCase();
100
+ const item = direction === "top"
101
+ ? promptStore.moveToTop(contextKey, moveMatch[1])
102
+ : direction === "up"
103
+ ? promptStore.moveUp(contextKey, moveMatch[1])
104
+ : promptStore.moveDown(contextKey, moveMatch[1]);
105
+ if (!item) {
106
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${moveMatch[1]}.`), {
107
+ fallbackText: `No queued prompt found with id ${moveMatch[1]}.`,
108
+ });
109
+ return;
110
+ }
111
+ const message = `Moved queued prompt ${item.id} ${direction}.`;
112
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
113
+ return;
114
+ }
115
+ const runMatch = argument.match(/^run\s+([a-z0-9]+)$/i);
116
+ if (runMatch) {
117
+ const item = promptStore.remove(contextKey, runMatch[1]);
118
+ if (!item) {
119
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${runMatch[1]}.`), {
120
+ fallbackText: `No queued prompt found with id ${runMatch[1]}.`,
121
+ });
122
+ return;
123
+ }
124
+ promptStore.enqueueFront(contextKey, item);
125
+ promptStore.resume(contextKey);
126
+ if (!chatId) {
127
+ return;
128
+ }
129
+ const busy = options.getBusyReason(contextKey);
130
+ if (busy.busy) {
131
+ const message = `Queued prompt ${item.id} moved to top and will run when the current task finishes.`;
132
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
133
+ if (busy.kind === "external") {
134
+ options.scheduleExternalQueueDrain(ctx, contextKey, chatId, session);
135
+ }
136
+ return;
137
+ }
138
+ const next = promptStore.dequeue(contextKey);
139
+ if (next) {
140
+ await options.handleUserPrompt(ctx, contextKey, chatId, session, next, { fromQueue: true });
141
+ }
142
+ return;
143
+ }
144
+ if (argument) {
145
+ await safeReply(ctx, escapeHTML("Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>"), {
146
+ fallbackText: "Usage: /queue, /queue pause, /queue resume, /queue later <minutes> <prompt>, /queue inspect <id>, /queue move <id> top|up|down, /queue run <id>",
147
+ });
148
+ return;
149
+ }
150
+ const queue = promptStore.list(contextKey);
151
+ const rendered = renderQueueList(promptStore, contextKey, queue);
152
+ await safeReply(ctx, rendered.html, {
153
+ fallbackText: rendered.plain,
154
+ replyMarkup: rendered.keyboard,
155
+ });
156
+ });
157
+ bot.command("clearqueue", async (ctx) => {
158
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
159
+ if (!contextSession) {
160
+ return;
161
+ }
162
+ const count = promptStore.clear(contextSession.contextKey);
163
+ const message = `Cleared ${count} queued prompt${count === 1 ? "" : "s"}.`;
164
+ await safeReply(ctx, escapeHTML(message), { fallbackText: message });
165
+ });
166
+ bot.command("cancel", async (ctx) => {
167
+ const contextSession = await options.getContextSession(ctx, { deferThreadStart: true });
168
+ if (!contextSession) {
169
+ return;
170
+ }
171
+ const rawText = ctx.message?.text ?? "";
172
+ const id = rawText.replace(/^\/cancel(?:@\w+)?\s*/i, "").trim();
173
+ if (!id) {
174
+ await safeReply(ctx, escapeHTML("Usage: /cancel <queue-id>"), {
175
+ fallbackText: "Usage: /cancel <queue-id>",
176
+ });
177
+ return;
178
+ }
179
+ const removed = promptStore.remove(contextSession.contextKey, id);
180
+ if (!removed) {
181
+ await safeReply(ctx, escapeHTML(`No queued prompt found with id ${id}.`), {
182
+ fallbackText: `No queued prompt found with id ${id}.`,
183
+ });
184
+ return;
185
+ }
186
+ await safeReply(ctx, escapeHTML(`Cancelled queued prompt ${removed.id}.`), {
187
+ fallbackText: `Cancelled queued prompt ${removed.id}.`,
188
+ });
189
+ });
190
+ bot.callbackQuery(/^queue_(cancel|remove|top|up|down|run):(-?\d+(?::\d+)?):([a-z0-9]+)$/, async (ctx) => {
191
+ const action = ctx.match?.[1];
192
+ const contextKey = ctx.match?.[2];
193
+ const queueId = ctx.match?.[3];
194
+ if (!action || !contextKey || !queueId) {
195
+ await ctx.answerCallbackQuery();
196
+ return;
197
+ }
198
+ const currentContextKey = contextKeyFromCtx(ctx);
199
+ if (currentContextKey && currentContextKey !== contextKey) {
200
+ await ctx.answerCallbackQuery({ text: "This queue button belongs to another chat or topic." });
201
+ return;
202
+ }
203
+ const chatId = ctx.chat?.id;
204
+ const messageId = ctx.callbackQuery.message?.message_id;
205
+ if (action === "top" || action === "up" || action === "down") {
206
+ const item = action === "top"
207
+ ? promptStore.moveToTop(contextKey, queueId)
208
+ : action === "up"
209
+ ? promptStore.moveUp(contextKey, queueId)
210
+ : promptStore.moveDown(contextKey, queueId);
211
+ await ctx.answerCallbackQuery({ text: item ? `Moved ${queueId} ${action}.` : "Queued prompt not found." });
212
+ if (chatId && messageId) {
213
+ const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
214
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
215
+ fallbackText: rendered.plain,
216
+ replyMarkup: rendered.keyboard,
217
+ });
218
+ }
219
+ return;
220
+ }
221
+ if (action === "run") {
222
+ const item = promptStore.remove(contextKey, queueId);
223
+ if (!item) {
224
+ await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
225
+ return;
226
+ }
227
+ promptStore.enqueueFront(contextKey, item);
228
+ promptStore.resume(contextKey);
229
+ await ctx.answerCallbackQuery({ text: `Queued prompt ${queueId} moved to next.` });
230
+ if (chatId && messageId) {
231
+ const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
232
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
233
+ fallbackText: rendered.plain,
234
+ replyMarkup: rendered.keyboard,
235
+ });
236
+ }
237
+ const session = options.getSession(contextKey);
238
+ if (chatId && session && !options.getBusyReason(contextKey).busy) {
239
+ void options.drainQueuedPrompts(ctx, contextKey, chatId, session).catch((error) => {
240
+ console.error("Failed to drain queue after run-now callback:", error);
241
+ });
242
+ }
243
+ return;
244
+ }
245
+ const removed = promptStore.remove(contextKey, queueId);
246
+ if (!removed) {
247
+ await ctx.answerCallbackQuery({ text: "Queued prompt already started or was cancelled." });
248
+ if (chatId && messageId) {
249
+ if (action === "remove") {
250
+ const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
251
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
252
+ fallbackText: rendered.plain,
253
+ replyMarkup: rendered.keyboard,
254
+ });
255
+ }
256
+ else {
257
+ const message = `Queued prompt ${queueId} is no longer queued.`;
258
+ await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
259
+ }
260
+ }
261
+ return;
262
+ }
263
+ const message = `Cancelled queued prompt ${removed.id}.`;
264
+ await ctx.answerCallbackQuery({ text: message });
265
+ if (!chatId || !messageId) {
266
+ return;
267
+ }
268
+ if (action === "remove") {
269
+ const rendered = renderQueueList(promptStore, contextKey, promptStore.list(contextKey));
270
+ await safeEditMessage(bot, chatId, messageId, rendered.html, {
271
+ fallbackText: rendered.plain,
272
+ replyMarkup: rendered.keyboard,
273
+ });
274
+ return;
275
+ }
276
+ await safeEditMessage(bot, chatId, messageId, escapeHTML(message), { fallbackText: message });
277
+ });
278
+ }
@@ -0,0 +1,53 @@
1
+ import { InputFile } from "grammy";
2
+ import { contextKeyFromCtx } from "./context-key.js";
3
+ import { getConnectorHealth, getVersionChecks } from "./operations.js";
4
+ import { formatLocalDateTime } from "./bot-rendering.js";
5
+ import { createSupportBundle } from "./support-bundle.js";
6
+ import { chatBucket } from "./telegram-output.js";
7
+ import { telegramRateLimiter } from "./telegram-rate-limit.js";
8
+ export function registerTelegramSupportCommands(options) {
9
+ options.bot.command(["support", "diagnostics_bundle"], async (ctx) => {
10
+ if (!ctx.chat) {
11
+ return;
12
+ }
13
+ const health = await getConnectorHealth(cliPathOptions(options.config));
14
+ const versionChecks = await getVersionChecks(cliPathOptions(options.config));
15
+ const bundle = await createSupportBundle({
16
+ config: options.config,
17
+ health,
18
+ versionChecks,
19
+ auditEvents: options.auditLog.list(100),
20
+ agentUpdateJobs: options.agentUpdates.list(),
21
+ source: "telegram",
22
+ });
23
+ const contextKey = contextKeyFromCtx(ctx);
24
+ if (contextKey) {
25
+ options.audit({
26
+ action: "command",
27
+ status: "ok",
28
+ contextKey,
29
+ actorId: ctx.from?.id,
30
+ actorRole: options.getUserRole(ctx),
31
+ description: "export diagnostics bundle",
32
+ detail: bundle.path,
33
+ });
34
+ }
35
+ await telegramRateLimiter.run(chatBucket(ctx.chat.id), "sendDocument", () => ctx.api.sendDocument(ctx.chat.id, new InputFile(bundle.path, bundle.name), {
36
+ caption: [
37
+ "Diagnostics bundle exported.",
38
+ `Created: ${formatLocalDateTime(new Date(bundle.createdAt))}`,
39
+ `Files: ${bundle.includedFiles.length}`,
40
+ `Size: ${bundle.sizeBytes} bytes`,
41
+ ].join("\n"),
42
+ ...(ctx.message?.message_thread_id ? { message_thread_id: ctx.message.message_thread_id } : {}),
43
+ }));
44
+ });
45
+ }
46
+ function cliPathOptions(config) {
47
+ return {
48
+ piCliPath: config.piCliPath,
49
+ hermesCliPath: config.hermesCliPath,
50
+ openClawCliPath: config.openClawCliPath,
51
+ claudeCodeCliPath: config.claudeCodeCliPath,
52
+ };
53
+ }
@@ -11,6 +11,11 @@ export function registerTelegramUpdateCommands(deps) {
11
11
  const argument = rawText.replace(/^\/update(?:@\w+)?\s*/i, "").trim();
12
12
  const tokens = argument.split(/\s+/).filter(Boolean);
13
13
  const subcommand = tokens[0]?.toLowerCase();
14
+ const installTarget = subcommand === "install" ? parseAgentUpdateId(tokens[1]) : null;
15
+ if (installTarget) {
16
+ await startTelegramAgentUpdate(ctx, installTarget, "install");
17
+ return;
18
+ }
14
19
  if (subcommand === "agents" || subcommand === "agent") {
15
20
  const rendered = renderAgentUpdatePickerAction(listAgentAdapterDescriptors());
16
21
  await replyChannelAction(ctx, rendered);
@@ -44,7 +49,7 @@ export function registerTelegramUpdateCommands(deps) {
44
49
  return;
45
50
  }
46
51
  if (subcommand) {
47
- const usage = "Unknown update target. Use /update, /update agents, /update jobs, /update <agent>, /update log <id>, /update cancel <id>, or /update input <id> <text>.";
52
+ const usage = "Unknown update target. Use /update, /update agents, /update jobs, /update <agent>, /update install <agent>, /update log <id>, /update cancel <id>, or /update input <id> <text>.";
48
53
  await safeReply(ctx, escapeHTML(usage), { fallbackText: usage });
49
54
  return;
50
55
  }
@@ -1,42 +1,90 @@
1
- export const WEB_API_ROUTES = [
2
- ...exact(["/api/bootstrap", "/api/health", "/api/snapshot", "/api/tasks", "/api/progress"], "inspect"),
3
- ...exact(["/api/version", "/api/adapters/health"], "inspect"),
4
- ...exact(["/api/diagnostics"], "diagnostics.read"),
5
- ...prefix(["/api/users", "/api/groups", "/api/telegram-chats"], readWrite("users.read", "users.write")),
6
- ...exact(["/api/permissions"], "users.read"),
7
- ...exact(["/api/audit"], "audit.read"),
8
- ...exact(["/api/control-options"], "settings.read"),
9
- ...exact(["/api/settings"], readWrite("settings.read", "settings.write")),
10
- ...exact(["/api/update"], "updates.run"),
11
- ...prefix(["/api/agent-update"], "updates.run"),
12
- ...exact(["/api/logs"], "logs.read"),
13
- ...exact(["/api/logs/clear"], "logs.clear"),
14
- ...exact(["/api/runtime/restart"], "system.restart"),
15
- ...prefix(["/api/sessions"], readWrite("sessions.read", "sessions.write")),
16
- ...exact(["/api/agent", "/api/sync", "/api/handback", "/api/locks"], readWrite("sessions.read", "sessions.write")),
17
- ...prefix(["/api/auth/"], readWrite("inspect", "auth.manage")),
18
- ...prefix(["/api/models", "/api/session/"], readWrite("settings.read", "settings.write")),
19
- ...exact(["/api/queue"], readWrite("queue.read", "queue.write")),
20
- ...exact(["/api/prompt", "/api/prompt/upload", "/api/retry"], readWrite("inspect", "prompt.send")),
21
- ...exact(["/api/abort", "/api/stop"], "prompt.abort"),
22
- ...prefix(["/api/chat"], readWrite("sessions.read", "sessions.write")),
23
- ...prefix(["/api/activity"], "sessions.read"),
24
- ...prefix(["/api/artifacts"], readWrite("files.read", "files.write")),
1
+ const stringToken = "${string}";
2
+ export const WEB_API_ROUTE_DEFINITIONS = [
3
+ exact("/api/auth/me", ["GET"], "inspect"),
4
+ exact("/api/dashboard/logout", ["POST"], "inspect"),
5
+ exact("/api/bootstrap", ["GET"], "inspect"),
6
+ exact("/api/health", ["GET"], "inspect"),
7
+ exact("/api/snapshot", ["GET"], "inspect"),
8
+ exact("/api/tasks", ["GET"], "inspect"),
9
+ exact("/api/progress", ["GET"], "inspect"),
10
+ exact("/api/version", ["GET"], "inspect"),
11
+ exact("/api/update", ["POST"], "updates.run"),
12
+ exact("/api/agent-updates", ["GET"], "updates.run"),
13
+ exact("/api/agent-update", ["POST"], "updates.run"),
14
+ dynamic("/api/agent-update/:id/log", "^/api/agent-update/[^/]+/log$", ["GET", "DELETE"], "updates.run", `/api/agent-update/${stringToken}/log`),
15
+ dynamic("/api/agent-update/:id/input", "^/api/agent-update/[^/]+/input$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/input`),
16
+ dynamic("/api/agent-update/:id/cancel", "^/api/agent-update/[^/]+/cancel$", ["POST"], "updates.run", `/api/agent-update/${stringToken}/cancel`),
17
+ exact("/api/adapters/health", ["GET"], "inspect"),
18
+ exact("/api/permissions", ["GET"], "users.read"),
19
+ exact("/api/users", ["GET", "POST"], readWrite("users.read", "users.write")),
20
+ dynamic("/api/users/:id", "^/api/users/[^/]+$", ["PATCH"], "users.write", `/api/users/${stringToken}`),
21
+ dynamic("/api/users/:id/password", "^/api/users/[^/]+/password$", ["POST"], "users.write", `/api/users/${stringToken}/password`),
22
+ dynamic("/api/users/:id/sessions", "^/api/users/[^/]+/sessions$", ["GET", "DELETE"], readWrite("users.read", "users.write"), `/api/users/${stringToken}/sessions`),
23
+ dynamic("/api/users/:id/sessions/:sessionId", "^/api/users/[^/]+/sessions/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/sessions/${stringToken}`),
24
+ dynamic("/api/users/:id/telegram", "^/api/users/[^/]+/telegram$", ["POST"], "users.write", `/api/users/${stringToken}/telegram`),
25
+ dynamic("/api/users/:id/telegram/:identityId", "^/api/users/[^/]+/telegram/[^/]+$", ["DELETE"], "users.write", `/api/users/${stringToken}/telegram/${stringToken}`),
26
+ exact("/api/groups", ["GET", "POST"], readWrite("users.read", "users.write")),
27
+ dynamic("/api/groups/:id", "^/api/groups/[^/]+$", ["PATCH"], "users.write", `/api/groups/${stringToken}`),
28
+ exact("/api/telegram-chats", ["GET", "POST"], readWrite("users.read", "users.write")),
29
+ dynamic("/api/telegram-chats/:id", "^/api/telegram-chats/[^/]+$", ["PATCH"], "users.write", `/api/telegram-chats/${stringToken}`),
30
+ exact("/api/audit", ["GET"], "audit.read"),
31
+ exact("/api/locks", ["GET", "POST", "DELETE"], readWrite("sessions.read", "sessions.write")),
32
+ exact("/api/auth/status", ["GET"], "inspect"),
33
+ exact("/api/auth/login", ["POST"], "auth.manage"),
34
+ exact("/api/auth/logout", ["POST"], "auth.manage"),
35
+ exact("/api/settings", ["GET", "PATCH"], readWrite("settings.read", "settings.write")),
36
+ exact("/api/control-options", ["GET"], "settings.read"),
37
+ exact("/api/sessions", ["GET"], "sessions.read"),
38
+ exact("/api/sessions/new", ["POST"], "sessions.write"),
39
+ exact("/api/sessions/switch", ["POST"], "sessions.write"),
40
+ exact("/api/sessions/attach", ["POST"], "sessions.write"),
41
+ exact("/api/sessions/detail", ["GET"], "sessions.read"),
42
+ exact("/api/agent", ["POST"], "sessions.write"),
43
+ exact("/api/models", ["GET"], "settings.read"),
44
+ exact("/api/session/model", ["POST"], "settings.write"),
45
+ exact("/api/session/reasoning", ["POST"], "settings.write"),
46
+ exact("/api/session/fast", ["POST"], "settings.write"),
47
+ exact("/api/session/launch", ["POST"], "settings.write"),
48
+ exact("/api/prompt", ["POST"], "prompt.send"),
49
+ exact("/api/prompt/upload", ["POST"], "prompt.send"),
50
+ exact("/api/abort", ["POST"], "prompt.abort"),
51
+ exact("/api/stop", ["POST"], "prompt.abort"),
52
+ exact("/api/handback", ["POST"], "sessions.write"),
53
+ exact("/api/retry", ["POST"], "prompt.send"),
54
+ exact("/api/sync", ["POST"], "sessions.write"),
55
+ exact("/api/queue", ["GET", "POST"], readWrite("queue.read", "queue.write")),
56
+ exact("/api/chat/history", ["GET", "DELETE"], readWrite("sessions.read", "sessions.write")),
57
+ exact("/api/activity", ["GET"], "sessions.read"),
58
+ exact("/api/artifacts", ["GET", "DELETE"], readWrite("files.read", "files.write")),
59
+ exact("/api/artifacts/bulk", ["POST"], "files.write"),
60
+ exact("/api/artifacts/zip", ["GET"], "files.read"),
61
+ exact("/api/artifacts/file", ["GET"], "files.read"),
62
+ exact("/api/artifacts/preview", ["GET"], "files.read"),
63
+ exact("/api/logs", ["GET"], "logs.read"),
64
+ exact("/api/logs/clear", ["POST"], "logs.clear"),
65
+ exact("/api/diagnostics", ["GET"], "diagnostics.read"),
66
+ exact("/api/diagnostics/bundle", ["GET"], "diagnostics.read"),
67
+ exact("/api/runtime/restart", ["POST"], "system.restart"),
25
68
  ];
69
+ export const WEB_API_STATIC_PATHS = WEB_API_ROUTE_DEFINITIONS
70
+ .filter((route) => !route.pattern)
71
+ .map((route) => route.path);
72
+ export const WEB_API_DYNAMIC_TYPE_PATHS = WEB_API_ROUTE_DEFINITIONS
73
+ .flatMap((route) => route.dynamicType ? [route.dynamicType] : []);
26
74
  export function permissionForWebRequestFromContract(method, pathname) {
27
75
  const verb = normalizeMethod(method);
28
- const rule = WEB_API_ROUTES.find((candidate) => (candidate.path !== undefined && candidate.path === pathname) ||
29
- (candidate.prefix !== undefined && pathname.startsWith(candidate.prefix)));
30
- if (!rule) {
76
+ const rule = WEB_API_ROUTE_DEFINITIONS.find((candidate) => (candidate.pattern ? new RegExp(candidate.pattern).test(pathname) : candidate.path === pathname));
77
+ const methods = rule?.methods ?? [];
78
+ if (!rule || !methods.includes(verb)) {
31
79
  return null;
32
80
  }
33
81
  return resolvePermission(rule.permissions, verb);
34
82
  }
35
- function exact(paths, permissions) {
36
- return paths.map((path) => ({ path, permissions }));
83
+ function exact(path, methods, permissions) {
84
+ return { path, methods, permissions };
37
85
  }
38
- function prefix(prefixes, permissions) {
39
- return prefixes.map((pathPrefix) => ({ prefix: pathPrefix, permissions }));
86
+ function dynamic(path, pattern, methods, permissions, dynamicType) {
87
+ return { path, pattern, methods, permissions, dynamicType };
40
88
  }
41
89
  function readWrite(read, write) {
42
90
  return { read, write };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,163 @@
1
+ import { ALL_PERMISSIONS } from "./access-control.js";
2
+ import { publicUser, publicUserSnapshot, } from "./user-management.js";
3
+ import { arrayNumberField, arrayStringField, numberField, numberParam, optionalBooleanField, optionalNumberField, optionalStringField, readJsonBody, sendJson, stringField, } from "./web-dashboard-http.js";
4
+ export async function handleDashboardAccessRoute(req, res, url, options) {
5
+ const { users, runtime, authUser } = options;
6
+ if (req.method === "GET" && url.pathname === "/api/permissions") {
7
+ sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
8
+ return true;
9
+ }
10
+ if (req.method === "GET" && url.pathname === "/api/users") {
11
+ sendJson(res, 200, { ...publicUserSnapshot(users.snapshot()), permissions: ALL_PERMISSIONS });
12
+ return true;
13
+ }
14
+ if (req.method === "POST" && url.pathname === "/api/users") {
15
+ const body = await readJsonBody(req);
16
+ const user = users.createUser({
17
+ email: stringField(body, "email"),
18
+ displayName: optionalStringField(body, "displayName") ?? stringField(body, "email"),
19
+ password: stringField(body, "password"),
20
+ groupIds: arrayStringField(body, "groupIds"),
21
+ active: optionalBooleanField(body, "active") ?? true,
22
+ telegramUserId: optionalNumberField(body, "telegramUserId"),
23
+ });
24
+ options.auditUserAction(authUser, "user_created", user.user.email);
25
+ sendJson(res, 201, { user: publicUser(user.user), groups: user.groups });
26
+ return true;
27
+ }
28
+ const userMatch = url.pathname.match(/^\/api\/users\/([^/]+)$/);
29
+ if (userMatch?.[1] && req.method === "PATCH") {
30
+ const body = await readJsonBody(req);
31
+ const user = users.updateUser(decodeURIComponent(userMatch[1]), {
32
+ email: optionalStringField(body, "email"),
33
+ displayName: optionalStringField(body, "displayName"),
34
+ active: optionalBooleanField(body, "active"),
35
+ groupIds: body.groupIds === undefined ? undefined : arrayStringField(body, "groupIds"),
36
+ });
37
+ options.auditUserAction(authUser, "user_updated", user.user.email);
38
+ sendJson(res, 200, { user: publicUser(user.user), groups: user.groups });
39
+ return true;
40
+ }
41
+ const passwordMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/password$/);
42
+ if (passwordMatch?.[1] && req.method === "POST") {
43
+ const body = await readJsonBody(req);
44
+ const userId = decodeURIComponent(passwordMatch[1]);
45
+ users.setPassword(userId, stringField(body, "password"));
46
+ options.auditUserAction(authUser, "user_password_changed", userId);
47
+ sendJson(res, 200, { ok: true });
48
+ return true;
49
+ }
50
+ const userSessionsMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/sessions$/);
51
+ if (userSessionsMatch?.[1] && req.method === "GET") {
52
+ sendJson(res, 200, { sessions: users.listWebSessions(decodeURIComponent(userSessionsMatch[1])) });
53
+ return true;
54
+ }
55
+ if (userSessionsMatch?.[1] && req.method === "DELETE") {
56
+ const userId = decodeURIComponent(userSessionsMatch[1]);
57
+ const revoked = users.revokeUserSessions(userId);
58
+ options.auditUserAction(authUser, "user_session_revoked", `${userId}: ${revoked} sessions`);
59
+ sendJson(res, 200, { revoked });
60
+ return true;
61
+ }
62
+ const userSessionMatch = url.pathname.match(/^\/api\/users\/[^/]+\/sessions\/([^/]+)$/);
63
+ if (userSessionMatch?.[1] && req.method === "DELETE") {
64
+ const sessionId = decodeURIComponent(userSessionMatch[1]);
65
+ const revoked = users.revokeWebSession(sessionId);
66
+ options.auditUserAction(authUser, "user_session_revoked", sessionId);
67
+ sendJson(res, 200, { revoked });
68
+ return true;
69
+ }
70
+ const telegramLinkMatch = url.pathname.match(/^\/api\/users\/([^/]+)\/telegram$/);
71
+ if (telegramLinkMatch?.[1] && req.method === "POST") {
72
+ const body = await readJsonBody(req);
73
+ if (body.createCode === true) {
74
+ const userId = decodeURIComponent(telegramLinkMatch[1]);
75
+ const linkCode = users.createTelegramLinkCode(userId);
76
+ options.auditUserAction(authUser, "telegram_link_created", userId);
77
+ sendJson(res, 201, { linkCode });
78
+ return true;
79
+ }
80
+ const identity = users.linkTelegramUser(decodeURIComponent(telegramLinkMatch[1]), {
81
+ telegramUserId: numberField(body, "telegramUserId"),
82
+ username: optionalStringField(body, "username"),
83
+ });
84
+ options.auditUserAction(authUser, "telegram_linked", String(identity.telegramUserId));
85
+ sendJson(res, 201, { identity });
86
+ return true;
87
+ }
88
+ const telegramUnlinkMatch = url.pathname.match(/^\/api\/users\/[^/]+\/telegram\/([^/]+)$/);
89
+ if (telegramUnlinkMatch?.[1] && req.method === "DELETE") {
90
+ const identityId = decodeURIComponent(telegramUnlinkMatch[1]);
91
+ const removed = users.unlinkTelegramIdentity(identityId);
92
+ options.auditUserAction(authUser, "telegram_unlinked", identityId);
93
+ sendJson(res, 200, { removed });
94
+ return true;
95
+ }
96
+ if (req.method === "GET" && url.pathname === "/api/groups") {
97
+ sendJson(res, 200, { groups: users.listGroups() });
98
+ return true;
99
+ }
100
+ if (req.method === "POST" && url.pathname === "/api/groups") {
101
+ const body = await readJsonBody(req);
102
+ const group = users.createGroup({
103
+ name: stringField(body, "name"),
104
+ description: optionalStringField(body, "description"),
105
+ permissions: arrayStringField(body, "permissions"),
106
+ agentIds: arrayStringField(body, "agentIds"),
107
+ workspaceRoots: arrayStringField(body, "workspaceRoots"),
108
+ telegramChatIds: arrayNumberField(body, "telegramChatIds"),
109
+ });
110
+ options.auditUserAction(authUser, "group_created", group.id);
111
+ sendJson(res, 201, { group });
112
+ return true;
113
+ }
114
+ const groupMatch = url.pathname.match(/^\/api\/groups\/([^/]+)$/);
115
+ if (groupMatch?.[1] && req.method === "PATCH") {
116
+ const body = await readJsonBody(req);
117
+ const group = users.updateGroup(decodeURIComponent(groupMatch[1]), {
118
+ name: optionalStringField(body, "name"),
119
+ description: optionalStringField(body, "description"),
120
+ permissions: body.permissions === undefined ? undefined : arrayStringField(body, "permissions"),
121
+ agentIds: body.agentIds === undefined ? undefined : arrayStringField(body, "agentIds"),
122
+ workspaceRoots: body.workspaceRoots === undefined ? undefined : arrayStringField(body, "workspaceRoots"),
123
+ telegramChatIds: body.telegramChatIds === undefined ? undefined : arrayNumberField(body, "telegramChatIds"),
124
+ });
125
+ options.auditUserAction(authUser, "group_updated", group.id);
126
+ sendJson(res, 200, { group });
127
+ return true;
128
+ }
129
+ if (req.method === "GET" && url.pathname === "/api/telegram-chats") {
130
+ sendJson(res, 200, { chats: users.snapshot().telegramChats });
131
+ return true;
132
+ }
133
+ if (req.method === "POST" && url.pathname === "/api/telegram-chats") {
134
+ const body = await readJsonBody(req);
135
+ const chat = users.registerTelegramChat({
136
+ chatId: numberField(body, "chatId"),
137
+ title: optionalStringField(body, "title"),
138
+ type: optionalStringField(body, "type"),
139
+ enabled: optionalBooleanField(body, "enabled") ?? true,
140
+ allowedGroupIds: arrayStringField(body, "allowedGroupIds"),
141
+ });
142
+ options.auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
143
+ sendJson(res, 201, { chat });
144
+ return true;
145
+ }
146
+ const chatMatch = url.pathname.match(/^\/api\/telegram-chats\/([^/]+)$/);
147
+ if (chatMatch?.[1] && req.method === "PATCH") {
148
+ const body = await readJsonBody(req);
149
+ const chat = users.updateTelegramChat(decodeURIComponent(chatMatch[1]), {
150
+ enabled: optionalBooleanField(body, "enabled"),
151
+ title: optionalStringField(body, "title"),
152
+ allowedGroupIds: body.allowedGroupIds === undefined ? undefined : arrayStringField(body, "allowedGroupIds"),
153
+ });
154
+ options.auditUserAction(authUser, "telegram_chat_updated", String(chat.chatId));
155
+ sendJson(res, 200, { chat });
156
+ return true;
157
+ }
158
+ if (req.method === "GET" && url.pathname === "/api/audit") {
159
+ sendJson(res, 200, { events: runtime.audit(numberParam(url, "limit", 50)) });
160
+ return true;
161
+ }
162
+ return false;
163
+ }