Go to file
Кирилл Осипков 755a093b61 fix: move @nestjs/*, rxjs, reflect-metadata to peerDependencies
instanceof checks for NestJS classes (TransformInterceptor, guards, etc.)
now work correctly in consuming plugins because they share the same
NestJS singletons instead of getting a separate copy from the SDK.
2026-05-27 15:10:51 +03:00
src docs: add comprehensive README with full SDK API reference 2026-05-27 14:00:12 +03:00
.gitignore chore: add .npmrc to gitignore 2026-05-27 14:01:07 +03:00
README.md docs: add comprehensive README with full SDK API reference 2026-05-27 14:00:12 +03:00
package-lock.json fix: move all deps to dependencies for npm sub-dependency install 2026-05-26 22:57:14 +03:00
package.json fix: move @nestjs/*, rxjs, reflect-metadata to peerDependencies 2026-05-27 15:10:51 +03:00
tsconfig.json Initial commit of ERP Plugin SDK 2026-05-22 14:40:26 +03:00

README.md

@erp/plugin-sdk

SDK для створення плагінів ERP-платформи. Містить бекенд (NestJS) та фронтенд (React) інструменти для інтеграції з ядром.

Зміст


Встановлення

npm install @erp/plugin-sdk

.npmrc

SDK опубліковано в Gitea Packages. Додайте в backend/.npmrc:

@erp:registry=https://gitea.ss.3w.com.ua/api/packages/erp/npm/

Dockerfile

COPY backend/.npmrc ./backend/
RUN cd backend && npm install

Швидкий старт

npx create-erp-plugin my-plugin --port 3007

Створює scaffold плагіна з:

  • backend/ — NestJS бекенд з CRUD, RabbitMQ, TypeORM
  • frontend/ — React (Module Federation) фронтенд
  • manifest.json — реєстрація плагіна в ядрі

Архітектура

┌───────────────────┐       HTTP/REST       ┌──────────────┐
│    Plugin (BE)    │ ◄──────────────────►  │  Core (BE)   │
│  CoreService      │       RabbitMQ        │  Port 3001   │
│  AttributeAccess  │ ◄──────────────────►  │              │
└────────┬──────────┘                       └──────────────┘
         │
    Module Federation
         │
┌────────▼──────────┐
│   Plugin (FE)     │
│  React + Vite     │
│  host/UIKit       │
└───────────────────┘

Комунікація:

  1. HTTP (REST)CoreService.fetchCore() для запитів до ядра
  2. RabbitMQ (асинхронно)CoreService.emit() для подій, @EventPattern() для отримання
  3. Module Federation — фронтенд плагіна монтується як remote module всередині host-застосунку

API Reference

CoreService

Головний сервіс для комунікації плагіна з ядром.

import { CoreService } from '@erp/plugin-sdk';

fetchCore(endpoint, options?)

HTTP-запит до ядра. Автоматично прокидyє JWT токен (з RequestContext).

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. Повторює з ретраями поки не зареєструється.

await coreService.register();

emit<T>(pattern, payload)

Асинхронна подія через RabbitMQ (fire-and-forget).

await coreService.emit('myplugin.data.synced', { recordId: 42 });

auditLog(action, resource, details?)

Запис в аудит-лог ядра.

await coreService.auditLog('deals.create', 'deal:42', { dealName: 'Test' });

getGlobalData<T>(key) / setGlobalData(key, value) / deleteGlobalData(key)

Глобальне сховище (key-value) в ядрі.

await coreService.setGlobalData('myplugin_config', { theme: 'dark' });
const config = await coreService.getGlobalData('myplugin_config');

sendNotification(payload)

Відправка сповіщення користувачу.

await coreService.sendNotification({
  userId: 1,
  title: 'Deal updated',
  message: 'Deal #42 has been modified',
});

getDbName()

Отримати назву БД (після setupDatabase).

const dbName = coreService.getDbName();

static setupDatabase({ pluginId })

Запитує у ядра виділену БД для плагіна. Викликати ДО ініціалізації NestJS.

// В main.ts перед створенням app
const dbName = await CoreService.setupDatabase({ pluginId: 'my-plugin' });

AttributeAccessService

Сервіс для Attribute-Based Access Control (ABAC). Дозволяє керувати атрибутами користувачів і ресурсів та перевіряти доступ на основі правил.

import { AttributeAccessService } from '@erp/plugin-sdk';

Типи

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;
}

Робота з атрибутами користувачів

// Встановити атрибут
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');

Робота з атрибутами ресурсів

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');

Перевірка доступу

// 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' });

Фільтрація списку

Корисно коли вже маєте список об'єктів і треба відфільтрувати доступні.

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.

import { CoreClientService } from '@erp/plugin-sdk';
// Запитати дані користувача (синхронно через RabbitMQ RPC)
const userInfo = await coreClient.getUserInfo(1);

// Сповістити ядро про подію
await coreClient.notifyCore('myplugin.event', { data: 'value' });

getRabbitMQOptions

Повертає MicroserviceOptions для NestJS. Використовується в main.ts для підключення до RabbitMQ.

import { getRabbitMQOptions } from '@erp/plugin-sdk';

app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));

Конфігурація:

  • Exchange: system_events (topic)
  • Routing key: # (отримує всі події)
  • Queue: вказана назва (durable)
  • Повідомлення: persistent

getRabbitMQClientOptions

Повертає конфігурацію для ClientsModule.register().

import { getRabbitMQClientOptions } from '@erp/plugin-sdk';

@Module({
  imports: [
    ClientsModule.register([
      { name: 'RABBITMQ_CLIENT', ...getRabbitMQClientOptions() },
    ]),
  ],
})

TransformInterceptor

NestJS interceptor — обгортає всі відповіді в ApiResponse формат.

import { TransformInterceptor } from '@erp/plugin-sdk';

app.useGlobalInterceptors(new TransformInterceptor());

Формат відповіді:

{
  "success": true,
  "data": { ... },
  "meta": {
    "timestamp": 1716800000000,
    "traceId": "trace-xxx",
    "service": "my-plugin"
  }
}

RequestContextInterceptor

NestJS interceptor — зберігає authorization токен та IP в AsyncLocalStorage. Використовується CoreService.fetchCore() для автоматичного прокидвання JWT.

import { RequestContextInterceptor } from '@erp/plugin-sdk';

app.useGlobalInterceptors(new RequestContextInterceptor());

AllExceptionsFilter

NestJS exception filter — перехоплює всі необроблені помилки і повертає стандартизований ApiResponse з помилкою.

import { AllExceptionsFilter } from '@erp/plugin-sdk';

app.useGlobalFilters(new AllExceptionsFilter());

Формат помилки:

{
  "success": false,
  "error": {
    "code": "ERR_404",
    "message": "Item not found",
    "details": null
  },
  "meta": {
    "timestamp": 1716800000000,
    "traceId": "uuid",
    "service": "my-plugin"
  }
}

Інтерфейси

ApiResponse<T>

Стандартна відповідь API.

interface ApiResponse<T = any> {
  success: boolean;
  data?: T;
  error?: { code: string; message: string; details?: any };
  meta: { timestamp: number; traceId: string; service: string };
}

SystemEvent<T>

Конверт для асинхронних подій RabbitMQ.

interface SystemEvent<T = any> {
  eventId: string;
  pattern: string;
  payload: T;
  source: string;
  timestamp: number;
  priority?: 'low' | 'medium' | 'high';
}

host/UIKit (фронтенд)

Типи для компонентів інтерфейсу, що надаються host-застосунком:

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. Ядро перевіряє — порівнює атрибути згідно з правилами

Приклад: арбітражний плагін

// 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

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)

// З плагіна в ядро (або інші плагіни)
await coreService.emit('myplugin.data.updated', { id: 42 });

// Конверт автоматично обгортається в SystemEvent:
// { eventId, pattern: 'myplugin.data.updated', payload: { id: 42 }, source: 'my-plugin', timestamp }

Отримання подій (@EventPattern)

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:

import { getRabbitMQOptions } from '@erp/plugin-sdk';

app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
await app.startAllMicroservices();

Шаблон "Запит-Відповідь" (Request/Response)

Для двосторонньої комунікації через події:

// 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' });
}

CLI — create-erp-plugin

Генерація нового плагіна:

npx create-erp-plugin <plugin-name> [--port <port>]

Параметри

Аргумент Опис
plugin-name Назва в kebab-case (наприклад my-plugin)
--port, -p Порт (102465535, за замовчуванням 3002)

Режими БД

У manifest.json:

database Опис
"self" (default) Плагін використовує спільну БД через POSTGRES_* змінні
"provide" Ядро створює виділену БД p_<pluginId>

Структура згенерованого плагіна

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, ...)
├── 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 компонентів