import crypto from 'crypto'; import type { ApiKeyScopes } from '@shared/schema'; function getHmacSecret(): string { const secret = process.env.API_KEY_HMAC_SECRET; if (!secret) { throw new Error( 'API_KEY_HMAC_SECRET environment variable is required for API key hashing. ' + 'Generate with: openssl rand -hex 32' ); } return secret; } export function hashApiKey(raw: string): string { return crypto .createHmac('sha256', getHmacSecret()) .update(raw) .digest('hex'); } export function verifyApiKey(raw: string, hash: string): boolean { const expected = hashApiKey(raw); if (expected.length !== hash.length) return false; return crypto.timingSafeEqual( Buffer.from(expected, 'hex'), Buffer.from(hash, 'hex') ); } export function legacySha256Hash(raw: string): string { return crypto.createHash('sha256').update(raw).digest('hex'); } // Полный доступ — используется как дефолт для legacy-ключей (scopes = NULL в БД) export const FULL_API_KEY_SCOPES: ApiKeyScopes = { mode: 'full', formIds: null, tableIds: null }; // Нормализация скоупов из БД: NULL/отсутствующий или частично заполненный объект // приводится к полной структуре ApiKeyScopes (дефолты — полный доступ). export function normalizeApiKeyScopes(scopes: unknown): ApiKeyScopes { if (!scopes || typeof scopes !== 'object' || Array.isArray(scopes)) { return { ...FULL_API_KEY_SCOPES }; } const s = scopes as Partial; return { mode: s.mode === 'read' || s.mode === 'write' || s.mode === 'full' ? s.mode : 'full', formIds: Array.isArray(s.formIds) ? s.formIds.filter((n): n is number => typeof n === 'number' && Number.isInteger(n)) : null, tableIds: Array.isArray(s.tableIds) ? s.tableIds.filter((n): n is number => typeof n === 'number' && Number.isInteger(n)) : null, }; } // Строгая валидация скоупов, присланных клиентом (POST/PATCH /api/mcp-keys). // Возвращает { ok: true, scopes } либо { ok: false, error } с русским сообщением. export function parseApiKeyScopesInput( input: unknown ): { ok: true; scopes: ApiKeyScopes } | { ok: false; error: string } { if (!input || typeof input !== 'object' || Array.isArray(input)) { return { ok: false, error: 'Поле scopes должно быть объектом { mode, formIds, tableIds }' }; } const s = input as Record; if (s.mode !== 'read' && s.mode !== 'write' && s.mode !== 'full') { return { ok: false, error: "Поле scopes.mode должно быть одним из: 'read', 'write', 'full'" }; } const parseIds = (value: unknown, field: string): number[] | null | { error: string } => { if (value === null || value === undefined) return null; if (!Array.isArray(value) || !value.every((n) => typeof n === 'number' && Number.isInteger(n))) { return { error: `Поле scopes.${field} должно быть массивом целых чисел или null` }; } return value as number[]; }; const formIds = parseIds(s.formIds, 'formIds'); if (formIds !== null && typeof formIds === 'object' && 'error' in formIds) { return { ok: false, error: formIds.error }; } const tableIds = parseIds(s.tableIds, 'tableIds'); if (tableIds !== null && typeof tableIds === 'object' && 'error' in tableIds) { return { ok: false, error: tableIds.error }; } return { ok: true, scopes: { mode: s.mode, formIds, tableIds } }; }