Атрибуция от бота в MCP/REST + REST API по ключу (этап 2)

- MCP: getActor (владелец ключа + бот), аудит changedByName=бот/label ключа, botId, metadata.source='mcp'; аудит для update_task/update_task_status
- sendTaskMessage: botId (messageType 'bot'), починен путь isBotToken без req.user
- authenticateTokenOrApiKey: JWT или X-Api-Key с белым списком endpoint'ов (/api/upload, messages, field-values, create task), проверки скоупов, аудит source='api'
- Лимиты файлов через env: UPLOAD_IMAGE_MAX_MB=25, UPLOAD_DOC_MAX_MB=100, UPLOAD_MAX_MB=100
- MCP upload-инструменты: параметр fileUrl для привязки уже загруженного файла; новый инструмент get_api_guide
This commit is contained in:
2026-07-22 15:18:04 +03:00
parent 68153caef2
commit 8cfd49fd9f
13 changed files with 720 additions and 246 deletions

View File

@@ -130,3 +130,15 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
# DOC_WORKER_URL=http://document-worker:3000
# For local development without Docker, you can point to an external service:
# DOC_WORKER_URL=http://localhost:3000
# =============================================
# File upload limits (optional, in megabytes)
# =============================================
# Per-type limits (extension-based, enforced after upload):
# UPLOAD_IMAGE_MAX_MB=25
# UPLOAD_DOC_MAX_MB=100
# Hard cap for the multipart parser (multer fileSize):
# UPLOAD_MAX_MB=100
# Bot login token TTL (access / refresh) — см. этап bot API keys:
# JWT_BOT_LOGIN_EXPIRES=30d
# JWT_BOT_LOGIN_REFRESH_EXPIRES=90d

View File

@@ -33,8 +33,8 @@ async function _notifyAdminsLegacyKeyMcp(organizationId: number, keyPrefix: stri
}
}
import type { Request, Response } from "express";
import type { Task, ApiKeyScopes } from "@shared/schema";
import { normalizeApiKeyScopes } from "./utils/api-key";
import type { Task, ApiKeyScopes, OrganizationApiKey, User, Bot } from "@shared/schema";
import { normalizeApiKeyScopes, isFormAllowedByScopes } from "./utils/api-key";
import beautify from "js-beautify";
import {
semanticSearch,
@@ -147,6 +147,7 @@ const READ_TOOLS: readonly string[] = [
'get_task_tree',
'get_user_field_values',
'dadata_suggest',
'get_api_guide',
];
// WRITE_EXTRA_TOOLS — дополнительно доступны в режимах write и full: создание данных.
@@ -171,10 +172,12 @@ const WRITE_EXTRA_TOOLS: readonly string[] = [
// Все остальные инструменты (изменение/удаление форм, задач, пользователей,
// автоматизаций, таб-модулей, страниц, переиндексация) — только режим full.
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа.
// Результат разрешения API-ключа: организация + нормализованные скоупы доступа
// + полная запись ключа (botId, createdBy, label) для атрибуции изменений.
export interface ResolvedApiKey {
organizationId: number;
scopes: ApiKeyScopes;
key: OrganizationApiKey;
}
// Разрешает API-ключ из запроса: возвращает organizationId и нормализованные скоупы
@@ -199,7 +202,7 @@ async function resolveApiKey(req: Request): Promise<ResolvedApiKey | null> {
}
if (!apiKey.isActive) return null;
storage.touchApiKey(apiKey.id).catch(() => {});
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes) };
return { organizationId: apiKey.organizationId, scopes: normalizeApiKeyScopes(apiKey.scopes), key: apiKey };
}
function taskToJson(t: Task) {
@@ -216,7 +219,7 @@ function taskToJson(t: Task) {
};
}
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer {
function buildMcpServer(organizationId: number, scopes: ApiKeyScopes, apiKeyRecord: OrganizationApiKey | null): McpServer {
const server = new McpServer({ name: "iistwin-mcp", version: "1.0.0" });
// ── Фильтрация инструментов по режиму ключа (scopes.mode) ─────────────────
@@ -239,12 +242,107 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
}) as typeof server.registerTool;
// ── Объектный доступ по scopes.formIds ────────────────────────────────────
const isFormAllowed = (formId: number) => scopes.formIds === null || scopes.formIds.includes(formId);
const isFormAllowed = (formId: number) => isFormAllowedByScopes(scopes, formId);
const formDenied = (formId: number) => ({
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Доступ к форме ${formId} запрещён правами API-ключа` }) }],
isError: true,
});
// ── Актор изменений по API-ключу ───────────────────────────────────────────
// user — владелец ключа (fallback: первый админ) для notNull FK-колонок;
// bot — бот ключа (если привязан); displayName — имя для аудита
// (имя бота или «Ключ "label"», чтобы в истории было видно, что это не человек).
interface McpActor {
user: User;
bot: Bot | null;
displayName: string;
}
let actorPromise: Promise<McpActor> | null = null;
const getActor = (): Promise<McpActor> => {
if (!actorPromise) {
actorPromise = (async () => {
let user: User | null | undefined = apiKeyRecord?.createdBy
? await storage.getUser(apiKeyRecord.createdBy).catch(() => null)
: null;
if (!user || user.organizationId !== organizationId) {
const orgUsers = await storage.getUsersByOrganization(organizationId);
user = orgUsers.find((u) => u.appRole === 'admin') ?? orgUsers[0] ?? null;
}
if (!user) throw new Error('В организации нет пользователей');
const bot = apiKeyRecord?.botId
? await storage.getBot(apiKeyRecord.botId, organizationId).catch(() => null) ?? null
: null;
const displayName = bot?.name
?? (apiKeyRecord ? `Ключ «${apiKeyRecord.label}»` : null)
?? (`${user.firstName || ''} ${user.lastName || ''}`.trim() || user.email || 'MCP');
return { user, bot, displayName };
})();
}
return actorPromise;
};
// Поля аудит-записи от актора: для бота changedBy=null и botId выставлен,
// канал фиксируется в metadata.source='mcp'.
const actorAudit = (actor: McpActor) => ({
changedBy: actor.bot ? null : actor.user.id,
changedByName: actor.displayName,
botId: actor.bot?.id ?? null,
});
// Источник файла для upload-инструментов: base64 (загрузка в хранилище)
// или fileUrl (уже загруженный файл — только привязка, без повторной загрузки).
// Ровно один источник обязателен.
const resolveUploadSource = async (args: {
fileName?: string;
contentBase64?: string;
fileUrl?: string;
fileSize?: number;
mimeType?: string;
taskId?: number | null;
fieldId?: number | null;
}): Promise<
| { error: string }
| { actor: McpActor; file: { key: string; url: string; name: string; size: number; mimeType: string } }
> => {
const hasBase64 = !!args.contentBase64;
const hasUrl = !!args.fileUrl;
if (hasBase64 === hasUrl) {
return { error: 'Укажите ровно один источник файла: contentBase64 или fileUrl' };
}
const actor = await getActor();
if (hasUrl) {
const url = args.fileUrl!;
if (!url.startsWith('/api/files/') && !url.startsWith('/uploads/')) {
return { error: 'fileUrl должен начинаться с /api/files/ или /uploads/' };
}
const name = args.fileName || url.split('/').pop() || 'file';
return {
actor,
file: {
key: url.replace(/^\/api\/files\/|^\/uploads\//, ''),
url,
name,
size: args.fileSize ?? 0,
mimeType: args.mimeType ?? 'application/octet-stream',
},
};
}
if (!args.fileName) {
return { error: 'fileName обязателен при загрузке через contentBase64' };
}
const file = await uploadFileFromBase64({
organizationId,
userId: actor.user.id,
fileName: args.fileName,
contentBase64: args.contentBase64!,
mimeType: args.mimeType,
taskId: args.taskId,
fieldId: args.fieldId,
botId: actor.bot?.id ?? null,
});
return { actor, file };
};
// ── Объектный доступ по scopes.tableIds (справочники) ─────────────────────
const isTableAllowed = (tableId: number) => scopes.tableIds === null || scopes.tableIds.includes(tableId);
const tableDenied = (tableId: number) => ({
@@ -494,11 +592,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
}
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) {
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
}
const actor = await getActor();
let parsedDueDate: Date | null = null;
if (due_date) {
@@ -513,7 +607,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
title,
formId: form_id,
organizationId,
createdBy: adminUser.id,
createdBy: actor.user.id,
assignedTo: assigned_to ?? null,
currentStatusId: resolvedStatusId,
description: description ?? null,
@@ -523,14 +617,12 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
parentTaskId: null,
});
const creatorName = `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP";
storage.addTaskAuditLog({
taskId: task.id,
organizationId,
action: "task.created",
changedBy: adminUser.id,
changedByName: creatorName,
metadata: { title: task.title },
...actorAudit(actor),
metadata: { title: task.title, source: 'mcp' },
}).catch((e: unknown) => { console.error("Audit log error (MCP create_task):", e); });
if (field_values && typeof field_values === "object") {
@@ -593,6 +685,23 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
isCompleted: isFinalStatus,
completedAt: isFinalStatus ? (task.completedAt ?? new Date()) : null,
});
// Аудит смены статуса с актором ключа (как REST: action 'status.changed')
const oldStatus = statuses.find((s) => s.id === task.currentStatusId);
if (task.currentStatusId !== status_id) {
const actor = await getActor();
storage.addTaskAuditLog({
taskId: task_id,
organizationId,
action: 'status.changed',
fieldName: 'Статус',
oldValue: oldStatus?.name ?? String(task.currentStatusId),
newValue: validStatus.name,
...actorAudit(actor),
metadata: { source: 'mcp' },
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task_status):", e); });
}
return {
content: [
{
@@ -655,6 +764,49 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
}
const updated = await storage.updateTask(task_id, organizationId, updates);
// Аудит изменённых полей с актором ключа (как REST PUT /api/tasks/:id)
{
const actor = await getActor();
const resolveUserName = async (userId: number | null | undefined): Promise<string | null> => {
if (!userId) return null;
const u = await storage.getUser(userId).catch(() => null);
return u ? (`${u.firstName || ''} ${u.lastName || ''}`.trim() || u.email) : String(userId);
};
const trackedFields: Array<{ key: 'title' | 'description' | 'assignedTo' | 'dueDate'; label: string }> = [
{ key: 'title', label: 'Заголовок' },
{ key: 'description', label: 'Описание' },
{ key: 'assignedTo', label: 'Исполнитель' },
{ key: 'dueDate', label: 'Срок' },
];
for (const { key, label } of trackedFields) {
if (!(key in updates)) continue;
const oldVal = task[key];
const newVal = updates[key];
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = newVal === null || newVal === undefined ? '' : String(newVal);
if (oldStr === newStr) continue;
let auditOldValue: string | null = oldVal === null || oldVal === undefined ? null : String(oldVal);
let auditNewValue: string | null = newVal === null || newVal === undefined ? null : String(newVal);
if (key === 'assignedTo') {
[auditOldValue, auditNewValue] = await Promise.all([
resolveUserName(oldVal as number | null),
resolveUserName(newVal as number | null),
]);
}
storage.addTaskAuditLog({
taskId: task_id,
organizationId,
action: 'task.updated',
fieldName: label,
oldValue: auditOldValue,
newValue: auditNewValue,
...actorAudit(actor),
metadata: { source: 'mcp' },
}).catch((e: unknown) => { console.error("Audit log error (MCP update_task):", e); });
}
}
return {
content: [
{
@@ -823,17 +975,13 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
},
},
async ({ name, description }) => {
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) {
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
}
const actor = await getActor();
const form = await storage.createForm({
organizationId,
name,
description: description ?? null,
createdBy: adminUser.id,
createdBy: actor.user.id,
isActive: true,
chatEnabled: true,
chatLayout: "default",
@@ -1103,11 +1251,7 @@ function buildMcpServer(organizationId: number, scopes: ApiKeyScopes): McpServer
if (existing) {
return { content: [{ type: "text" as const, text: `A module with type "${type}" already exists (id=${existing.id})` }], isError: true };
}
const adminUsers = await storage.getUsersByOrganization(organizationId);
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
if (!createdBy) {
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
}
const createdBy = (await getActor()).user.id;
const mod = await storage.createCustomTabModule({
type,
label,
@@ -1179,11 +1323,7 @@ RULES for tab component code:
isError: true,
};
}
const adminUsers = await storage.getUsersByOrganization(organizationId);
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
if (!createdBy) {
return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
}
const createdBy = (await getActor()).user.id;
const mod = await storage.createCustomTabModule({
type,
label,
@@ -1560,11 +1700,7 @@ MANDATORY: Call get_js_coding_reference BEFORE writing any code. Critical rules:
if (existing) {
return { content: [{ type: "text" as const, text: `A page with slug "${slug}" already exists` }], isError: true };
}
const adminUsers = await storage.getUsersByOrganization(organizationId);
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
if (!createdBy) {
return { content: [{ type: "text" as const, text: "No admin user found to assign createdBy" }], isError: true };
}
const createdBy = (await getActor()).user.id;
const createWarnings = validatePageCode(code);
const formattedCode = formatPageCode(code);
@@ -2112,10 +2248,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
},
},
async ({ name, code, trigger, description, trigger_config, is_active, run_offline, client_compatible }) => {
const users = await storage.getUsersByOrganization(organizationId);
const adminUser = users.find(u => u.appRole === 'admin');
const createdBy = adminUser?.id;
if (!createdBy) return { content: [{ type: "text" as const, text: "No admin user found" }], isError: true };
const createdBy = (await getActor()).user.id;
const item = await storage.createAutomation({
organizationId,
name,
@@ -2596,11 +2729,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
};
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) {
return { content: [{ type: "text" as const, text: "Organization has no users" }], isError: true };
}
const actor = await getActor();
const field = await storage.createFieldTemplate({
code,
@@ -2613,7 +2742,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
defaultValue: default_value ?? null,
validationRules: null,
organizationId,
createdBy: adminUser.id,
createdBy: actor.user.id,
});
return {
@@ -2826,24 +2955,16 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
resolvedTabId = found.id;
}
const adminUsers = await storage.getUsersByOrganization(organizationId);
const createdBy = adminUsers.find((u) => u.appRole === "admin")?.id ?? adminUsers[0]?.id;
if (!createdBy) {
return { content: [{ type: "text" as const, text: "No user found to assign createdBy" }], isError: true };
}
const actor = await getActor();
const row = await storage.createRegularTableRow({
taskId: task_id,
tabId: resolvedTabId,
data: data ?? {},
createdBy,
createdBy: actor.user.id,
});
const tab = await storage.getFormTab(resolvedTabId, task.formId, organizationId);
const adminUser = adminUsers.find((u) => u.id === createdBy);
const editorName = adminUser
? `${adminUser.firstName || ""} ${adminUser.middleName || ""} ${adminUser.lastName || ""}`.trim() || adminUser.email || "MCP"
: "MCP";
const persistedData = row.data && typeof row.data === "object" && Object.keys(row.data).length > 0 ? row.data : null;
storage.addTaskAuditLog({
taskId: task_id,
@@ -2852,8 +2973,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
fieldName: `Таблица «${tab?.name || resolvedTabId}»: добавлена строка`,
oldValue: null,
newValue: persistedData,
changedBy: createdBy,
changedByName: editorName,
...actorAudit(actor),
metadata: { source: 'mcp' },
}).catch((e: unknown) => { console.error("Audit log error (MCP append_table_row):", e); });
return {
@@ -3320,18 +3441,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
},
},
async ({ name, description, columns }) => {
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) {
return directoryError("В организации нет пользователей для назначения createdBy");
}
const actor = await getActor();
const table = await storage.createDataTable({
name,
description: description ?? null,
columns: columns.map((c) => ({ ...c, type: c.type ?? 'text' })),
organizationId,
createdBy: adminUser.id,
createdBy: actor.user.id,
});
return {
content: [{
@@ -3640,14 +3757,13 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
if (!content.trim()) return mcpError("Текст сообщения обязателен");
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей для авторства сообщения");
const actor = await getActor();
try {
const created = await sendTaskMessage({
task,
user: adminUser,
user: actor.bot ? undefined : actor.user,
botId: actor.bot?.id ?? null,
organizationId,
message: content,
});
@@ -3737,7 +3853,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
return mcpError(`Пользователи не найдены в организации: ${invalid.join(', ')}`);
}
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
const actor = await getActor();
// Полная замена списка: удаляем лишних, добавляем недостающих
const current = await storage.getTaskAssignees(taskId, organizationId);
@@ -3755,11 +3871,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
const newAssignedTo = uniqueIds[0] ?? null;
await storage.updateTask(taskId, organizationId, { assignedTo: newAssignedTo });
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser?.id ?? null });
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
const editorName = adminUser
? (`${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP')
: 'MCP';
const names = uniqueIds
.map((id) => {
const u = orgUsers.find((x) => x.id === id);
@@ -3773,15 +3886,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
fieldName: 'Ответственные обновлены',
oldValue: null,
newValue: names || '(пусто)',
changedBy: adminUser?.id ?? null,
changedByName: editorName,
...actorAudit(actor),
metadata: { source: 'mcp' },
}).catch((e: unknown) => { console.error('Audit log error (MCP set_task_assignees):', e); });
const refreshedTask = await storage.getTask(taskId, organizationId);
// Уведомление новому основному ответственному (если сменился)
if (refreshedTask && newAssignedTo && newAssignedTo !== task.assignedTo) {
notifyTaskAssigned(refreshedTask, newAssignedTo, adminUser?.id ?? null, organizationId)
notifyTaskAssigned(refreshedTask, newAssignedTo, actor.user.id, organizationId)
.catch((err) => console.error('[MCP set_task_assignees] notifyTaskAssigned error:', err));
}
@@ -3868,9 +3981,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей для генерации документа");
const actor = await getActor();
// Сервис генерации собирается так же, как в server/documents/routes.ts
const templateService = new DocumentTemplateService();
@@ -3883,7 +3994,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
templateId,
taskId,
organizationId,
userId: adminUser.id,
userId: actor.user.id,
outputFormat: format,
});
return {
@@ -4094,13 +4205,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
if (!recipient) {
return mcpError(`Пользователь ${userId} не принадлежит организации`);
}
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
const actor = await getActor();
const reminder = await storage.createTaskReminder({
taskId,
organizationId,
createdByUserId: adminUser.id,
createdByUserId: actor.user.id,
remindAt: remindAtDate,
note: message ?? null,
recipients: [{ type: "user", userId }],
@@ -4740,16 +4850,15 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
},
},
async ({ fileName, contentBase64, mimeType }) => {
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
const actor = await getActor();
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
userId: actor.user.id,
fileName,
contentBase64,
mimeType,
botId: actor.bot?.id ?? null,
});
return {
content: [{
@@ -4771,18 +4880,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
{
title: "Upload Task Field File",
description:
"Upload a file (base64) and APPEND it to a file-type form field of a task. " +
"Upload a file and APPEND it to a file-type form field of a task. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file, e.g. via POST /api/upload — binding only). " +
"File fields are multiple: the value is an array of {url, name, size}. " +
"Field maxFileCount/maxFileSizeMB limits are enforced. Returns the updated field value.",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
fieldId: z.number().int().describe("The numeric ID of the file-type form field"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64; for fileUrl defaults to the URL basename)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ taskId, fieldId, fileName, contentBase64, mimeType }) => {
async ({ taskId, fieldId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
@@ -4794,20 +4906,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
return mcpError(`Поле ${fieldId} имеет тип «${field.type}», а не file`);
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
fieldId,
});
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId, fieldId });
if ('error' in source) return mcpError(source.error);
const { actor, file } = source;
// File-поля множественные: значение = массив {url, name, size} — добавляем файл
const existingValues = await storage.getTaskFieldValues(taskId, organizationId);
@@ -4832,7 +4934,6 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
await storage.createTaskFieldValue({ taskId, fieldId, formId: task.formId, value: newFiles });
}
const editorName = `${adminUser.firstName || ''} ${adminUser.middleName || ''} ${adminUser.lastName || ''}`.trim() || adminUser.email || 'MCP';
storage.addTaskAuditLog({
taskId,
organizationId,
@@ -4841,12 +4942,12 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
fieldName: field.name,
oldValue: existingValue?.value ?? null,
newValue: newFiles,
changedBy: adminUser.id,
changedByName: editorName,
...actorAudit(actor),
metadata: { source: 'mcp' },
}).catch((e: unknown) => { console.error('Audit log error (MCP upload_task_file):', e); });
tasksMinimalCache.invalidatePrefix(`tasks:${organizationId}:minimal:`);
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: adminUser.id });
const autoResult = await evaluateAutoTransitions(taskId, organizationId, { triggeredBy: actor.user.id });
indexTaskAsync(taskId, organizationId).catch(() => {});
const freshTask = await storage.getTask(taskId, organizationId);
eventBus.publishEvent({
@@ -4879,39 +4980,34 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
{
title: "Upload Message Attachment",
description:
"Upload a file (base64) and post it as a task comment attachment. " +
"Upload a file and post it as a task comment attachment. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
"If content is omitted, the message text is generated as '📎 <file name>'. " +
"Triggers the same side effects as send_task_message (notifications, SSE, webhooks).",
inputSchema: {
taskId: z.number().int().describe("The numeric ID of the task"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
content: z.string().optional().describe("Comment text (optional; default '📎 <file name>')"),
},
},
async ({ taskId, fileName, contentBase64, mimeType, content }) => {
async ({ taskId, fileName, contentBase64, fileUrl, fileSize, mimeType, content }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
});
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
if ('error' in source) return mcpError(source.error);
const { actor, file } = source;
const created = await sendTaskMessage({
task,
user: adminUser,
user: actor.bot ? undefined : actor.user,
botId: actor.bot?.id ?? null,
organizationId,
message: content?.trim() || `📎 ${file.name}`,
attachments: [{ url: file.url, name: file.name, size: file.size, mimeType: file.mimeType }],
@@ -4942,18 +5038,21 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
{
title: "Upload Directory Cell File",
description:
"Upload a file (base64) and write a link into a directory row cell. " +
"Upload a file and write a link into a directory row cell. " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
"Directory columns have no file type, so the cell gets a markdown link: [file name](url).",
inputSchema: {
tableId: z.number().int().describe("The numeric ID of the directory (data table)"),
rowId: z.number().int().describe("The numeric ID of the row"),
columnIndex: z.number().int().min(0).describe("Column index (0-based)"),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ tableId, rowId, columnIndex, fileName, contentBase64, mimeType }) => {
async ({ tableId, rowId, columnIndex, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
if (!isTableAllowed(tableId)) return tableDenied(tableId);
const table = await storage.getDataTable(tableId, organizationId);
if (!table) return mcpError(`Справочник ${tableId} не найден`);
@@ -4964,18 +5063,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
if (!row) return mcpError(`Строка ${rowId} не найдена в справочнике ${tableId}`);
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
});
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType });
if ('error' in source) return mcpError(source.error);
const { file } = source;
// У колонок справочника нет file-типа: в ячейку пишем markdown-ссылку [имя](url)
const values = Array.isArray(row.values) ? [...row.values] : [];
@@ -5007,7 +5098,8 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
{
title: "Upload Table Tab Cell File",
description:
"Upload a file (base64) and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
"Upload a file and write a link into a cell of a task's 'table' tab (regular_table_rows). " +
"Source: contentBase64 (upload) OR fileUrl (already uploaded file — binding only). " +
"The cell gets a markdown link: [file name](url). " +
"If rowId is omitted, a new row is created (like append_table_row). Use get_form_tabs to discover tab and column IDs.",
inputSchema: {
@@ -5015,12 +5107,14 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
tabId: z.number().int().describe("The numeric ID of the table tab"),
columnId: z.string().min(1).describe("Column ID (e.g. 'gf_12', 'ft_34')"),
rowId: z.number().int().optional().describe("Row ID to update. Omit to create a new row."),
fileName: z.string().min(1).describe("Original file name with extension"),
contentBase64: z.string().min(1).describe("File content as base64 (data-URL prefix allowed)"),
fileName: z.string().min(1).optional().describe("Original file name with extension (required for contentBase64)"),
contentBase64: z.string().min(1).optional().describe("File content as base64 (data-URL prefix allowed). Mutually exclusive with fileUrl."),
fileUrl: z.string().optional().describe("URL of an already uploaded file (/api/files/... or /uploads/...). Mutually exclusive with contentBase64."),
fileSize: z.number().int().min(0).optional().describe("File size in bytes (only with fileUrl; default 0)"),
mimeType: z.string().optional().describe("MIME type (optional)"),
},
},
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, mimeType }) => {
async ({ taskId, tabId, columnId, rowId, fileName, contentBase64, fileUrl, fileSize, mimeType }) => {
const task = await storage.getTask(taskId, organizationId);
if (!task) return mcpError(`Задача ${taskId} не найдена`);
if (!isFormAllowed(task.formId)) return formDenied(task.formId);
@@ -5034,19 +5128,10 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
return mcpError(`Колонка "${columnId}" не найдена в табе ${tabId}. Доступные: ${columns.map((c) => c.id).join(', ') || '(нет)'}`);
}
const orgUsers = await storage.getUsersByOrganization(organizationId);
const adminUser = orgUsers.find((u) => u.appRole === "admin") ?? orgUsers[0];
if (!adminUser) return mcpError("В организации нет пользователей");
try {
const file = await uploadFileFromBase64({
organizationId,
userId: adminUser.id,
fileName,
contentBase64,
mimeType,
taskId,
});
const source = await resolveUploadSource({ fileName, contentBase64, fileUrl, fileSize, mimeType, taskId });
if ('error' in source) return mcpError(source.error);
const { actor, file } = source;
// Явного file-формата у ячеек таб-таблиц нет (рендерятся как текст):
// пишем markdown-ссылку [имя](url), как и в справочниках
@@ -5063,7 +5148,7 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
taskId,
tabId,
data: { [columnId]: cellValue },
createdBy: adminUser.id,
createdBy: actor.user.id,
});
}
@@ -5085,6 +5170,91 @@ To block task creation from task.before_create, set: ctx.result = { allow: false
}
);
// get_api_guide
register(
"get_api_guide",
{
title: "Get API Guide",
description: "Returns a compact Russian-language guide for AI agents: how to upload files and create tasks/comments via REST with the same API key, limits, and file field value formats. Call this FIRST when you need to attach files to tasks.",
inputSchema: {},
},
async () => {
const guide = `
# Работа с API iistwin по ключу (гайд для ИИ-агента)
Авторизация везде: заголовок \`X-Api-Key: $KEY\` (или \`Authorization: Bearer $KEY\`).
Базовый URL: \`https://iistwin.ru\`. Ключ работает и в MCP (этот сервер), и в REST.
## 1. Загрузка файла (multipart, НЕ base64)
\`\`\`bash
curl -F "file=@/path/report.pdf" \\
-H "X-Api-Key: $KEY" \\
"https://iistwin.ru/api/upload?taskId=<taskId>&fieldId=<fieldId>"
# → { "url": "/api/files/<key>", "name": "report.pdf", "size": 123456 }
\`\`\`
- taskId/fieldId в query — необязательны, но при taskId проверяется доступ ключа к форме задачи.
- Лимиты (дефолты, переопределяются env): изображения \`UPLOAD_IMAGE_MAX_MB=25\` МБ,
документы \`UPLOAD_DOC_MAX_MB=100\` МБ, жёсткий потолок \`UPLOAD_MAX_MB=100\` МБ.
- Расширения — whitelist: jpg/jpeg/png/gif/webp/svg, pdf, doc/docx, xls/xlsx, ppt/pptx, txt/csv, zip/rar.
Для jpg/png/pdf проверяются magic bytes.
- Для base64-загрузки больших файлов НЕ используй MCP upload_* с contentBase64 —
грузи через REST /api/upload, а привязывай через fileUrl (п.2).
## 2. Привязка файла к задаче/справочнику
Проще всего — MCP-инструменты с fileUrl (без повторной загрузки):
- \`upload_task_file({ taskId, fieldId, fileUrl, fileName?, fileSize? })\` — добавит файл в file-поле задачи.
- \`upload_message_file({ taskId, fileUrl, content? })\` — комментарий с вложением.
- \`upload_directory_file({ tableId, rowId, columnIndex, fileUrl })\` — ссылка в ячейку справочника.
- \`upload_table_row_file({ taskId, tabId, columnId, rowId?, fileUrl })\` — ссылка в ячейку таб-таблицы.
Либо напрямую REST (file-поле — массив объектов {url, name, size}):
\`\`\`bash
# Прочитать текущее значение, ДОБАВИТЬ объект, записать назад:
curl -X PATCH -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
-d '{"value":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
"https://iistwin.ru/api/tasks/<taskId>/field-values/<fieldId>"
\`\`\`
ВНИМАНИЕ: PATCH полностью заменяет значение поля — сначала прочитай задачу
(\`get_task\` в MCP или GET /api/tasks/<id> в REST) и добавь файл к существующему массиву.
## 3. Создание задачи и комментария через REST
\`\`\`bash
# Задача (customFields: { "customField_<fieldId>": значение })
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
-d '{"title":"Новая задача","customFields":{"customField_12":"Текст"}}' \\
"https://iistwin.ru/api/forms/<formId>/tasks"
# Комментарий (с вложением — attachments: [{url, name, size, mimeType?}])
curl -X POST -H "X-Api-Key: $KEY" -H "Content-Type: application/json" \\
-d '{"message":"Готово","attachments":[{"url":"/api/files/<key>","name":"report.pdf","size":123456}]}' \\
"https://iistwin.ru/api/tasks/<taskId>/messages"
\`\`\`
## 4. Права ключа
- mode=read: только чтение (запись → 403). mode=write: чтение+создание. mode=full: всё.
- formIds/tableIds ограничивают список доступных форм/справочников (null = все).
- REST по ключу открыт только для: POST /api/upload, POST /api/tasks/:id/messages,
PATCH /api/tasks/:id/field-values/:fieldId, POST /api/tasks/:id/field-values,
POST /api/forms/:id/tasks. Остальное — через MCP-инструменты.
- Атрибуция: действия по ключу бота записываются в историю от имени бота (bot_id),
по обычному ключу — «Ключ "label"» (metadata.source = 'mcp' | 'api').
## 5. Форматы значений
- file-поле задачи: массив \`[{url, name, size}]\` (множественное — добавляй, не заменяй).
- Вложение сообщения: \`{url, name, size, mimeType?}\`.
- Ячейка справочника/таб-таблицы: markdown-ссылка \`[имя](url)\`.
- url файла: \`/api/files/<key>\` (S3) или \`/uploads/<key>\` (локальный режим).
`.trim();
return { content: [{ type: "text" as const, text: guide }] };
}
);
return server;
}
@@ -5114,7 +5284,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
}
return;
}
const { organizationId, scopes } = resolved;
const { organizationId, scopes, key } = resolved;
const sessionId = req.headers["mcp-session-id"] as string | undefined;
@@ -5126,7 +5296,7 @@ export async function handleMcpRequest(req: Request, res: Response) {
mcpTransports.set(sid, { transport, server, organizationId, scopes });
},
});
const server = buildMcpServer(organizationId, scopes);
const server = buildMcpServer(organizationId, scopes, key);
await server.connect(transport);
@@ -5184,7 +5354,7 @@ export async function handleMcpSse(req: Request, res: Response) {
res.status(401).json({ error: "Invalid or missing API key. Provide X-Api-Key header." });
return;
}
const { organizationId, scopes } = resolved;
const { organizationId, scopes, key } = resolved;
const transport = new SSEServerTransport("/mcp/messages", res);
const sessionId = transport.sessionId;
@@ -5195,7 +5365,7 @@ export async function handleMcpSse(req: Request, res: Response) {
sseSessions.delete(sessionId);
};
const server = buildMcpServer(organizationId, scopes);
const server = buildMcpServer(organizationId, scopes, key);
await server.connect(transport);
}

View File

@@ -4,6 +4,8 @@ import { storage } from '../storage';
import { withTenant, openTenantCtx, openSuperAdminCtx, _tenantCtx } from '../db';
import { eq } from 'drizzle-orm';
import { trackUserActivity } from '../utils/userActivity';
import { normalizeApiKeyScopes } from '../utils/api-key';
import type { ApiKeyScopes } from '@shared/schema';
export interface AuthenticatedRequest extends Request {
user?: any;
@@ -11,6 +13,19 @@ export interface AuthenticatedRequest extends Request {
/** True when the request was authenticated with a bot-service JWT (type: 'bot_service').
* Routes should skip bot-trigger logic when this flag is set to prevent message loops. */
isBotToken?: boolean;
/** Контекст API-ключа (authenticateTokenOrApiKey), когда запрос авторизован ключом, а не JWT.
* req.user при этом равен null. */
apiKey?: RequestApiKeyContext;
}
// Контекст авторизации по API-ключу организации
export interface RequestApiKeyContext {
id: number;
organizationId: number;
botId: number | null;
createdBy: number;
label: string;
scopes: ApiKeyScopes;
}
export interface SuperAdminRequest extends Request {
@@ -111,6 +126,133 @@ export const authenticateToken = async (
}
};
// ── API-ключи: белый список endpoint'ов, доступных по ключу (REST) ───────────
// Проверяется по originalUrl только когда запрос авторизован ключом (не JWT).
const API_KEY_ALLOWED_ROUTES: Array<{ method: string; pattern: RegExp }> = [
{ method: 'POST', pattern: /^\/api\/upload(\?|$)/ },
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/messages(\?|$)/ },
{ method: 'PATCH', pattern: /^\/api\/tasks\/\d+\/field-values\/\d+(\?|$)/ },
{ method: 'POST', pattern: /^\/api\/tasks\/\d+\/field-values(\?|$)/ },
{ method: 'POST', pattern: /^\/api\/forms\/\d+\/tasks(\?|$)/ },
];
// Разрешает запрос по JWT пользователя (поведение authenticateToken не меняется)
// либо по API-ключу организации (X-Api-Key или Authorization: Bearer <key>).
// При авторизации ключом: req.user = null, req.apiKey заполнен,
// req.organizationId = key.organizationId, tenant-контекст открывается так же,
// как в authenticateToken. Legacy/неактивные ключи отклоняются.
export const authenticateTokenOrApiKey = async (
req: AuthenticatedRequest,
res: Response,
next: NextFunction
) => {
if (req.isBotToken) {
return next();
}
const authHeader = req.headers['authorization'];
const headerToken = authHeader && authHeader.split(' ')[1];
const cookieToken = (req as any).cookies?.access_token;
const apiKeyHeader = (req.headers['x-api-key'] as string | undefined)?.trim();
// 1. JWT пользователя (cookie или Bearer) — как в authenticateToken
const userJwt = cookieToken || (!apiKeyHeader ? headerToken : null);
if (userJwt) {
try {
const decoded = verifyAccessToken(userJwt);
// Токены ботов не принимаются (та же проверка, что в authenticateToken)
const payloadType = (decoded as { type?: string }).type;
if (decoded.appRole === 'bot' || (typeof payloadType === 'string' && payloadType.startsWith('bot'))) {
return res.status(401).json({ error: 'Токены ботов не принимаются на этом ресурсе' });
}
const user = await withTenant(decoded.organizationId, () =>
storage.getUserWithOrganization(decoded.userId)
);
if (!user || !user.isActive) {
return res.status(401).json({ error: 'Пользователь не найден или заблокирован' });
}
if (user.organization && !user.organization.isActive) {
return res.status(403).json({ error: 'Доступ организации заблокирован' });
}
req.user = user;
req.organizationId = user.organizationId;
trackUserActivity(user.id);
if (_tenantCtx.getStore()) return next();
openTenantCtx(user.organizationId)
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err) => next(err as Error));
return;
} catch {
// JWT невалиден — если Bearer-токен задан, пробуем его как API-ключ
if (!headerToken) {
return res.status(403).json({ error: 'Недействительный токен' });
}
}
}
// 2. API-ключ организации
const rawKey = apiKeyHeader || headerToken;
if (!rawKey) {
return res.status(401).json({ error: 'Токен доступа отсутствует' });
}
try {
// getApiKeyByHash сам фильтрует isActive и isLegacy=false
const key = await storage.getApiKeyByHash(rawKey);
if (!key || !key.isActive) {
return res.status(401).json({ error: 'Недействительный API-ключ' });
}
// Endpoint должен быть в белом списке для API-ключей
const allowed = API_KEY_ALLOWED_ROUTES.some(
(r) => r.method === req.method && r.pattern.test(req.originalUrl)
);
if (!allowed) {
return res.status(403).json({ error: 'API-ключ не поддерживается на этом ресурсе' });
}
storage.touchApiKey(key.id).catch(() => {});
req.apiKey = {
id: key.id,
organizationId: key.organizationId,
botId: key.botId ?? null,
createdBy: key.createdBy,
label: key.label,
scopes: normalizeApiKeyScopes(key.scopes),
};
req.user = null;
req.organizationId = key.organizationId;
if (_tenantCtx.getStore()) return next();
openTenantCtx(key.organizationId)
.then((handle) => {
handle.run(() => {
const guard = setTimeout(() => {
console.warn(`[POOL] Force-releasing tenant connection after 30s timeout (${req.method} ${req.path})`);
handle.release();
}, 30_000);
res.once('finish', () => { clearTimeout(guard); handle.release(); });
res.once('close', () => { clearTimeout(guard); handle.release(); });
next();
});
})
.catch((err) => next(err as Error));
} catch {
return res.status(401).json({ error: 'Недействительный API-ключ' });
}
};
/**
* Optional pre-middleware: detects a bot-service JWT (type: 'bot_service') and sets
* req.isBotToken = true when found. authenticateToken then skips its user-lookup step.

View File

@@ -1,12 +1,13 @@
import { Router } from "express";
import { z } from 'zod';
import { storage } from "../storage";
import { authenticateToken, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { authenticateToken, authenticateTokenOrApiKey, tryBotServiceToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { validateRequest } from "../middleware/validation.middleware";
import { createTaskMessageSchema } from "@shared/schema";
import { eventBus } from "./shared";
import { sendTaskMessage, SendTaskMessageError } from "../services/task-message.service";
import { isFormAllowedByScopes } from "../utils/api-key";
export function registerChatMessageRoutes(router: Router): void {
// Get task messages
@@ -40,10 +41,10 @@ export function registerChatMessageRoutes(router: Router): void {
}
);
// Create task message
// Create task message (JWT пользователя или API-ключ организации)
router.post('/api/tasks/:id/messages',
tryBotServiceToken,
authenticateToken,
authenticateTokenOrApiKey,
tenantIsolation,
validateRequest(createTaskMessageSchema.omit({ taskId: true })),
async (req: AuthenticatedRequest, res) => {
@@ -65,9 +66,9 @@ export function registerChatMessageRoutes(router: Router): void {
{
const taskRolesForGuard = await storage.getTaskRoles(taskId, req.organizationId!);
if (taskRolesForGuard.length > 0) {
if (req.user && taskRolesForGuard.length > 0) {
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
taskId, req.user.id, req.organizationId!, req.user.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
@@ -75,12 +76,38 @@ export function registerChatMessageRoutes(router: Router): void {
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
if (req.apiKey) {
if (req.apiKey.scopes.mode === 'read') {
return res.status(403).json({ success: false, error: 'API-ключ в режиме read не может отправлять сообщения' });
}
if (!isFormAllowedByScopes(req.apiKey.scopes, task.formId)) {
return res.status(403).json({ success: false, error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
}
}
// Автор сообщения: пользователь (JWT); по ключу бота — сам бот;
// по ключу без бота — владелец ключа
let messageUser = req.user ?? null;
let messageBotId: number | null = null;
if (!messageUser && req.apiKey) {
if (req.apiKey.botId) {
messageBotId = req.apiKey.botId;
} else {
messageUser = await storage.getUser(req.apiKey.createdBy);
}
}
if (!messageUser && !messageBotId) {
return res.status(401).json({ success: false, error: 'Не удалось определить автора сообщения' });
}
// Основная логика (сообщение + side-эффекты) вынесена в сервис —
// переиспользуется также MCP-сервером (инструмент send_task_message).
try {
const createdMessage = await sendTaskMessage({
task,
user: req.user!,
user: messageUser ?? undefined,
botId: messageBotId,
organizationId: req.organizationId!,
message: req.body.message || '',
messageType: req.body.messageType,

View File

@@ -2,8 +2,9 @@ import { Router } from "express";
import fs from 'fs/promises';
import multer from 'multer';
import { storage } from "../storage";
import { authenticateToken, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { authenticateTokenOrApiKey, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { isS3Enabled, uploadToS3FromFile } from "../utils/s3";
import { isFormAllowedByScopes } from "../utils/api-key";
import { upload, EXT_TO_MIME, getFileExt, checkMagicBytes, getSizeLimit, DOC_MAX_SIZE,
getPendingKey, getActivePendingUploads, addPendingUpload,
} from "./shared";
@@ -11,8 +12,8 @@ import { db } from "../db";
import { fileUploads } from "@shared/schema";
const router = Router();
// File upload endpoint
router.post('/api/upload', authenticateToken, async (req: AuthenticatedRequest, res) => {
// File upload endpoint (JWT пользователя или API-ключ организации)
router.post('/api/upload', authenticateTokenOrApiKey, async (req: AuthenticatedRequest, res) => {
try {
await new Promise<void>((resolve, reject) => {
upload.single('file')(req, res, (err: unknown) => {
@@ -32,6 +33,22 @@ const router = Router();
return res.status(400).json({ error: 'Файл не передан' });
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
if (req.apiKey) {
if (req.apiKey.scopes.mode === 'read') {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'API-ключ в режиме read не может загружать файлы' });
}
const taskIdForScope = req.query.taskId ? parseInt(String(req.query.taskId)) : NaN;
if (!isNaN(taskIdForScope)) {
const scopedTask = await storage.getTask(taskIdForScope, req.organizationId!);
if (!scopedTask || !isFormAllowedByScopes(req.apiKey.scopes, scopedTask.formId)) {
if (req.file?.path) { try { await fs.unlink(req.file.path); } catch { /* ignore */ } }
return res.status(403).json({ error: 'Доступ к форме этой задачи запрещён правами API-ключа' });
}
}
}
// deleteNewFile — удаляем временный файл с диска если валидация не прошла
// Теперь всегда disk-режим (S3 тоже пишет во tmpUploadDir до загрузки)
const deleteNewFile = async () => {
@@ -164,7 +181,9 @@ const router = Router();
}
// Записываем файл в таблицу трекинга для последующей проверки доступа
if (req.user?.id && req.organizationId) {
// Автор: пользователь (JWT) или владелец API-ключа; botId — атрибуция бота
const uploaderId = req.user?.id ?? req.apiKey?.createdBy;
if (uploaderId && req.organizationId) {
const fileKey = isS3Enabled
? url.replace('/api/files/', '')
: (req.file.filename ?? '');
@@ -174,12 +193,13 @@ const router = Router();
try {
await db.insert(fileUploads).values({
organizationId: req.organizationId,
uploadedBy: req.user.id,
uploadedBy: uploaderId,
fileKey,
originalName: name,
sizeBytes: req.file.size,
taskId: taskIdNum && !isNaN(taskIdNum) ? taskIdNum : null,
fieldId: fieldIdNum && !isNaN(fieldIdNum) ? fieldIdNum : null,
botId: req.apiKey?.botId ?? null,
}).onConflictDoNothing();
} catch (trackErr) {
console.warn('[Upload] Failed to track file upload:', trackErr);

View File

@@ -16,6 +16,7 @@ import { validateRequiredFields } from "../utils/validate-required-fields";
import { shiftRelatedTasks, calculateDateShift } from "../services/gantt-shift.service";
import { runAutomationsByTrigger, type AutomationRunResult } from "./automation.routes";
import { normalizeFieldValueForStorage } from "../utils/normalize-field-value";
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
import { db, withTenant } from "../db";
import { eq, and, sql } from "drizzle-orm";
@@ -51,10 +52,17 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
return res.status(404).json({ success: false, error: 'Форма не найдена' });
}
const canAccessForm = await storage.canUserAccessForm(req.user!.id, formId, req.organizationId!, 'participate');
if (req.user) {
const canAccessForm = await storage.canUserAccessForm(req.user.id, formId, req.organizationId!, 'participate');
if (!canAccessForm) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой форме' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrCreate = checkApiKeyWriteAccess(req, formId);
if (apiKeyErrCreate) {
return res.status(403).json({ success: false, error: apiKeyErrCreate });
}
const { customFields, dueDate, ...baseTaskData } = req.body;
@@ -137,11 +145,17 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
}
}
// Автор: пользователь (JWT) или владелец API-ключа
const createdByUserId = req.user?.id ?? req.apiKey?.createdBy;
if (!createdByUserId) {
return res.status(401).json({ success: false, error: 'Не удалось определить автора задачи' });
}
const taskData = {
...baseTaskData,
formId,
organizationId: req.organizationId!,
createdBy: req.user!.id,
createdBy: createdByUserId,
dueDate: parsedDueDate,
parentTaskId,
};
@@ -197,14 +211,15 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
}
}
const creatorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
const creatorActor = await resolveRestActor(req, req.organizationId!);
storage.addTaskAuditLog({
taskId: task.id,
organizationId: req.organizationId!,
action: 'task.created',
changedBy: req.user?.id ?? null,
changedByName: creatorName,
metadata: { title: task.title },
changedBy: creatorActor.changedBy,
changedByName: creatorActor.changedByName,
botId: creatorActor.botId,
metadata: { title: task.title, ...(creatorActor.source ? { source: creatorActor.source } : {}) },
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
if (customFields && typeof customFields === 'object') {
@@ -307,8 +322,8 @@ export function registerTaskCrudWriteRoutes(router: ReturnType<typeof import("ex
finalTask = await storage.updateTask(task.id, req.organizationId!, { title: computedTitle });
}
if (finalTask.assignedTo && finalTask.assignedTo !== req.user!.id) {
notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user!.id, req.organizationId!)
if (finalTask.assignedTo && finalTask.assignedTo !== req.user?.id) {
notifyTaskAssigned(finalTask, finalTask.assignedTo, req.user?.id ?? null, req.organizationId!)
.catch((err) => console.error('[TaskCreate] notifyTaskAssigned error:', err));
}

View File

@@ -1,7 +1,8 @@
import { Router } from "express";
import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { authenticateToken, authenticateTokenOrApiKey, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { resolveRestActor, checkApiKeyWriteAccess } from "../utils/api-key-actor";
import { buildSystemFieldValues, buildTableRowMap, deleteRemovedFiles, eventBus, formatFieldValueForTitle, resolveTaskFieldTitles } from "./shared";
import { evaluateAutoTransitions } from "../utils/auto-transitions";
import { tasksMinimalCache } from "../utils/cache";
@@ -63,7 +64,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
// Create/Update task field values (bulk)
router.post('/api/tasks/:id/field-values',
authenticateToken,
authenticateTokenOrApiKey,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
@@ -77,14 +78,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
if (req.user) {
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
taskId, req.user.id, req.organizationId!, req.user.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrPost = checkApiKeyWriteAccess(req, existingTask.formId);
if (apiKeyErrPost) {
return res.status(403).json({ success: false, error: apiKeyErrPost });
}
const { fieldValues } = req.body;
if (!Array.isArray(fieldValues)) {
@@ -226,7 +232,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
}
}
const fieldEditorName = req.user ? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email) : 'API';
const fieldActor = await resolveRestActor(req, req.organizationId!);
const offlineEnqueuedAtPost = parseOfflineTimestamp(req);
for (const fieldValue of fieldValues) {
const field = fieldMap.get(fieldValue.fieldId);
@@ -247,11 +253,14 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: newVal ?? null,
changedBy: req.user?.id ?? null,
changedByName: fieldEditorName,
changedBy: fieldActor.changedBy,
changedByName: fieldActor.changedByName,
botId: fieldActor.botId,
metadata: {
displayOldValue: displayOld || null,
displayNewValue: displayNew || null,
// Атрибуция канала: 'api' при изменении по API-ключу
...(fieldActor.source ? { source: fieldActor.source } : {}),
},
...(offlineEnqueuedAtPost ? { createdAt: offlineEnqueuedAtPost } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error:', auditErr); });
@@ -365,7 +374,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
// Update single field value (inline editing)
router.patch('/api/tasks/:id/field-values/:fieldId',
authenticateToken,
authenticateTokenOrApiKey,
tenantIsolation,
async (req: AuthenticatedRequest, res) => {
try {
@@ -380,14 +389,19 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
return res.status(404).json({ success: false, error: 'Задача не найдена' });
}
{
if (req.user) {
const hasAccess = await storage.canUserAccessTask(
taskId, req.user!.id, req.organizationId!, req.user!.appRole
taskId, req.user.id, req.organizationId!, req.user.appRole
);
if (!hasAccess) {
return res.status(403).json({ success: false, error: 'Нет доступа к этой задаче' });
}
}
// Проверки для авторизации по API-ключу (JWT-путь не меняется)
const apiKeyErrPatch = checkApiKeyWriteAccess(req, existingTask.formId);
if (apiKeyErrPatch) {
return res.status(403).json({ success: false, error: apiKeyErrPatch });
}
const { value } = req.body;
@@ -473,9 +487,7 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
});
}
const editorName = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
const patchActor = await resolveRestActor(req, req.organizationId!);
const offlineEnqueuedAtPatch = parseOfflineTimestamp(req);
const oldStr = oldVal === null || oldVal === undefined ? '' : String(oldVal);
const newStr = normalizedValue === null || normalizedValue === undefined ? '' : String(normalizedValue);
@@ -488,8 +500,10 @@ export function registerTaskFieldRoutes(router: ReturnType<typeof import("expres
fieldName: field.name,
oldValue: oldVal ?? null,
newValue: normalizedValue ?? null,
changedBy: req.user?.id ?? null,
changedByName: editorName,
changedBy: patchActor.changedBy,
changedByName: patchActor.changedByName,
botId: patchActor.botId,
...(patchActor.source ? { metadata: { source: patchActor.source } } : {}),
...(offlineEnqueuedAtPatch ? { createdAt: offlineEnqueuedAtPatch } : {}),
}).catch((auditErr: unknown) => { console.error('Audit log error (inline edit):', auditErr); });
}

View File

@@ -1,5 +1,5 @@
import { Router } from "express";
import { authenticateToken } from "../middleware/auth.middleware";
import { authenticateToken, authenticateTokenOrApiKey } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { registerUserRoutes } from "./user.routes";
import { registerFormRoutes } from "./form.routes";
@@ -11,11 +11,13 @@ import { registerTaskRoleRoutes } from "./task-roles.routes";
const router = Router();
// Global middleware for all protected route prefixes
// Для /api/tasks и /api/forms — authenticateTokenOrApiKey: JWT-путь идентичен,
// API-ключ допускается только на endpoint'ы из белого списка (см. auth.middleware).
router.use('/api/users', authenticateToken, tenantIsolation);
router.use('/api/user-statuses', authenticateToken, tenantIsolation);
router.use('/api/organizations', authenticateToken, tenantIsolation);
router.use('/api/forms', authenticateToken, tenantIsolation);
router.use('/api/tasks', authenticateToken, tenantIsolation);
router.use('/api/forms', authenticateTokenOrApiKey, tenantIsolation);
router.use('/api/tasks', authenticateTokenOrApiKey, tenantIsolation);
// Register domain route handlers
registerUserRoutes(router);

View File

@@ -31,6 +31,7 @@ export interface UploadFileFromBase64Params {
mimeType?: string;
taskId?: number | null; // опциональная привязка к задаче
fieldId?: number | null; // опциональная привязка к полю формы
botId?: number | null; // атрибуция загрузки ботом (file_uploads.bot_id)
}
export interface UploadedFileInfo {
@@ -50,7 +51,7 @@ const MAX_BASE64_LENGTH = Math.ceil(DOC_MAX_SIZE * 4 / 3) + 1024;
// whitelist расширений, magic bytes, раздельные лимиты (10 МБ изображения / 50 МБ прочее).
// Создаёт запись трекинга в file_uploads.
export async function uploadFileFromBase64(params: UploadFileFromBase64Params): Promise<UploadedFileInfo> {
const { organizationId, userId, taskId = null, fieldId = null } = params;
const { organizationId, userId, taskId = null, fieldId = null, botId = null } = params;
// 1. Имя файла: whitelist расширений + запрет недопустимых символов (как в multer fileFilter)
const name = path.basename(params.fileName || '');
@@ -123,6 +124,7 @@ export async function uploadFileFromBase64(params: UploadFileFromBase64Params):
sizeBytes: buffer.length,
taskId,
fieldId,
botId,
}).returning({ id: fileUploads.id });
fileUploadId = row?.id ?? null;
} catch (trackErr) {

View File

@@ -23,7 +23,8 @@ type MessageAttachment = { url: string; name: string; size: number; mimeType?: s
export interface SendTaskMessageParams {
task: Task; // задача (уже загружена и проверена вызывающим кодом)
user: User; // автор сообщения
user?: User; // автор сообщения; необязателен, если передан botId
botId?: number | null; // сообщение от бота: authorId=null, botId, messageType='bot'
organizationId: number;
message: string;
messageType?: string; // 'comment' (default), 'bot', 'system', ...
@@ -41,8 +42,14 @@ export interface SendTaskMessageParams {
// постановка embedding в очередь, запись взаимодействия с задачей.
// Логика вынесена из POST /api/tasks/:id/messages (routes/chat.messages.routes.ts)
// и переиспользуется MCP-сервером — поведение не менять.
// При botId (сообщение от бота) авторство и пользовательские side-эффекты пропускаются,
// как в POST /api/bot/message (bot-api.routes.ts).
export async function sendTaskMessage(params: SendTaskMessageParams): Promise<TaskMessage> {
const { task, user, organizationId } = params;
const botId = params.botId ?? null;
if (!user && !botId) {
throw new SendTaskMessageError('Не указан автор сообщения (user или botId)', 500);
}
const taskId = task.id;
let replyToMessage = null;
@@ -59,7 +66,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
const orgUsers = await storage.getUsersByOrganization(organizationId);
const orgUserIds = orgUsers.map(u => u.id);
mentionedUserIds = params.mentionedUserIds.filter(id =>
orgUserIds.includes(id) && id !== user.id
orgUserIds.includes(id) && id !== user?.id
);
}
@@ -67,15 +74,16 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
// 1. messageType is 'bot' or 'system' (messages authored by the bot service)
// 2. authorId is null in the payload (another indicator of a bot/system message)
// 3. Request was authenticated via a bot-service JWT (isBotToken = true)
const messageType = params.messageType || (botId ? 'bot' : 'comment');
const isBotOrSystemMessage =
params.messageType === 'bot' ||
params.messageType === 'system' ||
messageType === 'bot' ||
messageType === 'system' ||
params.bodyAuthorId === null ||
params.isBotToken === true;
let mentionedBotIds: number[] = [];
let mentionedBotsMap = new Map<number, Bot>();
if (!isBotOrSystemMessage && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
if (!isBotOrSystemMessage && user && params.mentionedBotIds && params.mentionedBotIds.length > 0) {
const orgBots = await storage.getBotsByOrganization(organizationId);
const activeBots = orgBots.filter(b => b.isActive);
activeBots.forEach(bot => mentionedBotsMap.set(bot.id, bot));
@@ -86,10 +94,11 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
const messageData = {
taskId,
formId: task.formId,
authorId: user.id,
authorId: botId ? null : (user?.id ?? null),
botId,
replyToMessageId: params.replyToMessageId || null,
message: params.message || '',
messageType: params.messageType || 'comment',
messageType,
mentionedUserIds: mentionedUserIds.length > 0 ? mentionedUserIds : null,
attachments: params.attachments?.length ? params.attachments : null,
};
@@ -127,7 +136,7 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
}
}
if (mentionedBotIds.length > 0) {
if (mentionedBotIds.length > 0 && user) {
const taskFieldValues = await storage.getTaskFieldValues(taskId, organizationId);
const form = await storage.getForm(task.formId, organizationId);
const taskWithFields = { ...task, fieldValues: taskFieldValues };
@@ -226,6 +235,9 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
taskId: taskId
});
// Уведомления и исходящие вебхуки — только для сообщений от пользователя
// (для bot-сообщений — как в POST /api/bot/message: без notificationService).
if (user) {
const notificationEvent: NotificationEvent = {
type: replyToMessage ? EVENT_TYPES.TASK_COMMENT_REPLIED : EVENT_TYPES.TASK_COMMENT_CREATED,
organizationId,
@@ -261,13 +273,14 @@ export async function sendTaskMessage(params: SendTaskMessageParams): Promise<Ta
organizationId, taskId, task.formId, createdMessage, user.id
).catch(err => console.error('Webhook dispatch error:', err));
}
}
if (messageData.messageType === 'comment') {
storage.enqueueEmbedding(organizationId, 'task_message', createdMessage.id, 'upsert')
.catch(err => console.error('[RAG] enqueue message embedding error:', err));
}
if (user.id) {
if (user?.id) {
storage.recordTaskInteractionAuto(taskId, user.id, organizationId)
.catch(err => console.error('recordTaskInteraction error:', err));
}

View File

@@ -0,0 +1,44 @@
import { storage } from '../storage';
import type { AuthenticatedRequest } from '../middleware/auth.middleware';
import { isFormAllowedByScopes } from './api-key';
// Актор изменений для REST-хендлеров, поддерживающих API-ключ:
// JWT — текущий пользователь; ключ бота — бот (changedBy=null); ключ без бота — label ключа.
export interface RestActor {
changedBy: number | null;
changedByName: string;
botId: number | null;
source: 'api' | null; // 'api' при авторизации по ключу (идёт в metadata аудит-записей)
}
export async function resolveRestActor(req: AuthenticatedRequest, organizationId: number): Promise<RestActor> {
if (!req.apiKey) {
const name = req.user
? (`${req.user.firstName || ''} ${req.user.middleName || ''} ${req.user.lastName || ''}`.trim() || req.user.email)
: 'API';
return { changedBy: req.user?.id ?? null, changedByName: name, botId: null, source: null };
}
const bot = req.apiKey.botId
? await storage.getBot(req.apiKey.botId, organizationId).catch(() => null)
: null;
return {
changedBy: bot ? null : req.apiKey.createdBy,
changedByName: bot?.name ?? `Ключ «${req.apiKey.label}»`,
botId: bot?.id ?? null,
source: 'api',
};
}
// Проверки авторизации по API-ключу для write-endpoint'а (изменение данных формы/задачи).
// Возвращает текст ошибки для 403 или null, если доступ разрешён.
// Для JWT-запросов всегда null (их проверки — в хендлерах, как раньше).
export function checkApiKeyWriteAccess(req: AuthenticatedRequest, formId: number): string | null {
if (!req.apiKey) return null;
if (req.apiKey.scopes.mode === 'read') {
return 'API-ключ в режиме read не может изменять данные';
}
if (!isFormAllowedByScopes(req.apiKey.scopes, formId)) {
return 'Доступ к этой форме запрещён правами API-ключа';
}
return null;
}

View File

@@ -35,6 +35,16 @@ export function legacySha256Hash(raw: string): string {
// Полный доступ — используется как дефолт для legacy-ключей (scopes = NULL в БД)
export const FULL_API_KEY_SCOPES: ApiKeyScopes = { mode: 'full', formIds: null, tableIds: null };
// Проверка доступа к форме по скоупам ключа (null = все формы)
export function isFormAllowedByScopes(scopes: ApiKeyScopes, formId: number): boolean {
return scopes.formIds === null || scopes.formIds.includes(formId);
}
// Проверка доступа к справочнику по скоупам ключа (null = все справочники)
export function isTableAllowedByScopes(scopes: ApiKeyScopes, tableId: number): boolean {
return scopes.tableIds === null || scopes.tableIds.includes(tableId);
}
// Нормализация скоупов из БД: NULL/отсутствующий или частично заполненный объект
// приводится к полной структуре ApiKeyScopes (дефолты — полный доступ).
export function normalizeApiKeyScopes(scopes: unknown): ApiKeyScopes {

View File

@@ -53,10 +53,13 @@ export const EXT_MAGIC: Record<string, Buffer> = {
pdf: Buffer.from([0x25, 0x50, 0x44, 0x46]), // %PDF
};
// Image extensions → 10 MB limit; all others → 50 MB
// Image extensions → лимит изображений; all others → документный лимит
// Лимиты настраиваются через env (в МБ), дефолты: 25 МБ изображения, 100 МБ документы
export const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg']);
export const IMAGE_MAX_SIZE = 10 * 1024 * 1024; // 10 МБ
export const DOC_MAX_SIZE = 50 * 1024 * 1024; // 50 МБ
export const IMAGE_MAX_SIZE = Number(process.env.UPLOAD_IMAGE_MAX_MB || 25) * 1024 * 1024;
export const DOC_MAX_SIZE = Number(process.env.UPLOAD_DOC_MAX_MB || 100) * 1024 * 1024;
// Жёсткий потолок multer (fileSize) — отдельный env, дефолт 100 МБ
export const UPLOAD_MAX_SIZE = Number(process.env.UPLOAD_MAX_MB || 100) * 1024 * 1024;
// Derives the lowercase extension from the original filename (trusted)
export function getFileExt(originalname: string): string {
@@ -127,7 +130,7 @@ const multerStorage = multer.diskStorage({
export const upload = multer({
storage: multerStorage,
limits: { fileSize: DOC_MAX_SIZE }, // hard cap 50 МБ; per-type check done in handler
limits: { fileSize: UPLOAD_MAX_SIZE }, // жёсткий потолок (UPLOAD_MAX_MB); раздельная проверка по типам — в хендлере
fileFilter: (_req, file, cb) => {
// 1. Validate filename: strict regex + extension whitelist (extension is trusted)
const { valid, reason } = validateFilename(file.originalname);