# @erp/plugin-sdk SDK для створення плагінів ERP-платформи. Містить бекенд (NestJS) та фронтенд (React) інструменти для інтеграції з ядром. ## Зміст - [Встановлення](#встановлення) - [Швидкий старт](#швидкий-старт) - [Архітектура](#архітектура) - [API Reference](#api-reference) - [CoreService](#coreservice) - [AttributeAccessService](#attributeaccessservice) - [CoreClientService](#coreclientservice) - [getRabbitMQOptions](#getrabbitmqoptions) - [getRabbitMQClientOptions](#getrabbitmqclientoptions) - [TransformInterceptor](#transforminterceptor) - [RequestContextInterceptor](#requestcontextinterceptor) - [AllExceptionsFilter](#allexceptionsfilter) - [Інтерфейси](#інтерфейси) - [Attribute Access Control (ABAC)](#attribute-access-control-abac) - [Події та RabbitMQ](#події-та-rabbitmq) - [Міжплагінна комунікація](#міжплагінна-комунікація) - [CLI — create-erp-plugin](#cli--create-erp-plugin) - [Експорт SDK](#експорт-sdk) --- ## Встановлення ```bash npm install @erp/plugin-sdk ``` ### `.npmrc` SDK опубліковано в Gitea Packages. Додайте в `backend/.npmrc`: ``` @erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/ ``` ### Dockerfile ```dockerfile COPY backend/.npmrc ./backend/ RUN cd backend && npm install ``` --- ## Швидкий старт ```bash npx create-erp-plugin my-plugin --port 3007 ``` Створює scaffold плагіна з: - `backend/` — NestJS бекенд з CRUD, RabbitMQ, TypeORM - `frontend/` — React (Module Federation) фронтенд - `manifest.json` — реєстрація плагіна в ядрі --- ## Архітектура ``` ┌───────────────────┐ HTTP/REST ┌──────────────┐ │ Plugin A (BE) │ ◄──────────────────► │ Core (BE) │ │ CoreService │ RabbitMQ │ Port 3001 │ │ AttributeAccess │ ◄──────────────────► │ │ └────────┬──────────┘ └──────────┬───┘ │ ┌───────────────────────▲ │ │ Plugin→Plugin│ HTTP RPC Proxy │ │ │ v1.3.0 │ │ │ │ ┌──────┴───────┐ │ │ │ │ Plugin B (BE)│ │ │ │ │ callPlugin │───────────────┘ │ │ └──────────────┘ │ │ │ Module Federation Module Fed. │ │ ┌────────▼──────────┐ ┌──────────▼───┐ │ Plugin A (FE) │ │ Plugin B(FE)│ │ React + Vite │ │ React + Vite│ │ host/UIKit │ │ host/UIKit │ └───────────────────┘ └──────────────┘ ``` **Комунікація:** 1. **HTTP (REST)** — `CoreService.fetchCore()` для запитів до ядра 2. **RabbitMQ (асинхронно)** — `CoreService.emit()` для подій, `@EventPattern()` для отримання 3. **Plugin→Plugin RPC** (v1.3.0+) — `CoreService.callPluginApi()` для виклику API іншого плагіна через core-proxy 4. **Module Federation** — фронтенд плагіна монтується як remote module всередині host-застосунку --- ## API Reference ### CoreService Головний сервіс для комунікації плагіна з ядром. ```typescript import { CoreService } from '@erp/plugin-sdk'; ``` #### `fetchCore(endpoint, options?)` HTTP-запит до ядра. Автоматично прокидyє JWT токен (з RequestContext). ```typescript const users = await coreService.fetchCore('/api/admin/users'); const created = await coreService.fetchCore('/api/admin/users', { method: 'POST', body: JSON.stringify({ username: 'newuser', password: '123' }), }); ``` #### `register()` Реєструє плагін в ядрі. Читає `manifest.json`, відправляє POST на `/api/plugins/register`. Повторює з ретраями поки не зареєструється. ```typescript await coreService.register(); ``` #### `emit(pattern, payload)` Асинхронна подія через RabbitMQ (fire-and-forget). ```typescript await coreService.emit('myplugin.data.synced', { recordId: 42 }); ``` #### `auditLog(action, resource, details?)` Запис в аудит-лог ядра. ```typescript await coreService.auditLog('deals.create', 'deal:42', { dealName: 'Test' }); ``` #### `getGlobalData(key)` / `setGlobalData(key, value)` / `deleteGlobalData(key)` Глобальне сховище (key-value) в ядрі. ```typescript await coreService.setGlobalData('myplugin_config', { theme: 'dark' }); const config = await coreService.getGlobalData('myplugin_config'); ``` #### `sendNotification(payload)` Відправка сповіщення користувачу. ```typescript await coreService.sendNotification({ userId: 1, title: 'Deal updated', message: 'Deal #42 has been modified', }); ``` #### `callPluginApi(pluginId, path, body?)` Викликати API іншого плагіна через core RPC-проксі. Запит проксується ядром на `backendUrl` цільового плагіна методом POST. JWT-токен автоматично прокидyється. ```typescript // Викликати ендпоінт плагіна notifications const result = await coreService.callPluginApi('notifications', 'stats', { period: 'week' }); // Викликати з вкладеним шляхом const user = await coreService.callPluginApi('user-management', 'users/42'); ``` #### `findPluginsByEvent(eventName)` Знайти плагіни, які оголосили певну подію у своєму `manifest.json`. ```typescript const listeners = await coreService.findPluginsByEvent('core.user.created'); // listeners = [ { id: 'audit-logs', name: 'Audit Logs', ... } ] ``` #### `emitToPlugin(pluginId, pattern, payload)` Надіслати подію конкретному плагіну через RabbitMQ. Публікує в `system_events` з routing key `plugin..`. Цільовий плагін отримує подію через `@EventPattern()`. ```typescript // Надіслати подію тільки плагіну notifications await coreService.emitToPlugin('notifications', 'send', { userId: 1, title: 'New deal', message: 'Deal #42 created', }); ``` Цільовий плагін обробляє через стандартний `@EventPattern`: ```typescript @EventPattern('plugin.notifications.send') async handle(@Payload() data: any) { const event = data.data || data; // event.payload = { userId: 1, title: 'New deal', ... } } ``` #### `requestPlugin(pluginId, pattern, payload, timeoutMs?)` RPC-виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS RabbitMQ RPC (`send` / `@MessagePattern`). ```typescript const stats = await coreService.requestPlugin('notifications', 'getStats', { period: 'week', }); // stats = { sent: 42, failed: 1 } ``` Цільовий плагін обробляє через `@MessagePattern`: ```typescript @MessagePattern('plugin.notifications.getStats') async getStats(@Payload() data: { period: string }) { // логіка підрахунку... return { sent: 42, failed: 1 }; } ``` Параметри: - `pluginId` — ID цільового плагіна - `pattern` — назва команди (без префікса `plugin..`, він додається автоматично) - `payload` — тіло запиту - `timeoutMs` — таймаут (за замовчуванням 5000 мс). Повертає `null` при перевищенні. #### `discoverPlugins()` Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `id`, `name`, `label`, `icon`, `routePath`, `backendUrl`, `events`, `objectTypes`, `permissions`, `roles`, `database`, `menu`, `headerActions`. ```typescript const plugins = await coreService.discoverPlugins(); // Знайти плагін за id const notifPlugin = plugins.find(p => p.id === 'notifications'); console.log(notifPlugin.backendUrl); // http://notifications:3008 // Знайти всі плагіни, що оголосили певну подію const listeners = plugins.filter(p => p.events?.some(e => e.name === 'core.user.created') ); // Знайти плагіни, що працюють з певним типом об'єктів const dealPlugins = plugins.filter(p => p.objectTypes?.includes('deal') ); ``` #### `getDbName()` Отримати назву БД (після `setupDatabase`). ```typescript const dbName = coreService.getDbName(); ``` #### `static setupDatabase({ pluginId })` Запитує у ядра виділену БД для плагіна. Викликати ДО ініціалізації NestJS. ```typescript // В main.ts перед створенням app const dbName = await CoreService.setupDatabase({ pluginId: 'my-plugin' }); ``` --- ### AttributeAccessService Сервіс для Attribute-Based Access Control (ABAC). Дозволяє керувати атрибутами користувачів і ресурсів та перевіряти доступ на основі правил. ```typescript import { AttributeAccessService } from '@erp/plugin-sdk'; ``` #### Типи ```typescript type AttributeMatch = 'eq' | 'neq' | 'in' | 'contains' | 'exists' | 'gte' | 'lte'; interface AttributeRule { userAttr: string; // ключ атрибута на користувачеві match: AttributeMatch; // тип порівняння resourceAttr?: string; // ключ атрибута на ресурсі value?: string; // або фіксоване значення } interface ResourceRef { type: string; // наприклад 'deal', 'server' id: string; } ``` #### Робота з атрибутами користувачів ```typescript // Встановити атрибут await attrService.setUserAttribute(1, 'team', 'alpha', 'arbitration'); // Встановити кілька атрибутів await attrService.setUserAttributes(1, [ { key: 'team', value: 'alpha' }, { key: 'region', value: 'EU' }, { key: 'clearance', value: '3' }, ], 'arbitration'); // Отримати атрибути const attrs = await attrService.getUserAttributes(1); // Видалити await attrService.deleteUserAttribute(1, 'team'); ``` #### Робота з атрибутами ресурсів ```typescript await attrService.setResourceAttribute('deal', '42', 'team', 'alpha', 'arbitration'); await attrService.setResourceAttributes('deal', '42', [ { key: 'team', value: 'alpha' }, { key: 'region', value: 'EU' }, ], 'arbitration'); const attrs = await attrService.getResourceAttributes('deal', '42'); await attrService.deleteResourceAttribute('deal', '42', 'team'); ``` #### Перевірка доступу ```typescript // 1. Check: чи user в тій самій команді що й deal? const allowed = await attrService.evaluate(1, [ { userAttr: 'team', match: 'eq', resourceAttr: 'team' } ], { type: 'deal', id: '42' }); // 2. Check: чи має user рівень доступу >= 3? const allowed = await attrService.evaluate(1, [ { userAttr: 'clearance', match: 'gte', value: '3' } ]); // 3. Check: чи є user менеджером власника ресурсу? const allowed = await attrService.evaluate(1, [ { userAttr: 'subordinates', match: 'contains', resourceAttr: 'owner_id' } ], { type: 'deal', id: '42' }); ``` #### Фільтрація списку Корисно коли вже маєте список об'єктів і треба відфільтрувати доступні. ```typescript const visibleDeals = await attrService.filter( userId, [{ userAttr: 'team', match: 'eq', resourceAttr: 'team' }], allDeals, (deal) => ({ team: deal.team }) // екстрактор атрибутів з об'єкта ); ``` #### Типи порівняння | `match` | Опис | Приклад | |---------|------|---------| | `eq` | Дорівнює | `user.team == resource.team` | | `neq` | Не дорівнює | `user.region != 'blocked'` | | `in` | Значення user в списку resource | `user.region in resource.allowed_regions` | | `contains` | Список user містить resource | `user.roles contains 'admin'` | | `exists` | Атрибут існує | `user.clearance exists` | | `gte` | >= | `user.clearance >= 3` | | `lte` | <= | `user.priority <= 2` | --- ### CoreClientService Альтернативний спосіб комунікації з ядром через RabbitMQ RPC. ```typescript import { CoreClientService } from '@erp/plugin-sdk'; ``` ```typescript // Запитати дані користувача (синхронно через RabbitMQ RPC) const userInfo = await coreClient.getUserInfo(1); // Сповістити ядро про подію await coreClient.notifyCore('myplugin.event', { data: 'value' }); ``` --- ### getRabbitMQOptions Повертає `MicroserviceOptions` для NestJS. Використовується в `main.ts` для підключення до RabbitMQ. ```typescript import { getRabbitMQOptions } from '@erp/plugin-sdk'; // Звичайне підключення — отримує всі події app.connectMicroservice(getRabbitMQOptions('plugin_my_queue')); // З підтримкою targeted events — також байндить plugin..* const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); app.connectMicroservice(getRabbitMQOptions('plugin_my_queue', undefined, manifest.id)); ``` Параметри: - `queueName` — назва черги - `url?` — RabbitMQ URL (за замовчуванням з `RABBITMQ_URL` або `amqp://localhost:5672`) - `pluginId?` — (v1.4.0+) ID плагіна для додаткового binding `plugin..*` (targeted events) Конфігурація: - Exchange: `system_events` (topic) - Routing keys: `#` (всі події) + `plugin..*` (якщо передано `pluginId`) - Queue: вказана назва (durable) - Повідомлення: persistent --- ### getRabbitMQClientOptions Повертає конфігурацію для `ClientsModule.register()`. ```typescript import { getRabbitMQClientOptions } from '@erp/plugin-sdk'; @Module({ imports: [ ClientsModule.register([ { name: 'RABBITMQ_CLIENT', ...getRabbitMQClientOptions() }, ]), ], }) ``` --- ### TransformInterceptor NestJS interceptor — обгортає всі відповіді в `ApiResponse` формат. ```typescript import { TransformInterceptor } from '@erp/plugin-sdk'; app.useGlobalInterceptors(new TransformInterceptor()); ``` Формат відповіді: ```json { "success": true, "data": { ... }, "meta": { "timestamp": 1716800000000, "traceId": "trace-xxx", "service": "my-plugin" } } ``` --- ### RequestContextInterceptor NestJS interceptor — зберігає `authorization` токен та IP в `AsyncLocalStorage`. Використовується `CoreService.fetchCore()` для автоматичного прокидвання JWT. ```typescript import { RequestContextInterceptor } from '@erp/plugin-sdk'; app.useGlobalInterceptors(new RequestContextInterceptor()); ``` --- ### AllExceptionsFilter NestJS exception filter — перехоплює всі необроблені помилки і повертає стандартизований `ApiResponse` з помилкою. ```typescript import { AllExceptionsFilter } from '@erp/plugin-sdk'; app.useGlobalFilters(new AllExceptionsFilter()); ``` Формат помилки: ```json { "success": false, "error": { "code": "ERR_404", "message": "Item not found", "details": null }, "meta": { "timestamp": 1716800000000, "traceId": "uuid", "service": "my-plugin" } } ``` --- ### Інтерфейси #### `ApiResponse` Стандартна відповідь API. ```typescript interface ApiResponse { success: boolean; data?: T; error?: { code: string; message: string; details?: any }; meta: { timestamp: number; traceId: string; service: string }; } ``` #### `SystemEvent` Конверт для асинхронних подій RabbitMQ. ```typescript interface SystemEvent { eventId: string; pattern: string; payload: T; source: string; timestamp: number; priority?: 'low' | 'medium' | 'high'; } ``` #### `host/UIKit` (фронтенд) Типи для компонентів інтерфейсу, що надаються host-застосунком: ```typescript import { Button, Input, Card, Typography } from 'host/UIKit'; ``` --- ## Attribute Access Control (ABAC) Система атрибутивного контролю доступу дозволяє плагінам визначати правила доступу на основі атрибутів користувачів і ресурсів, не прив'язуючи ядро до конкретних атрибутів. ### Як це працює 1. **Плагін визначає атрибути** — наприклад `team`, `region`, `clearance` 2. **Ядро зберігає атрибути** — key-value для юзерів і ресурсів 3. **Плагін задає правила** — наприклад `user.team == resource.team` 4. **Ядро перевіряє** — порівнює атрибути згідно з правилами ### Приклад: арбітражний плагін ```typescript // 1. Адмін призначає атрибути await attrService.setUserAttributes(userId, [ { key: 'team', value: 'alpha' }, { key: 'region', value: 'EU' }, ], 'arbitration'); // 2. При створенні deal, зберігаємо атрибути ресурсу await attrService.setResourceAttributes('deal', dealId, [ { key: 'team', value: deal.team }, { key: 'region', value: deal.region }, ], 'arbitration'); // 3. При перегляді — перевіряємо const canView = await attrService.evaluate(userId, [ { userAttr: 'team', match: 'eq', resourceAttr: 'team' }, { userAttr: 'region', match: 'eq', resourceAttr: 'region' }, ], { type: 'deal', id: dealId }); if (!canView) throw new ForbiddenException(); ``` ### Раунд-тріп vs локальна фільтрація `evaluate()` робить HTTP-запит до ядра (перевіряє атрибути ресурсу в БД). `filter()` робить один запит для отримання атрибутів юзера, потім фільтрує локально. ### Інтеграція з RBAC ```typescript async viewDeal(userId: number, dealId: string) { // 1. RBAC — чи має дозвіл const hasPerm = await this.usersService.hasPermission(userId, 'deals.view'); if (!hasPerm) throw new ForbiddenException(); // 2. ABAC — чи збігаються атрибути const access = await this.attributeService.evaluate(userId, [ { userAttr: 'team', match: 'eq', resourceAttr: 'team' } ], { type: 'deal', id: dealId }); if (!access) throw new ForbiddenException(); return this.dealRepo.findOne(dealId); } ``` --- ## Події та RabbitMQ ### Відправка подій (emit) ```typescript // З плагіна в ядро (або інші плагіни) await coreService.emit('myplugin.data.updated', { id: 42 }); // Конверт автоматично обгортається в SystemEvent: // { eventId, pattern: 'myplugin.data.updated', payload: { id: 42 }, source: 'my-plugin', timestamp } ``` ### Отримання подій (@EventPattern) ```typescript import { EventPattern, Payload } from '@nestjs/microservices'; @Injectable() export class MyHandler { @EventPattern('core.search.request') async handle(@Payload() data: any) { const payload = data.payload || data; // обробка... } } ``` Підключення мікросервісу в `main.ts`: ```typescript import { getRabbitMQOptions } from '@erp/plugin-sdk'; app.connectMicroservice(getRabbitMQOptions('plugin_my_queue')); await app.startAllMicroservices(); ``` ### Шаблон "Запит-Відповідь" (Request/Response) Для двосторонньої комунікації через події: ```typescript // Core відправляє запит coreService.emit('plugin.some.request', { requestId, ... }); // Плагін відповідає @EventPattern('plugin.some.request') async handle(@Payload() data: any) { const { requestId } = data.payload || data; this.client.emit('core.some.response', { requestId, result: 'ok' }); } ``` --- ## Міжплагінна комунікація До версії 1.3.0 плагіни могли спілкуватись тільки через ядро (HTTP до core + RabbitMQ broadcast). Починаючи з v1.3.0 додано підтримку прямого плагін→плагін зв'язку через HTTP RPC проксі. ### Архітектура ``` Plugin A Core Backend Plugin B | | | |-- discoverPlugins() ----------->| | |<-- [{ id:'notifications', ... }]| | | | | |-- callPluginApi('notifications', | | | 'stats', { period:'week' })-->| | | |-- POST /stats -------------->| | |<-- { sent: 42 } ------------| |<-- { sent: 42 } ----------------| | ``` Всі запити проходять через Core, який: 1. **Перевіряє JWT** (як і будь-який захищений ендпоінт) 2. **Визначає `backendUrl`** цільового плагіна з реєстру 3. **Проксує POST-запит** з автентифікацією та тілом ### Усі способи комунікації | Метод | Тип | Канал | Адресат | |-------|-----|-------|---------| | `callPluginApi()` | Sync HTTP RPC | HTTP через core-proxy | Конкретний плагін | | `emitToPlugin()` | Async event | RabbitMQ `plugin..` | Конкретний плагін | | `requestPlugin()` | Sync RPC | RabbitMQ `send/@MessagePattern` | Конкретний плагін | | `emit()` | Async event | RabbitMQ broadcast | Всі плагіни | ### 1. Discovery ```typescript // Всі зареєстровані плагіни const plugins = await coreService.discoverPlugins(); // Знайти плагіни за подією const listeners = await coreService.findPluginsByEvent('core.user.created'); ``` Кожен плагін має: - `id`, `name`, `label`, `backendUrl` — адреса для HTTP RPC - `events: { name, description }[]` — які події оголошує - `objectTypes: string[]` — з якими об'єктами працює - `permissions`, `roles` — права доступу ### 2. Targeted Events (RabbitMQ) Надіслати подію конкретному плагіну через RabbitMQ. Подія публікується в `system_events` з routing key `plugin..`. Цільовий плагін має додатковий binding `plugin..*` (якщо передано `pluginId` у `getRabbitMQOptions`). ```typescript // Відправник await coreService.emitToPlugin('notifications', 'send', { userId: 1, title: 'New deal', message: 'Deal #42 created', }); // Отримувач (notifications plugin) @EventPattern('plugin.notifications.send') async handle(@Payload() data: any) { const event = data.data || data; console.log(event.payload.title); // 'New deal' } ``` **Підключення отримувача** (в `main.ts`): ```typescript const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8')); app.connectMicroservice( getRabbitMQOptions('plugin_notifications_queue', undefined, manifest.id) ); // Черга байндиться з routing keys: # (всі події) + plugin.notifications.* (targeted) ``` ### 3. RPC Request/Response (RabbitMQ) Синхронний виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS `send()` / `@MessagePattern()`. ```typescript // Відправник const stats = await coreService.requestPlugin('notifications', 'getStats', { period: 'week', }); console.log(stats); // { sent: 42, failed: 1 } // Отримувач (notifications plugin) @MessagePattern('plugin.notifications.getStats') async getStats(@Payload() data: { period: string }) { const stats = await this.calcStats(data.period); return stats; // повертається відправнику } ``` Параметри `requestPlugin`: - `pluginId` — ID цільового плагіна - `pattern` — назва команди (без префікса) - `payload` — тіло запиту - `timeoutMs` — таймаут у мс (за замовчуванням 5000). `null` при перевищенні. ### 4. HTTP RPC через core-proxy ```typescript const stats = await coreService.callPluginApi('notifications', 'stats'); const user = await coreService.callPluginApi('user-management', 'users/42'); const result = await coreService.callPluginApi('global-store', 'store/set', { key: 'my-key', value: { foo: 'bar' }, }); ``` **Обмеження:** - Тільки POST-метод - Потребує JWT-токена (автоматично прокидyється з контексту) - Якщо плагін не зареєстрований або без `backendUrl` — 404 ### Типовий сценарій ```typescript async function notifyAndLog() { // 1. Discovery const [notifPlugin] = await this.coreService.findPluginsByEvent('notification.send'); // 2. Targeted event if (notifPlugin) { await this.coreService.emitToPlugin('notifications', 'send', { userId: 1, title: 'Event occurred', message: 'Plugin A triggered a notification', }); } // 3. RPC запит до audit-logs const status = await this.coreService.requestPlugin('audit-logs', 'dailyStats', {}); } --- ## CLI — create-erp-plugin Генерація нового плагіна: ```bash npx create-erp-plugin [--port ] ``` ### Параметри | Аргумент | Опис | |----------|------| | `plugin-name` | Назва в kebab-case (наприклад `my-plugin`) | | `--port`, `-p` | Порт (1024–65535, за замовчуванням 3002) | ### Режими БД У `manifest.json`: | `database` | Опис | |------------|------| | `"self"` (default) | Плагін використовує спільну БД через `POSTGRES_*` змінні | | `"provide"` | Ядро створює виділену БД `p_` | ### Структура згенерованого плагіна ``` my-plugin/ ├── manifest.json # Реєстраційний маніфест ├── backend/ │ ├── .npmrc # @erp:registry=... │ ├── package.json │ ├── tsconfig.json │ ├── nest-cli.json │ ├── Dockerfile │ └── src/ │ ├── main.ts │ ├── app.module.ts │ ├── app.controller.ts │ ├── ping.controller.ts │ ├── item.controller.ts │ ├── item.service.ts │ └── entities/ │ └── item.entity.ts ├── frontend/ │ ├── package.json │ ├── tsconfig.json │ ├── vite.config.ts │ ├── index.html │ └── src/ │ └── App.tsx └── k8s-plugin.yaml ``` --- ## Експорт SDK ``` @erp/plugin-sdk ├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...) ├── CoreClientService # RabbitMQ RPC клієнт ├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу ├── getRabbitMQOptions() # NestJS Microservice options ├── getRabbitMQClientOptions() # NestJS Client options ├── TransformInterceptor # Обгортка відповідей в ApiResponse ├── RequestContextInterceptor # AsyncLocalStorage для JWT ├── AllExceptionsFilter # Глобальний фільтр помилок ├── ApiResponse # Інтерфейс відповіді ├── SystemEvent # Інтерфейс події └── host/UIKit (types) # Типи UI компонентів ```