Upload-тикеты: загрузка файлов через REST без знания API-ключа

- MCP create_upload_ticket → короткоживущий uploadUrl (10 мин, in-memory)
- POST /api/upload/ticket/:token — тот же пайплайн (processUploadRequest вынесен из /api/upload), авторизация тикетом, проверки скоупов
- get_api_guide: флоу без ключа — ticket → curl → привязка по fileUrl
This commit is contained in:
2026-07-23 15:53:17 +03:00
parent af119d1cdc
commit affbdeee1e
3 changed files with 389 additions and 177 deletions

View File

@@ -0,0 +1,61 @@
import crypto from 'crypto';
import type { ApiKeyScopes } from '@shared/schema';
// Короткоживущие тикеты загрузки файлов.
// Сценарий: ИИ-агент подключён по MCP, но значение API-ключа зашито в конфиге
// его MCP-клиента и агенту недоступно — выполнить curl на POST /api/upload
// с заголовком X-Api-Key он не может. MCP-инструмент create_upload_ticket
// выдаёт тикет, а REST POST /api/upload/ticket/:token принимает файл БЕЗ ключа.
// TTL тикета: 10 минут
const TICKET_TTL_MS = 10 * 60 * 1000;
export interface UploadTicket {
token: string;
organizationId: number;
apiKeyId: number | null;
botId: number | null;
createdBy: number;
scopes: ApiKeyScopes;
expiresAt: Date;
}
// Хранилище в памяти процесса: тикеты короткоживущие, персистентность не нужна
const tickets = new Map<string, UploadTicket>();
// Ленивая очистка просроченных тикетов (вызывается при create/consume)
function cleanupExpired(): void {
const now = Date.now();
for (const [token, ticket] of tickets) {
if (ticket.expiresAt.getTime() <= now) tickets.delete(token);
}
}
// Создаёт тикет загрузки. Тикет многоразовый в пределах TTL (несколько файлов)
// и жёстко привязан к организации ключа.
export function createUploadTicket(data: {
organizationId: number;
apiKeyId: number | null;
botId: number | null;
createdBy: number;
scopes: ApiKeyScopes;
}): { token: string; expiresAt: Date } {
cleanupExpired();
const token = crypto.randomBytes(24).toString('base64url');
const expiresAt = new Date(Date.now() + TICKET_TTL_MS);
tickets.set(token, { token, expiresAt, ...data });
return { token, expiresAt };
}
// Возвращает данные тикета или null (неизвестный/просроченный).
// Тикет НЕ удаляется при использовании — он валиден до конца TTL.
export function consumeUploadTicket(token: string): UploadTicket | null {
cleanupExpired();
const ticket = tickets.get(token);
if (!ticket) return null;
if (ticket.expiresAt.getTime() <= Date.now()) {
tickets.delete(token);
return null;
}
return ticket;
}