import crypto from 'node:crypto'; export class InvalidCursorError extends Error { readonly code = 'INVALID_CURSOR'; constructor(message = 'Курсор синхронизации недействителен или повреждён') { super(message); this.name = 'InvalidCursorError'; } } const CURSOR_PREFIX = 'cur_'; const CURSOR_SALT = 'krona_staff_sync_cursor_salt_v1'; export function encodeCursor(tenantId: string, seq: bigint | number | string): string { const seqStr = String(seq); const hash = crypto .createHash('sha256') .update(`${tenantId}:${seqStr}:${CURSOR_SALT}`) .digest('hex') .slice(0, 12); return `${CURSOR_PREFIX}${seqStr}_${hash}`; } export function decodeCursor(tenantId: string, cursor: string): bigint { if (typeof cursor !== 'string' || !cursor) { throw new InvalidCursorError('Курсор синхронизации не задан'); } const match = cursor.match(/^cur_(\d+)_([0-9a-f]{12})$/); if (!match) { throw new InvalidCursorError('Неверный формат курсора синхронизации'); } const seqStr = match[1]!; const candidateHash = match[2]!; const expectedHash = crypto .createHash('sha256') .update(`${tenantId}:${seqStr}:${CURSOR_SALT}`) .digest('hex') .slice(0, 12); const candidateBuffer = Buffer.from(candidateHash, 'utf8'); const expectedBuffer = Buffer.from(expectedHash, 'utf8'); if ( candidateBuffer.length !== expectedBuffer.length || !crypto.timingSafeEqual(candidateBuffer, expectedBuffer) ) { throw new InvalidCursorError( 'Подпись курсора синхронизации недействительна для данного арендатора', ); } return BigInt(seqStr); }