@nordbyte/nordrelay 0.7.0 → 0.8.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.
- package/.env.example +35 -0
- package/README.md +118 -49
- package/dist/activity-events.js +2 -2
- package/dist/adapter-conformance.js +61 -0
- package/dist/bot.js +18 -31
- package/dist/channel-adapter.js +33 -6
- package/dist/channel-command-catalog.js +6 -0
- package/dist/channel-command-core.js +60 -0
- package/dist/channel-command-service.js +20 -4
- package/dist/channel-mirror-registry.js +9 -2
- package/dist/channel-prompt-engine.js +177 -0
- package/dist/channel-turn-lifecycle.js +73 -0
- package/dist/config-metadata.js +67 -8
- package/dist/config.js +48 -1
- package/dist/context-key.js +32 -0
- package/dist/discord-bot.js +99 -327
- package/dist/index.js +9 -0
- package/dist/metrics.js +2 -0
- package/dist/peer-client.js +90 -2
- package/dist/peer-readiness.js +77 -0
- package/dist/peer-runtime-service.js +22 -0
- package/dist/peer-server.js +20 -4
- package/dist/peer-store.js +17 -2
- package/dist/relay-runtime-helpers.js +3 -1
- package/dist/relay-runtime.js +7 -0
- package/dist/settings-wizard-test.js +216 -0
- package/dist/slack-artifacts.js +165 -0
- package/dist/slack-bot.js +1461 -0
- package/dist/slack-channel-runtime.js +147 -0
- package/dist/slack-command-surface.js +46 -0
- package/dist/slack-diagnostics.js +116 -0
- package/dist/slack-rate-limit.js +139 -0
- package/dist/user-management-crypto.js +38 -0
- package/dist/user-management-normalize.js +188 -0
- package/dist/user-management-types.js +1 -0
- package/dist/user-management.js +193 -196
- package/dist/web-api-contract.js +8 -0
- package/dist/web-dashboard-access-routes.js +62 -0
- package/dist/web-dashboard-assets.js +1 -0
- package/dist/web-dashboard-pages.js +14 -4
- package/dist/web-dashboard-peer-routes.js +32 -11
- package/dist/web-dashboard.js +34 -0
- package/dist/web-state.js +2 -2
- package/dist/webui-assets/dashboard.css +193 -0
- package/dist/webui-assets/dashboard.js +546 -145
- package/package.json +3 -1
- package/plugins/nordrelay/scripts/nordrelay.mjs +105 -11
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { SlackChannelAdapter, } from "./channel-adapter.js";
|
|
3
|
+
import { redactText } from "./redaction.js";
|
|
4
|
+
import { slackRateLimiter } from "./slack-rate-limit.js";
|
|
5
|
+
const SLACK_TEXT_LIMIT = 40000;
|
|
6
|
+
const SLACK_SAFE_TEXT_LIMIT = 3500;
|
|
7
|
+
export const SLACK_ACTION_PREFIX = "nr:";
|
|
8
|
+
export class SlackBotChannelRuntime {
|
|
9
|
+
client;
|
|
10
|
+
id = "slack";
|
|
11
|
+
label = "Slack";
|
|
12
|
+
capabilities = new SlackChannelAdapter().capabilities;
|
|
13
|
+
constructor(client) {
|
|
14
|
+
this.client = client;
|
|
15
|
+
}
|
|
16
|
+
describe() {
|
|
17
|
+
return new SlackChannelAdapter().describe();
|
|
18
|
+
}
|
|
19
|
+
async sendMessage(context, message) {
|
|
20
|
+
const chunks = splitSlackMessage(slackMessageText(message));
|
|
21
|
+
let firstTs = "";
|
|
22
|
+
for (const [index, chunk] of chunks.entries()) {
|
|
23
|
+
const result = await slackRateLimiter.run(slackBucket(context), "sendMessage", () => this.client.chat.postMessage({
|
|
24
|
+
channel: context.chatId,
|
|
25
|
+
thread_ts: message.threadId ?? context.topicId,
|
|
26
|
+
text: chunk,
|
|
27
|
+
mrkdwn: true,
|
|
28
|
+
blocks: slackBlocks({ ...message, fallbackText: chunk, text: chunk }, index === chunks.length - 1),
|
|
29
|
+
unfurl_links: false,
|
|
30
|
+
unfurl_media: false,
|
|
31
|
+
}));
|
|
32
|
+
firstTs ||= result.ts ?? "";
|
|
33
|
+
}
|
|
34
|
+
return { messageId: firstTs };
|
|
35
|
+
}
|
|
36
|
+
async editMessage(context, messageId, message) {
|
|
37
|
+
const text = trimSlackMessage(slackMessageText(message));
|
|
38
|
+
await slackRateLimiter.run(slackBucket(context), "editMessage", () => this.client.chat.update({
|
|
39
|
+
channel: context.chatId,
|
|
40
|
+
ts: messageId,
|
|
41
|
+
text,
|
|
42
|
+
blocks: slackBlocks({ ...message, fallbackText: text, text }, true),
|
|
43
|
+
unfurl_links: false,
|
|
44
|
+
unfurl_media: false,
|
|
45
|
+
})).catch(async () => {
|
|
46
|
+
await this.sendMessage(context, message);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async sendTyping(context) {
|
|
50
|
+
await slackRateLimiter.run(slackBucket(context), "typing", async () => {
|
|
51
|
+
await this.client.assistant?.threads?.setStatus?.({
|
|
52
|
+
channel_id: context.chatId,
|
|
53
|
+
thread_ts: context.topicId,
|
|
54
|
+
status: "Working...",
|
|
55
|
+
});
|
|
56
|
+
}).catch(() => { });
|
|
57
|
+
}
|
|
58
|
+
async sendFile(context, file) {
|
|
59
|
+
const result = await slackRateLimiter.run(slackBucket(context), "sendFile", () => this.client.files.uploadV2({
|
|
60
|
+
channel_id: context.chatId,
|
|
61
|
+
thread_ts: file.threadId ?? context.topicId,
|
|
62
|
+
file: createReadStream(file.localPath),
|
|
63
|
+
filename: file.name,
|
|
64
|
+
title: file.name,
|
|
65
|
+
initial_comment: file.caption ? trimSlackMessage(redactText(file.caption)) : undefined,
|
|
66
|
+
}));
|
|
67
|
+
return { messageId: result.files?.[0]?.id ?? "" };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function slackMessageText(message) {
|
|
71
|
+
const text = message.fallbackText?.trim() || stripHtml(message.text).trim() || ".";
|
|
72
|
+
return text.length <= SLACK_TEXT_LIMIT ? text : `${text.slice(0, SLACK_TEXT_LIMIT - 1)}…`;
|
|
73
|
+
}
|
|
74
|
+
export function slackActionId(action) {
|
|
75
|
+
const raw = `${SLACK_ACTION_PREFIX}${action}`;
|
|
76
|
+
return raw.length <= 255 ? raw : `${SLACK_ACTION_PREFIX}${action.slice(0, 255 - SLACK_ACTION_PREFIX.length)}`;
|
|
77
|
+
}
|
|
78
|
+
export function actionFromSlackActionId(actionId) {
|
|
79
|
+
return actionId.startsWith(SLACK_ACTION_PREFIX) ? actionId.slice(SLACK_ACTION_PREFIX.length) : null;
|
|
80
|
+
}
|
|
81
|
+
export function splitSlackMessage(text) {
|
|
82
|
+
const normalized = text || ".";
|
|
83
|
+
if (normalized.length <= SLACK_SAFE_TEXT_LIMIT) {
|
|
84
|
+
return [normalized];
|
|
85
|
+
}
|
|
86
|
+
const chunks = [];
|
|
87
|
+
let remaining = normalized;
|
|
88
|
+
while (remaining.length > 0) {
|
|
89
|
+
const slice = remaining.slice(0, SLACK_SAFE_TEXT_LIMIT);
|
|
90
|
+
const breakAt = Math.max(slice.lastIndexOf("\n"), slice.lastIndexOf(" "));
|
|
91
|
+
const length = breakAt > 400 ? breakAt : SLACK_SAFE_TEXT_LIMIT;
|
|
92
|
+
chunks.push(remaining.slice(0, length).trimEnd() || ".");
|
|
93
|
+
remaining = remaining.slice(length).trimStart();
|
|
94
|
+
}
|
|
95
|
+
return chunks;
|
|
96
|
+
}
|
|
97
|
+
export function trimSlackMessage(text) {
|
|
98
|
+
return text.length <= SLACK_SAFE_TEXT_LIMIT ? text : `${text.slice(0, SLACK_SAFE_TEXT_LIMIT - 1)}…`;
|
|
99
|
+
}
|
|
100
|
+
export function slackBlocks(message, includeButtons = true) {
|
|
101
|
+
const blocks = [
|
|
102
|
+
{
|
|
103
|
+
type: "section",
|
|
104
|
+
text: {
|
|
105
|
+
type: "mrkdwn",
|
|
106
|
+
text: trimSlackMessage(slackMessageText(message)),
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
if (includeButtons && message.buttons?.length) {
|
|
111
|
+
blocks.push(...message.buttons.slice(0, 5).map((row) => ({
|
|
112
|
+
type: "actions",
|
|
113
|
+
elements: row.slice(0, 5).map((button) => slackButton(button)),
|
|
114
|
+
})));
|
|
115
|
+
}
|
|
116
|
+
return blocks;
|
|
117
|
+
}
|
|
118
|
+
function slackButton(button) {
|
|
119
|
+
return {
|
|
120
|
+
type: "button",
|
|
121
|
+
text: {
|
|
122
|
+
type: "plain_text",
|
|
123
|
+
text: trimButtonLabel(button.label),
|
|
124
|
+
emoji: true,
|
|
125
|
+
},
|
|
126
|
+
action_id: slackActionId(button.action),
|
|
127
|
+
value: button.action.slice(0, 2000),
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function slackBucket(context) {
|
|
131
|
+
return context.topicId ?? context.chatId;
|
|
132
|
+
}
|
|
133
|
+
function stripHtml(text) {
|
|
134
|
+
return text
|
|
135
|
+
.replace(/<br\s*\/?>/gi, "\n")
|
|
136
|
+
.replace(/<\/(p|div|li|pre)>/gi, "\n")
|
|
137
|
+
.replace(/<[^>]+>/g, "")
|
|
138
|
+
.replace(/</g, "<")
|
|
139
|
+
.replace(/>/g, ">")
|
|
140
|
+
.replace(/&/g, "&")
|
|
141
|
+
.replace(/"/g, "\"")
|
|
142
|
+
.replace(/'/g, "'");
|
|
143
|
+
}
|
|
144
|
+
function trimButtonLabel(label) {
|
|
145
|
+
const trimmed = label.trim() || "Action";
|
|
146
|
+
return trimmed.length <= 75 ? trimmed : trimmed.slice(0, 75);
|
|
147
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { permissionForCommand } from "./access-control.js";
|
|
2
|
+
import { normalizeChannelCommandName, parseChannelCommand } from "./channel-runtime.js";
|
|
3
|
+
export function parseSlackMessageCommand(text) {
|
|
4
|
+
return parseChannelCommand(text, { allowBotMention: false });
|
|
5
|
+
}
|
|
6
|
+
export function parseSlackSlashCommand(text) {
|
|
7
|
+
const trimmed = text.trim();
|
|
8
|
+
const parsed = parseSlackMessageCommand(trimmed);
|
|
9
|
+
if (parsed) {
|
|
10
|
+
return parsed;
|
|
11
|
+
}
|
|
12
|
+
const [command = "prompt", ...rest] = trimmed.split(/\s+/);
|
|
13
|
+
if (!trimmed) {
|
|
14
|
+
return { command: "help", argument: "" };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
command: normalizeChannelCommandName(command),
|
|
18
|
+
argument: rest.join(" ").trim(),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function requiredPermissionForSlackCommand(command, argument) {
|
|
22
|
+
const normalized = normalizeChannelCommandName(command);
|
|
23
|
+
if (normalized === "prompt")
|
|
24
|
+
return "prompt.send";
|
|
25
|
+
if (normalized === "queue")
|
|
26
|
+
return argument.trim() ? "queue.write" : "queue.read";
|
|
27
|
+
return permissionForCommand(normalized);
|
|
28
|
+
}
|
|
29
|
+
export function isUnauthenticatedSlackCommandAllowed(command) {
|
|
30
|
+
return normalizeChannelCommandName(command) === "link";
|
|
31
|
+
}
|
|
32
|
+
export function permissionForSlackAction(action) {
|
|
33
|
+
if (action.startsWith("slack_queue_") || action.startsWith("slack_peer_queue_"))
|
|
34
|
+
return "queue.write";
|
|
35
|
+
if (action.startsWith("slack_abort:"))
|
|
36
|
+
return "prompt.abort";
|
|
37
|
+
if (action.startsWith("slack_pick:"))
|
|
38
|
+
return "sessions.write";
|
|
39
|
+
if (action.startsWith("slack_artifact_delete:"))
|
|
40
|
+
return "files.write";
|
|
41
|
+
if (action.startsWith("slack_artifact_"))
|
|
42
|
+
return "files.read";
|
|
43
|
+
if (action.startsWith("agent-update:"))
|
|
44
|
+
return "updates.run";
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { WebClient } from "@slack/web-api";
|
|
2
|
+
import { friendlyErrorText } from "./error-messages.js";
|
|
3
|
+
import { UserStore } from "./user-management.js";
|
|
4
|
+
export async function collectSlackDiagnostics(input) {
|
|
5
|
+
const { config } = input;
|
|
6
|
+
const checks = [];
|
|
7
|
+
const userStore = input.userStore ?? new UserStore();
|
|
8
|
+
const registeredChannels = userStore.snapshot().slackChannels;
|
|
9
|
+
const mode = config.slackSocketMode ? "socket" : "http";
|
|
10
|
+
const configured = Boolean(config.slackBotToken) &&
|
|
11
|
+
(config.slackSocketMode ? Boolean(config.slackAppToken) : Boolean(config.slackSigningSecret));
|
|
12
|
+
checks.push(check(Boolean(config.slackBotToken), "Bot token", "SLACK_BOT_TOKEN is configured.", "SLACK_BOT_TOKEN is missing."));
|
|
13
|
+
checks.push(check(config.slackSocketMode ? Boolean(config.slackAppToken) : Boolean(config.slackSigningSecret), "Transport secret", config.slackSocketMode ? "Socket Mode app token is configured." : "HTTP signing secret is configured.", config.slackSocketMode ? "SLACK_APP_TOKEN is required for Socket Mode." : "SLACK_SIGNING_SECRET is required for HTTP Events mode."));
|
|
14
|
+
checks.push({
|
|
15
|
+
status: registeredChannels.length > 0 ? "ok" : "warn",
|
|
16
|
+
label: "Registered channels",
|
|
17
|
+
detail: registeredChannels.length > 0
|
|
18
|
+
? `${registeredChannels.length} Slack channel access record(s) configured.`
|
|
19
|
+
: "No Slack channels are registered yet. Admins can use /register_channel or the WebUI Access page.",
|
|
20
|
+
});
|
|
21
|
+
checks.push({
|
|
22
|
+
status: config.slackAllowedTeamIds.length > 0 || config.slackAllowedChannelIds.length > 0 ? "ok" : "warn",
|
|
23
|
+
label: "Environment allow-list",
|
|
24
|
+
detail: config.slackAllowedTeamIds.length > 0 || config.slackAllowedChannelIds.length > 0
|
|
25
|
+
? `Team allow-list: ${config.slackAllowedTeamIds.length}; channel allow-list: ${config.slackAllowedChannelIds.length}.`
|
|
26
|
+
: "No SLACK_ALLOWED_TEAM_IDS or SLACK_ALLOWED_CHANNEL_IDS are set. User/group permissions still apply.",
|
|
27
|
+
});
|
|
28
|
+
checks.push({
|
|
29
|
+
status: "ok",
|
|
30
|
+
label: "File upload smoke",
|
|
31
|
+
detail: "Slack file upload uses files.uploadV2; real upload probes are intentionally not sent without a target channel.",
|
|
32
|
+
});
|
|
33
|
+
let auth;
|
|
34
|
+
const channelChecks = [];
|
|
35
|
+
if (config.slackEnabled && config.slackBotToken) {
|
|
36
|
+
const client = new WebClient(config.slackBotToken);
|
|
37
|
+
const timeoutMs = input.timeoutMs ?? 4_000;
|
|
38
|
+
try {
|
|
39
|
+
const authResult = await withTimeout(client.auth.test(), timeoutMs);
|
|
40
|
+
auth = {
|
|
41
|
+
ok: Boolean(authResult.ok),
|
|
42
|
+
teamId: authResult.team_id,
|
|
43
|
+
userId: authResult.user_id,
|
|
44
|
+
botId: authResult.bot_id,
|
|
45
|
+
url: authResult.url,
|
|
46
|
+
detail: authResult.ok ? "Slack auth.test succeeded." : "Slack auth.test returned ok=false.",
|
|
47
|
+
};
|
|
48
|
+
checks.push({ status: auth.ok ? "ok" : "error", label: "Slack auth.test", detail: auth.detail });
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
auth = { ok: false, detail: friendlyErrorText(error) };
|
|
52
|
+
checks.push({ status: "error", label: "Slack auth.test", detail: auth.detail });
|
|
53
|
+
}
|
|
54
|
+
for (const channel of registeredChannels.slice(0, input.channelProbeLimit ?? 5)) {
|
|
55
|
+
try {
|
|
56
|
+
const result = await withTimeout(client.conversations.info({ channel: channel.channelId }), timeoutMs);
|
|
57
|
+
channelChecks.push({
|
|
58
|
+
channelId: channel.channelId,
|
|
59
|
+
teamId: channel.teamId,
|
|
60
|
+
title: channel.title,
|
|
61
|
+
status: result.ok ? "ok" : "warn",
|
|
62
|
+
detail: result.ok ? "Slack conversations.info succeeded." : "Slack conversations.info returned ok=false.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
channelChecks.push({
|
|
67
|
+
channelId: channel.channelId,
|
|
68
|
+
teamId: channel.teamId,
|
|
69
|
+
title: channel.title,
|
|
70
|
+
status: "error",
|
|
71
|
+
detail: friendlyErrorText(error),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (config.slackEnabled) {
|
|
77
|
+
checks.push({ status: "error", label: "Slack API probes", detail: "Cannot run Slack API probes without SLACK_BOT_TOKEN." });
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
checks.push({ status: "skipped", label: "Slack API probes", detail: "Slack adapter is disabled." });
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
enabled: config.slackEnabled,
|
|
84
|
+
mode,
|
|
85
|
+
configured,
|
|
86
|
+
generatedAt: new Date().toISOString(),
|
|
87
|
+
checks,
|
|
88
|
+
auth,
|
|
89
|
+
registeredChannels: registeredChannels.length,
|
|
90
|
+
channelChecks,
|
|
91
|
+
rateLimit: input.rateLimit,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function check(condition, label, okDetail, errorDetail) {
|
|
95
|
+
return {
|
|
96
|
+
status: condition ? "ok" : "error",
|
|
97
|
+
label,
|
|
98
|
+
detail: condition ? okDetail : errorDetail,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
async function withTimeout(promise, timeoutMs) {
|
|
102
|
+
let timer;
|
|
103
|
+
try {
|
|
104
|
+
return await Promise.race([
|
|
105
|
+
promise,
|
|
106
|
+
new Promise((_, reject) => {
|
|
107
|
+
timer = setTimeout(() => reject(new Error(`Slack API probe timed out after ${timeoutMs}ms`)), timeoutMs);
|
|
108
|
+
timer.unref?.();
|
|
109
|
+
}),
|
|
110
|
+
]);
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
if (timer)
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
const DEFAULT_OPTIONS = {
|
|
2
|
+
minIntervalMs: 250,
|
|
3
|
+
editMinIntervalMs: 1_500,
|
|
4
|
+
typingMinIntervalMs: 4_500,
|
|
5
|
+
maxRetries: 5,
|
|
6
|
+
};
|
|
7
|
+
export class SlackRateLimiter {
|
|
8
|
+
options;
|
|
9
|
+
buckets = new Map();
|
|
10
|
+
queued = 0;
|
|
11
|
+
running = 0;
|
|
12
|
+
completed = 0;
|
|
13
|
+
failed = 0;
|
|
14
|
+
retries = 0;
|
|
15
|
+
rateLimitHits = 0;
|
|
16
|
+
lastRateLimitAt;
|
|
17
|
+
lastRetryAfterSeconds;
|
|
18
|
+
constructor(options = DEFAULT_OPTIONS) {
|
|
19
|
+
this.options = options;
|
|
20
|
+
}
|
|
21
|
+
configure(options) {
|
|
22
|
+
this.options = { ...this.options, ...options };
|
|
23
|
+
}
|
|
24
|
+
async run(bucket, method, operation) {
|
|
25
|
+
const normalizedBucket = `${method}:${bucket}`;
|
|
26
|
+
const state = this.getBucket(normalizedBucket);
|
|
27
|
+
this.queued += 1;
|
|
28
|
+
let releasePrevious;
|
|
29
|
+
const previous = state.chain;
|
|
30
|
+
state.chain = new Promise((resolve) => {
|
|
31
|
+
releasePrevious = resolve;
|
|
32
|
+
});
|
|
33
|
+
await previous.catch(() => { });
|
|
34
|
+
this.queued = Math.max(0, this.queued - 1);
|
|
35
|
+
this.running += 1;
|
|
36
|
+
try {
|
|
37
|
+
await this.waitForBucket(state, method);
|
|
38
|
+
const result = await this.runWithRetries(operation);
|
|
39
|
+
this.completed += 1;
|
|
40
|
+
state.lastRunAtMs = Date.now();
|
|
41
|
+
state.queuedUntilMs = Math.max(state.queuedUntilMs, state.lastRunAtMs + this.intervalForMethod(method));
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
this.failed += 1;
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
this.running = Math.max(0, this.running - 1);
|
|
50
|
+
releasePrevious();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
getMetrics() {
|
|
54
|
+
return {
|
|
55
|
+
queued: this.queued,
|
|
56
|
+
running: this.running,
|
|
57
|
+
completed: this.completed,
|
|
58
|
+
failed: this.failed,
|
|
59
|
+
retries: this.retries,
|
|
60
|
+
rateLimitHits: this.rateLimitHits,
|
|
61
|
+
lastRateLimitAt: this.lastRateLimitAt,
|
|
62
|
+
lastRetryAfterSeconds: this.lastRetryAfterSeconds,
|
|
63
|
+
buckets: [...this.buckets.entries()]
|
|
64
|
+
.filter(([, state]) => state.queuedUntilMs > Date.now() || state.lastRunAtMs > 0)
|
|
65
|
+
.slice(0, 12)
|
|
66
|
+
.map(([bucket, state]) => ({
|
|
67
|
+
bucket,
|
|
68
|
+
queuedUntilMs: state.queuedUntilMs,
|
|
69
|
+
lastRunAtMs: state.lastRunAtMs,
|
|
70
|
+
})),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
getBucket(bucket) {
|
|
74
|
+
let state = this.buckets.get(bucket);
|
|
75
|
+
if (!state) {
|
|
76
|
+
state = { chain: Promise.resolve(), queuedUntilMs: 0, lastRunAtMs: 0 };
|
|
77
|
+
this.buckets.set(bucket, state);
|
|
78
|
+
}
|
|
79
|
+
return state;
|
|
80
|
+
}
|
|
81
|
+
async waitForBucket(state, method) {
|
|
82
|
+
const interval = this.intervalForMethod(method);
|
|
83
|
+
const now = Date.now();
|
|
84
|
+
const earliest = Math.max(state.queuedUntilMs, state.lastRunAtMs + interval);
|
|
85
|
+
if (earliest > now) {
|
|
86
|
+
await delay(earliest - now);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async runWithRetries(operation) {
|
|
90
|
+
let attempt = 0;
|
|
91
|
+
while (true) {
|
|
92
|
+
try {
|
|
93
|
+
return await operation();
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
const retryAfterSeconds = getRetryAfterSeconds(error);
|
|
97
|
+
if (retryAfterSeconds === undefined || attempt >= this.options.maxRetries) {
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
attempt += 1;
|
|
101
|
+
this.retries += 1;
|
|
102
|
+
this.rateLimitHits += 1;
|
|
103
|
+
this.lastRateLimitAt = new Date().toISOString();
|
|
104
|
+
this.lastRetryAfterSeconds = retryAfterSeconds;
|
|
105
|
+
await delay((retryAfterSeconds * 1000) + 250);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
intervalForMethod(method) {
|
|
110
|
+
if (method.startsWith("edit"))
|
|
111
|
+
return this.options.editMinIntervalMs;
|
|
112
|
+
if (method.startsWith("typing"))
|
|
113
|
+
return this.options.typingMinIntervalMs;
|
|
114
|
+
return this.options.minIntervalMs;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
export const slackRateLimiter = new SlackRateLimiter();
|
|
118
|
+
export function getSlackRateLimitMetrics() {
|
|
119
|
+
return slackRateLimiter.getMetrics();
|
|
120
|
+
}
|
|
121
|
+
function getRetryAfterSeconds(error) {
|
|
122
|
+
if (!error || typeof error !== "object") {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const candidate = error;
|
|
126
|
+
if (typeof candidate.retryAfter === "number") {
|
|
127
|
+
return candidate.retryAfter;
|
|
128
|
+
}
|
|
129
|
+
if (typeof candidate.data?.retryAfter === "number") {
|
|
130
|
+
return candidate.data.retryAfter;
|
|
131
|
+
}
|
|
132
|
+
const header = candidate.headers?.["retry-after"];
|
|
133
|
+
const raw = Array.isArray(header) ? header[0] : header;
|
|
134
|
+
const parsed = Number(raw);
|
|
135
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
136
|
+
}
|
|
137
|
+
function delay(ms) {
|
|
138
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
139
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
|
|
2
|
+
const PASSWORD_KEYLEN = 64;
|
|
3
|
+
export function sleepSync(ms) {
|
|
4
|
+
const end = Date.now() + ms;
|
|
5
|
+
while (Date.now() < end) {
|
|
6
|
+
// The lock is only held around tiny JSON mutations; a short spin keeps the implementation dependency-free.
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export function hashPassword(password) {
|
|
10
|
+
if (password.length < 8) {
|
|
11
|
+
throw new Error("Password must be at least 8 characters.");
|
|
12
|
+
}
|
|
13
|
+
const salt = randomBytes(16).toString("hex");
|
|
14
|
+
const hash = scryptSync(password, salt, PASSWORD_KEYLEN).toString("hex");
|
|
15
|
+
return { salt, hash };
|
|
16
|
+
}
|
|
17
|
+
export function verifyPasswordHash(password, salt, expectedHash) {
|
|
18
|
+
const actual = scryptSync(password, salt, PASSWORD_KEYLEN);
|
|
19
|
+
const expected = Buffer.from(expectedHash, "hex");
|
|
20
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected);
|
|
21
|
+
}
|
|
22
|
+
export function hashToken(token) {
|
|
23
|
+
return createHash("sha256").update(token).digest("hex");
|
|
24
|
+
}
|
|
25
|
+
export function constantTimeStringEqual(left, right) {
|
|
26
|
+
const leftBuffer = Buffer.from(left);
|
|
27
|
+
const rightBuffer = Buffer.from(right);
|
|
28
|
+
return leftBuffer.length === rightBuffer.length && timingSafeEqual(leftBuffer, rightBuffer);
|
|
29
|
+
}
|
|
30
|
+
export function randomId() {
|
|
31
|
+
return randomUUID().replace(/-/g, "").slice(0, 12);
|
|
32
|
+
}
|
|
33
|
+
export function randomLinkCode() {
|
|
34
|
+
return `NR-${randomBytes(4).toString("hex").toUpperCase()}`;
|
|
35
|
+
}
|
|
36
|
+
export function randomSessionToken() {
|
|
37
|
+
return randomBytes(32).toString("hex");
|
|
38
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { ADMIN_GROUP_ID, BUILTIN_GROUPS, READONLY_GROUP_ID, isPermission, } from "./access-control.js";
|
|
3
|
+
export function normalizePayload(payload) {
|
|
4
|
+
const now = new Date().toISOString();
|
|
5
|
+
const groupsById = new Map();
|
|
6
|
+
for (const group of BUILTIN_GROUPS) {
|
|
7
|
+
groupsById.set(group.id, {
|
|
8
|
+
...group,
|
|
9
|
+
permissions: group.id === ADMIN_GROUP_ID ? allPermissionsSafe() : group.permissions,
|
|
10
|
+
agentIds: [],
|
|
11
|
+
workspaceRoots: [],
|
|
12
|
+
telegramChatIds: [],
|
|
13
|
+
discordChannelIds: [],
|
|
14
|
+
slackChannelIds: [],
|
|
15
|
+
createdAt: now,
|
|
16
|
+
updatedAt: now,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
for (const group of payload?.groups ?? []) {
|
|
20
|
+
if (!isGroupRecord(group))
|
|
21
|
+
continue;
|
|
22
|
+
groupsById.set(group.id, {
|
|
23
|
+
...group,
|
|
24
|
+
permissions: group.id === ADMIN_GROUP_ID ? allPermissionsSafe() : normalizePermissions(group.permissions),
|
|
25
|
+
system: BUILTIN_GROUPS.some((builtin) => builtin.id === group.id) || group.system,
|
|
26
|
+
agentIds: normalizeStringList(group.agentIds),
|
|
27
|
+
workspaceRoots: normalizeStringList(group.workspaceRoots),
|
|
28
|
+
telegramChatIds: normalizeNumberList(group.telegramChatIds),
|
|
29
|
+
discordChannelIds: normalizeStringList(group.discordChannelIds),
|
|
30
|
+
slackChannelIds: normalizeStringList(group.slackChannelIds),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
const groups = Array.from(groupsById.values());
|
|
34
|
+
const groupIds = new Set(groups.map((group) => group.id));
|
|
35
|
+
const users = (payload?.users ?? []).filter(isUserRecord);
|
|
36
|
+
const userIds = new Set(users.map((user) => user.id));
|
|
37
|
+
return {
|
|
38
|
+
version: 1,
|
|
39
|
+
users,
|
|
40
|
+
groups,
|
|
41
|
+
userGroups: (payload?.userGroups ?? []).filter((item) => isUserGroupRecord(item) && userIds.has(item.userId) && groupIds.has(item.groupId)),
|
|
42
|
+
telegramIdentities: (payload?.telegramIdentities ?? []).filter((item) => isTelegramIdentityRecord(item) && userIds.has(item.userId)),
|
|
43
|
+
telegramChats: (payload?.telegramChats ?? []).filter(isTelegramChatAccessRecord).map((chat) => ({
|
|
44
|
+
...chat,
|
|
45
|
+
allowedGroupIds: chat.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
|
|
46
|
+
})),
|
|
47
|
+
discordIdentities: (payload?.discordIdentities ?? []).filter((item) => isDiscordIdentityRecord(item) && userIds.has(item.userId)),
|
|
48
|
+
discordChannels: (payload?.discordChannels ?? []).filter(isDiscordChannelAccessRecord).map((channel) => ({
|
|
49
|
+
...channel,
|
|
50
|
+
allowedGroupIds: channel.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
|
|
51
|
+
})),
|
|
52
|
+
slackIdentities: (payload?.slackIdentities ?? []).filter((item) => isSlackIdentityRecord(item) && userIds.has(item.userId)),
|
|
53
|
+
slackChannels: (payload?.slackChannels ?? []).filter(isSlackChannelAccessRecord).map((channel) => ({
|
|
54
|
+
...channel,
|
|
55
|
+
allowedGroupIds: channel.allowedGroupIds.filter((groupId) => groupIds.has(groupId)),
|
|
56
|
+
})),
|
|
57
|
+
webSessions: (payload?.webSessions ?? []).filter((item) => isWebSessionRecord(item) && userIds.has(item.userId)),
|
|
58
|
+
telegramLinkCodes: (payload?.telegramLinkCodes ?? []).filter((item) => isTelegramLinkCodeRecord(item) && userIds.has(item.userId)),
|
|
59
|
+
discordLinkCodes: (payload?.discordLinkCodes ?? []).filter((item) => isDiscordLinkCodeRecord(item) && userIds.has(item.userId)),
|
|
60
|
+
slackLinkCodes: (payload?.slackLinkCodes ?? []).filter((item) => isSlackLinkCodeRecord(item) && userIds.has(item.userId)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
export function normalizeEmail(email) {
|
|
64
|
+
return email.trim().toLowerCase();
|
|
65
|
+
}
|
|
66
|
+
export function normalizeGroupIds(payload, values, emptyFallback = READONLY_GROUP_ID) {
|
|
67
|
+
const available = new Set(payload.groups.map((group) => group.id));
|
|
68
|
+
const groupIds = Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)));
|
|
69
|
+
for (const groupId of groupIds) {
|
|
70
|
+
if (!available.has(groupId)) {
|
|
71
|
+
throw new Error(`Unknown group: ${groupId}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return groupIds.length > 0 ? groupIds : (emptyFallback ? [emptyFallback] : []);
|
|
75
|
+
}
|
|
76
|
+
export function normalizePermissions(values, strict = false) {
|
|
77
|
+
const permissions = [];
|
|
78
|
+
for (const value of values ?? []) {
|
|
79
|
+
if (isPermission(value)) {
|
|
80
|
+
if (!permissions.includes(value)) {
|
|
81
|
+
permissions.push(value);
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (strict && value.trim()) {
|
|
86
|
+
throw new Error(`Unknown permission: ${value}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return permissions;
|
|
90
|
+
}
|
|
91
|
+
export function normalizeStringList(values) {
|
|
92
|
+
return Array.from(new Set((values ?? []).map((value) => value.trim()).filter(Boolean)));
|
|
93
|
+
}
|
|
94
|
+
export function normalizeNumberList(values) {
|
|
95
|
+
return Array.from(new Set((values ?? []).filter((value) => Number.isInteger(value))));
|
|
96
|
+
}
|
|
97
|
+
export function normalizeDiscordId(value) {
|
|
98
|
+
const normalized = String(value ?? "").trim();
|
|
99
|
+
return normalized || undefined;
|
|
100
|
+
}
|
|
101
|
+
export function normalizeSlackId(value) {
|
|
102
|
+
const normalized = String(value ?? "").trim();
|
|
103
|
+
return normalized || undefined;
|
|
104
|
+
}
|
|
105
|
+
export function assertActiveAdminExists(payload) {
|
|
106
|
+
const hasAdmin = payload.users.some((user) => user.active && payload.userGroups.some((item) => item.userId === user.id && item.groupId === ADMIN_GROUP_ID));
|
|
107
|
+
if (!hasAdmin) {
|
|
108
|
+
throw new Error("Cannot remove or disable the last active admin user.");
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
export function normalizeWorkspacePath(value) {
|
|
112
|
+
return path.resolve(value);
|
|
113
|
+
}
|
|
114
|
+
export function isPathInside(candidate, root) {
|
|
115
|
+
const relative = path.relative(root, candidate);
|
|
116
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
117
|
+
}
|
|
118
|
+
export function slugify(value) {
|
|
119
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
120
|
+
}
|
|
121
|
+
export function allPermissionsSafe() {
|
|
122
|
+
return [...BUILTIN_GROUPS.find((group) => group.id === ADMIN_GROUP_ID).permissions];
|
|
123
|
+
}
|
|
124
|
+
function isUserRecord(value) {
|
|
125
|
+
const candidate = value;
|
|
126
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.email === "string" &&
|
|
127
|
+
typeof candidate.displayName === "string" && typeof candidate.passwordHash === "string" &&
|
|
128
|
+
typeof candidate.passwordSalt === "string" && typeof candidate.active === "boolean";
|
|
129
|
+
}
|
|
130
|
+
function isGroupRecord(value) {
|
|
131
|
+
const candidate = value;
|
|
132
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.name === "string" &&
|
|
133
|
+
Array.isArray(candidate.permissions);
|
|
134
|
+
}
|
|
135
|
+
function isUserGroupRecord(value) {
|
|
136
|
+
const candidate = value;
|
|
137
|
+
return Boolean(candidate) && typeof candidate.userId === "string" && typeof candidate.groupId === "string";
|
|
138
|
+
}
|
|
139
|
+
function isTelegramIdentityRecord(value) {
|
|
140
|
+
const candidate = value;
|
|
141
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
142
|
+
Number.isInteger(candidate.telegramUserId) && typeof candidate.active === "boolean";
|
|
143
|
+
}
|
|
144
|
+
function isTelegramChatAccessRecord(value) {
|
|
145
|
+
const candidate = value;
|
|
146
|
+
return Boolean(candidate) && typeof candidate.id === "string" && Number.isInteger(candidate.chatId) &&
|
|
147
|
+
typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
|
|
148
|
+
}
|
|
149
|
+
function isDiscordIdentityRecord(value) {
|
|
150
|
+
const candidate = value;
|
|
151
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
152
|
+
typeof candidate.discordUserId === "string" && typeof candidate.active === "boolean";
|
|
153
|
+
}
|
|
154
|
+
function isDiscordChannelAccessRecord(value) {
|
|
155
|
+
const candidate = value;
|
|
156
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.channelId === "string" &&
|
|
157
|
+
typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
|
|
158
|
+
}
|
|
159
|
+
function isSlackIdentityRecord(value) {
|
|
160
|
+
const candidate = value;
|
|
161
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
162
|
+
typeof candidate.slackUserId === "string" && typeof candidate.active === "boolean";
|
|
163
|
+
}
|
|
164
|
+
function isSlackChannelAccessRecord(value) {
|
|
165
|
+
const candidate = value;
|
|
166
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.channelId === "string" &&
|
|
167
|
+
typeof candidate.enabled === "boolean" && Array.isArray(candidate.allowedGroupIds);
|
|
168
|
+
}
|
|
169
|
+
function isWebSessionRecord(value) {
|
|
170
|
+
const candidate = value;
|
|
171
|
+
return Boolean(candidate) && typeof candidate.id === "string" && typeof candidate.userId === "string" &&
|
|
172
|
+
typeof candidate.tokenHash === "string" && typeof candidate.expiresAt === "string";
|
|
173
|
+
}
|
|
174
|
+
function isTelegramLinkCodeRecord(value) {
|
|
175
|
+
const candidate = value;
|
|
176
|
+
return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
|
|
177
|
+
typeof candidate.expiresAt === "string";
|
|
178
|
+
}
|
|
179
|
+
function isDiscordLinkCodeRecord(value) {
|
|
180
|
+
const candidate = value;
|
|
181
|
+
return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
|
|
182
|
+
typeof candidate.expiresAt === "string";
|
|
183
|
+
}
|
|
184
|
+
function isSlackLinkCodeRecord(value) {
|
|
185
|
+
const candidate = value;
|
|
186
|
+
return Boolean(candidate) && typeof candidate.code === "string" && typeof candidate.userId === "string" &&
|
|
187
|
+
typeof candidate.expiresAt === "string";
|
|
188
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|