Files
iistwin/server/routes/mcp-rag.routes.ts
Ильяс Султанов 26419f395f security(errors): 5xx без утечки err.message клиенту
Шаг 0.7 плана production-готовности:
- глобальный error handler: нейтральный текст + код инцидента, детали в лог
- route-уровень: зачистка err.message/String(err) в 5xx по 12 файлам,
  оригинальные ошибки логируются с контекстом
2026-09-07 20:42:39 +03:00

425 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { storage } from "../storage";
import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware";
import { tenantIsolation } from "../middleware/tenant.middleware";
import { type ReminderRecipient } from "@shared/schema";
import { normalizeApiKeyScopes, parseApiKeyScopesInput } from "../utils/api-key";
import { handleMcpRequest, handleMcpSse, handleMcpMessages } from "../mcp";
import { setupSwagger } from "../swagger";
import express, { type Request, type Response, type NextFunction } from 'express';
// In-process dedup: avoid spamming admins with repeated legacy-key notifications.
// Key: "orgId:keyPrefix" — reset only on process restart.
const _legacyKeyNotifiedSet = new Set<string>();
async function notifyAdminsLegacyKey(organizationId: number, keyPrefix: string, label: string): Promise<void> {
const dedupeKey = `${organizationId}:${keyPrefix}`;
if (_legacyKeyNotifiedSet.has(dedupeKey)) return;
_legacyKeyNotifiedSet.add(dedupeKey);
try {
const admins = await storage.getUsersByRole('admin', organizationId);
for (const admin of admins) {
await storage.createUserNotification({
userId: admin.id,
organizationId,
type: 'system',
title: 'Устаревший API-ключ',
message: `API-ключ "${label}" (${keyPrefix}…) использует устаревший алгоритм (SHA-256). Удалите его и создайте новый в Настройки → API-ключи.`,
isRead: false,
});
}
} catch {
_legacyKeyNotifiedSet.delete(dedupeKey);
}
}
async function requireMcpApiKey(req: Request, res: Response, next: NextFunction): Promise<void> {
const rawKey =
(req.headers['x-api-key'] as string | undefined) ||
(req.headers['authorization'] as string | undefined)?.replace(/^Bearer\s+/i, '') ||
(req.query?.['apiKey'] as string | undefined) ||
(req.query?.['key'] as string | undefined);
if (!rawKey) {
res.status(401).json({ error: 'Missing API key. Provide Authorization: Bearer <key>, X-Api-Key header, or ?key=<key> query parameter.' });
return;
}
const trimmed = rawKey.trim();
const apiKey = await storage.getApiKeyByHash(trimmed);
if (apiKey && apiKey.isActive) {
storage.touchApiKey(apiKey.id).catch(() => {});
// Прикрепляем нормализованные скоупы ключа к запросу для downstream-обработчиков
(req as any).apiKeyScopes = normalizeApiKeyScopes(apiKey.scopes);
next();
return;
}
const legacyKey = await storage.getApiKeyByLegacyHash(trimmed);
if (legacyKey && legacyKey.isActive) {
notifyAdminsLegacyKey(legacyKey.organizationId, legacyKey.keyPrefix, legacyKey.label).catch(() => {});
res.status(401).json({
error: 'API key is outdated (SHA-256). Please delete it and generate a new one in Settings → API Keys.',
});
return;
}
res.status(401).json({ error: 'Invalid or revoked API key.' });
}
export function registerMcpRagRoutes(app: import("express").Express): void {
/**
* @swagger
* /api/mcp-keys:
* get:
* tags: [MCP]
* summary: Список API-ключей для MCP (admin)
* description: API-ключи для подключения AI-агентов (Claude Desktop, Cursor, Cline). Требуется роль admin.
* responses:
* 200:
* description: Список ключей (без keyHash)
* post:
* tags: [MCP]
* summary: Создать API-ключ (admin)
* description: Ключ показывается **только один раз** в поле `key`. Формат — `wf_<random>`.
* /api/mcp-keys/{id}:
* delete:
* tags: [MCP]
* summary: Удалить API-ключ (admin)
*/
app.get('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const keys = await storage.listApiKeys(req.organizationId!);
// Возвращаем scopes каждого ключа в нормализованном виде (null в БД = полный доступ)
const safeKeys = keys.map(({ keyHash: _h, scopes, ...rest }) => ({
...rest,
scopes: normalizeApiKeyScopes(scopes),
}));
res.json({ success: true, keys: safeKeys });
} catch (error) {
console.error('List MCP keys error:', error);
res.status(500).json({ error: 'Ошибка получения ключей' });
}
});
app.post('/api/mcp-keys', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const label = (req.body?.label as string | undefined)?.trim() || 'Default';
// Скоупы необязательны: без них ключ создаётся с полным доступом (scopes = NULL)
let scopes: import('@shared/schema').ApiKeyScopes | null = null;
if (req.body?.scopes !== undefined && req.body?.scopes !== null) {
const parsed = parseApiKeyScopesInput(req.body.scopes);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
scopes = parsed.scopes;
}
const { key, record } = await storage.createApiKey(req.organizationId!, req.user!.id, label, scopes);
const { keyHash: _h, ...safeRecord } = record;
res.json({ success: true, key, record: safeRecord });
} catch (error) {
console.error('Create MCP key error:', error);
res.status(500).json({ error: 'Ошибка создания ключа' });
}
});
// Обновление label и/или scopes существующего ключа.
// scopes: null в body — сброс к полному доступу (NULL в БД).
app.patch('/api/mcp-keys/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
const updates: { label?: string; scopes?: import('@shared/schema').ApiKeyScopes | null } = {};
if (req.body?.label !== undefined) {
if (typeof req.body.label !== 'string' || !req.body.label.trim()) {
return res.status(400).json({ error: 'Поле label должно быть непустой строкой' });
}
updates.label = req.body.label.trim();
}
if (req.body?.scopes !== undefined) {
if (req.body.scopes === null) {
updates.scopes = null; // сброс к полному доступу
} else {
const parsed = parseApiKeyScopesInput(req.body.scopes);
if (!parsed.ok) return res.status(400).json({ error: parsed.error });
updates.scopes = parsed.scopes;
}
}
if (Object.keys(updates).length === 0) {
return res.status(400).json({ error: 'Нечего обновлять: передайте label и/или scopes' });
}
const updated = await storage.updateApiKey(id, req.organizationId!, updates);
if (!updated) return res.status(404).json({ error: 'Ключ не найден' });
const { keyHash: _h, ...safeRecord } = updated;
res.json({ success: true, record: safeRecord });
} catch (error) {
console.error('Update MCP key error:', error);
res.status(500).json({ error: 'Ошибка обновления ключа' });
}
});
app.delete('/api/mcp-keys/:id', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ error: 'Неверный ID' });
await storage.deleteApiKey(id, req.organizationId!);
res.json({ success: true });
} catch (error) {
console.error('Delete MCP key error:', error);
res.status(500).json({ error: 'Ошибка удаления ключа' });
}
});
// RAG / Semantic Search Endpoints
app.get('/api/mcp/rag-stats', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const { getEmbeddingCounts } = await import('../services/embedding.service');
const rawCounts = await getEmbeddingCounts(req.organizationId!);
const counts = {
form: rawCounts['form'] ?? 0,
task: rawCounts['task'] ?? 0,
task_message: rawCounts['task_message'] ?? 0,
};
res.json({ success: true, counts });
} catch (error) {
console.error('RAG stats error:', error);
res.status(500).json({ success: false, error: 'Ошибка получения статистики индекса' });
}
});
interface ReindexProgress {
indexed: number;
total: number;
attempted: number;
failed: number;
status: 'running' | 'done' | 'error';
startedAt: number;
result?: { forms: number; tasks: number; messages: number; attempted: number; failed: number };
error?: string;
}
const reindexProgressStore = new Map<number, ReindexProgress>();
app.get('/api/mcp/reindex-progress', authenticateToken, tenantIsolation, requirePermission('settings.manage'), (req: AuthenticatedRequest, res) => {
const organizationId = req.organizationId!;
const progress = reindexProgressStore.get(organizationId);
if (!progress) {
return res.json({ success: true, status: 'idle' });
}
res.json({ success: true, ...progress });
});
app.post('/api/mcp/reindex', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const organizationId = req.organizationId!;
const existing = reindexProgressStore.get(organizationId);
if (existing && existing.status === 'running') {
return res.json({ success: true, message: 'Переиндексация уже выполняется', alreadyRunning: true });
}
const rawTypes: string[] = req.body?.entity_types ?? ['form', 'task', 'task_message'];
const validTypes = ['form', 'task', 'task_message'];
const entityTypes = rawTypes.filter(t => validTypes.includes(t)) as import('../services/embedding.service').EntityType[];
const { reindexOrganization } = await import('../services/embedding.service');
reindexProgressStore.set(organizationId, { indexed: 0, total: 0, attempted: 0, failed: 0, status: 'running', startedAt: Date.now() });
reindexOrganization(storage, organizationId, entityTypes, (indexed, total) => {
const prev = reindexProgressStore.get(organizationId);
reindexProgressStore.set(organizationId, {
indexed,
total,
attempted: prev?.attempted ?? 0,
failed: prev?.failed ?? 0,
status: 'running',
startedAt: prev?.startedAt ?? Date.now(),
});
}).then((result) => {
const finalTotal = result.attempted;
reindexProgressStore.set(organizationId, {
indexed: result.forms + result.tasks + result.messages,
total: finalTotal,
attempted: result.attempted,
failed: result.failed,
status: 'done',
startedAt: reindexProgressStore.get(organizationId)?.startedAt ?? Date.now(),
result,
});
setTimeout(() => reindexProgressStore.delete(organizationId), 5 * 60 * 1000);
}).catch((err) => {
console.error('[RAG] Reindex error:', err);
const prev = reindexProgressStore.get(organizationId);
reindexProgressStore.set(organizationId, {
indexed: prev?.indexed ?? 0,
total: prev?.total ?? 0,
attempted: prev?.attempted ?? 0,
failed: prev?.failed ?? 0,
status: 'error',
startedAt: prev?.startedAt ?? Date.now(),
error: 'Ошибка переиндексации',
});
setTimeout(() => reindexProgressStore.delete(organizationId), 5 * 60 * 1000);
});
res.json({ success: true, message: 'Переиндексация запущена в фоне' });
} catch (error) {
console.error('Reindex error:', error);
res.status(500).json({ success: false, error: 'Ошибка запуска переиндексации' });
}
});
app.patch('/api/forms/:id/ai-summary', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) return res.status(400).json({ success: false, error: 'Неверный ID формы' });
const form = await storage.getForm(formId, req.organizationId!);
if (!form) return res.status(404).json({ success: false, error: 'Форма не найдена' });
const { aiSummary, aiSummaryIsAuto } = req.body;
if (aiSummaryIsAuto !== undefined && typeof aiSummaryIsAuto !== 'boolean') {
return res.status(400).json({ success: false, error: 'aiSummaryIsAuto должен быть булевым значением' });
}
const updated = await storage.updateForm(formId, req.organizationId!, {
aiSummary,
...(aiSummaryIsAuto !== undefined ? { aiSummaryIsAuto } : {}),
});
storage.enqueueEmbedding(req.organizationId!, 'form', formId, 'upsert').catch(() => {});
res.json({ success: true, form: updated });
} catch (error) {
console.error('Update AI summary error:', error);
res.status(500).json({ success: false, error: 'Ошибка обновления резюме' });
}
});
app.post('/api/forms/:id/regenerate-summary', authenticateToken, tenantIsolation, requirePermission('settings.manage'), async (req: AuthenticatedRequest, res) => {
try {
const formId = parseInt(req.params.id);
if (isNaN(formId)) return res.status(400).json({ success: false, error: 'Неверный ID формы' });
const form = await storage.getForm(formId, req.organizationId!);
if (!form) return res.status(404).json({ success: false, error: 'Форма не найдена' });
const { generateFormSummary } = await import('../services/embedding.service');
const [fields, statuses] = await Promise.all([
storage.getFormFields(formId, req.organizationId!),
storage.getFormStatuses(formId, req.organizationId!),
]);
const summary = await generateFormSummary(form, fields, statuses, req.organizationId!);
res.json({ success: true, aiSummary: summary ?? null });
} catch (error) {
console.error('Regenerate summary error:', error);
res.status(500).json({ success: false, error: 'Ошибка генерации резюме' });
}
});
// Task Reminders Endpoints
app.get('/api/tasks/:id/reminders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
try {
const reminders = await storage.getTaskReminders(taskId, req.organizationId!);
return res.json({ success: true, reminders });
} catch (err: any) {
console.error('Get task reminders error:', err);
return res.status(500).json({ success: false, error: 'Ошибка загрузки напоминаний' });
}
});
app.post('/api/tasks/:id/reminders', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
const taskId = parseInt(req.params.id);
if (isNaN(taskId)) return res.status(400).json({ success: false, error: 'Неверный ID задачи' });
const task = await storage.getTask(taskId, req.organizationId!);
if (!task) return res.status(404).json({ success: false, error: 'Задача не найдена' });
const { remindAt, note, recipients } = req.body;
if (!remindAt) return res.status(400).json({ success: false, error: 'Укажите дату и время напоминания' });
const remindAtDate = new Date(remindAt);
if (isNaN(remindAtDate.getTime())) {
return res.status(400).json({ success: false, error: 'Неверный формат даты напоминания' });
}
if (!Array.isArray(recipients) || recipients.length === 0) {
return res.status(400).json({ success: false, error: 'Укажите хотя бы одного получателя' });
}
const ALLOWED_SYSTEM_ROLES = new Set(['admin', 'user']);
const validatedRecipients: ReminderRecipient[] = [];
const hasUserRecipients = recipients.some((rec: any) => rec.type === 'user');
const orgUsersMap = hasUserRecipients
? new Map((await storage.getUsersByOrganization(req.organizationId!)).map(u => [u.id, u]))
: new Map();
for (const rec of recipients) {
if (rec.type === 'user') {
if (typeof rec.userId !== 'number') return res.status(400).json({ success: false, error: 'Неверный формат получателя' });
const orgUser = orgUsersMap.get(rec.userId);
if (!orgUser) {
return res.status(400).json({ success: false, error: `Пользователь ${rec.userId} не принадлежит организации` });
}
validatedRecipients.push({ type: 'user', userId: rec.userId });
} else if (rec.type === 'role') {
if (typeof rec.role !== 'string' || !ALLOWED_SYSTEM_ROLES.has(rec.role)) {
return res.status(400).json({ success: false, error: `Недопустимая системная роль: ${rec.role}` });
}
validatedRecipients.push({ type: 'role', role: rec.role });
} else if (rec.type === 'orgRole') {
if (typeof rec.roleId !== 'number') return res.status(400).json({ success: false, error: 'Неверный формат роли организации' });
await storage.getUsersByOrgRoleId(rec.roleId, req.organizationId!);
validatedRecipients.push({ type: 'orgRole', roleId: rec.roleId });
} else {
return res.status(400).json({ success: false, error: 'Неверный тип получателя' });
}
}
try {
const reminder = await storage.createTaskReminder({
taskId,
organizationId: req.organizationId!,
createdByUserId: req.user!.id,
remindAt: remindAtDate,
note: note || null,
recipients: validatedRecipients,
});
return res.json({ success: true, reminder });
} catch (err: any) {
console.error('Create task reminder error:', err);
return res.status(500).json({ success: false, error: 'Ошибка создания напоминания' });
}
});
app.delete('/api/reminders/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
const id = parseInt(req.params.id);
if (isNaN(id)) return res.status(400).json({ success: false, error: 'Неверный ID' });
try {
await storage.deleteTaskReminder(id, req.organizationId!);
return res.json({ success: true });
} catch (err: any) {
console.error('Delete task reminder error:', err);
return res.status(500).json({ success: false, error: 'Ошибка удаления напоминания' });
}
});
// MCP Server Endpoints — require valid org API key at route level
// Handle notifications before handleMcpRequest to avoid transport issues with consumed body streams.
// NOTE: we rely on the global express.json() middleware (already applied in server/index.ts).
app.all('/mcp', (req, res, next) => {
if (
req.method === 'POST' &&
req.body &&
!Array.isArray(req.body) &&
typeof (req.body as any).method === 'string' &&
(req.body as any).method.startsWith('notifications/')
) {
return res.status(202).send();
}
next();
}, handleMcpRequest);
app.get('/mcp/sse', requireMcpApiKey, handleMcpSse);
app.post('/mcp/messages', requireMcpApiKey, express.json(), handleMcpMessages);
// Swagger UI
setupSwagger(app);
}