# @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'); ``` #### `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')); ``` Конфігурація: - Exchange: `system_events` (topic) - Routing key: `#` (отримує всі події) - 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-запит** з автентифікацією та тілом ### Discover — дізнатись які плагіни доступні ```typescript const plugins = await coreService.discoverPlugins(); ``` Кожен плагін має: - `id`, `name`, `label`, `backendUrl` — адреса для RPC - `events: { name, description }[]` — які події оголошує - `objectTypes: string[]` — з якими об'єктами працює - `permissions`, `roles` — права доступу ### RPC — викликати API іншого плагіна ```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-метод (ядро проксує POST на `backendUrl` цільового плагіна). Для GET-ендпоінтів цільового плагіна — використовуйте `@Post()` контролер з потрібною логікою. - Потребує JWT-токена (як і будь-який захищений API). Токен автоматично прокидyється з контексту запиту. - Якщо плагін не зареєстрований або не має `backendUrl` — повертається 404. ### Типовий сценарій використання ```typescript // В сервісі плагіна A async function notifyAndLog() { // 1. Discovery — знайти потрібний плагін const plugins = await this.coreService.discoverPlugins(); const notifPlugin = plugins.find(p => p.events?.some(e => e.name === 'notification.send')); // 2. RPC — викликати API плагіна B if (notifPlugin) { await this.coreService.callPluginApi('notifications', 'send', { userId: 1, title: 'Event occurred', message: 'Plugin A triggered a notification', }); } // 3. Або викликати будь-який кастомний ендпоінт const status = await this.coreService.callPluginApi('audit-logs', 'stats/daily'); } ``` --- ## 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, ...) ├── 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 компонентів ```