Skip to content

Как написать Module Federation tool для Aseller

Аудитория: разработчики, которые делают tool для экосистемы Aseller (свой frontend + свой backend) и хотят встроить его в Host UI Платформы.

Версия документа: 2026-06-08. Описывает MVP-состояние api-new (этапы E1/E2/E3 готовы; E4/E5/E6 ещё нет). Источники истины: api-new/docs/migration/tech-spec.md, AUTH_INTROSPECTION.md, MCP_CLAUDE_DESKTOP_GUIDE.md, CHECKPOINTS.md.

Что такое MF tool в нашей архитектуре

api-new — ядро экосистемы Aseller. Один backend на Fastify 5 + Sequelize + PostgreSQL + Redis. Один JWT-issuer, одна система permissions, один реестр инструментов в таблице external_tools.

Tool — это независимый сервис: свой репозиторий, своя команда, свой CI/CD, своя БД. У tool две части:

  • Frontend — Vue/React приложение, собранное как Module Federation remote. Host UI Платформы динамически загружает его и монтирует в нужную вкладку.
  • Backend — отдельный API (стек на ваше усмотрение). Принимает Bearer JWT от пользователя, валидирует его через POST /api/v1/auth/validate в api-new и работает напрямую с фронтом tool'а.

Tool появляется у юзера в Host UI только если админ зарегистрировал его в api-new и юзеру выдан соответствующий permission.

iframe запрещён

MF — единственный способ встраивания tool'а в Host UI (tech-spec §A12, business §П9). iframe-альтернативы нет: единый design-system, единый канал передачи контекста (mount → unmount → mount), нативный UX.

api-new — не универсальный прокси. ~90 % трафика идёт MF-frontend → tool backend напрямую, минуя api-new (tech-spec §A3). api-new вмешивается только при tools.call через MCP (см. ниже) и при token introspection.

Контракт frontend

MF-remote обязан экспонировать модуль с двумя функциями:

ts
export function mount(element: HTMLElement, hostContext: HostContext): void
export function unmount(element: HTMLElement): void

mount должен быть идемпотентным: повторный вызов с тем же element ре-рендерит remote с новым контекстом (например, при смене темы или языка), а не пересоздаёт корневой компонент.

HostContext

Минимальный контекст, который Host UI передаёт в remote:

ts
interface HostContext {
  getToken: () => string | null              // текущий ACCESS-JWT
  user: { id: string; role: number }          // identity
  language: 'en' | 'ru' | 'ua' | 'zh'
  theme: 'light' | 'dark'
  onUnauthorized: () => void                  // вызов при 401 от backend
  onNavigate?: (path: string) => void         // опционально для deeplink внутри Host UI
}

Не лезьте в localStorage/cookie Host UI напрямую — токен получается только через hostContext.getToken(). Это позволит Host UI прозрачно подменять токен при refresh.

Пример Vue 3 + Vite + Module Federation

vite.config.ts:

ts
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import federation from '@originjs/vite-plugin-federation'

export default defineConfig({
  plugins: [
    vue(),
    federation({
      name: 'demoTool',                       // совпадает с ExternalTool.mfName
      filename: 'remoteEntry.js',
      exposes: {
        './app': './src/mount.ts'             // совпадает с ExternalTool.mfExposedModule
      },
      shared: {}                              // полная изоляция от Host UI
    })
  ],
  build: {target: 'esnext', minify: false, cssCodeSplit: false}
})

src/mount.ts:

ts
import {createApp, type App as VueApp} from 'vue'
import App from './App.vue'

const instances = new WeakMap<HTMLElement, VueApp>()

export function mount(element: HTMLElement, hostContext: HostContext) {
  let app = instances.get(element)
  if (app) {
    // идемпотентность: обновляем provide-инжекшен новыми значениями
    app.provide('hostContext', hostContext)
    return
  }
  app = createApp(App)
  app.provide('hostContext', hostContext)
  app.mount(element)
  instances.set(element, app)
}

export function unmount(element: HTMLElement) {
  const app = instances.get(element)
  app?.unmount()
  instances.delete(element)
}

Аналогично делается на React (через ReactDOM.createRoot с такой же WeakMap<element, root>).

Standalone доступ

Tool обязан открываться по своему URL отдельно от Host UI — для прямых ссылок из писем, документации, deeplink (tech-spec §3.4, §A13).

Алгоритм при загрузке standalone:

  1. Tool читает JWT из localStorage (или собственного хранилища).
  2. Если токена нет / истёк — window.location.replace(...):
    https://platform.aseller.io/login?returnTo=<encoded current URL>
  3. Host UI логинит юзера и возвращает 302 на исходный URL с токенами в URL fragment (не query!):
    https://your-tool.aseller.io/some-path#access_token=...&refresh_token=...&expires_in=60
  4. Tool парсит fragment, кладёт токены в storage и сразу делает history.replaceState({}, '', location.pathname + location.search) — чтобы убрать токены из URL.

Fragment не отправляется на backend, не пишется в server-side логи и не покидает браузер до явного парсинга — это защита от случайной утечки.

Откуда брать host login URL

Из конфига вашего tool'а (env-переменная типа VITE_HOST_LOGIN_URL). Не хардкодьте домен — у нас три окружения: dev / stage / prod.

Контракт backend

Tool backend принимает Authorization: Bearer <user-JWT> и не валидирует JWT локально. JWT_KEY находится только в api-new и вам не отдают — это сознательное решение (tech-spec §A14).

Вместо локального jwt.verify — HTTP-вызов token introspection:

POST https://<api-new-host>/api/v1/auth/validate
Authorization: Bearer <user-JWT>

Ответ всегда 200 OK, проверяете поле valid. Полный гайд — api-new/docs/migration/AUTH_INTROSPECTION.md. Кратко:

Реалистичный пример клиента (Node.js)

js
import crypto from 'node:crypto'

const CACHE_TTL_MS = 30_000
const cache = new Map()                       // sha256(token) → { payload, expiresAt }

const AUTH_VALIDATE_URL = process.env.AUTH_VALIDATE_URL
// например https://api-new.aseller.io/api/v1/auth/validate

export async function validateToken(token) {
  const key = crypto.createHash('sha256').update(token).digest('hex')
  const cached = cache.get(key)
  if (cached && cached.expiresAt > Date.now()) return cached.payload

  const res = await fetch(AUTH_VALIDATE_URL, {
    method: 'POST',
    headers: {Authorization: `Bearer ${token}`}
  })
  const payload = await res.json()
  cache.set(key, {payload, expiresAt: Date.now() + CACHE_TTL_MS})
  return payload
}

Express-middleware:

js
app.use(async (req, res, next) => {
  const auth = req.headers.authorization
  if (!auth?.startsWith('Bearer ')) {
    return res.status(401).json({error: 'missing_authorization'})
  }
  const result = await validateToken(auth.slice(7))
  if (!result.valid) {
    return res.status(401).json({error: result.reason})
  }
  req.user = result.user                      // { id, role, email, name }
  next()
})

Reason codes

payload.reason (при valid: false) — один из:

reasonКогда возникает
missing_authorizationНет header Authorization
unsupported_authorization_typeНе Bearer или пустой токен
invalid_or_expiredjwt.verify упал (плохая подпись / истёк)
wrong_token_typeПередан REFRESH вместо ACCESS
user_pending_confirmationЮзер ещё не подтвердил регистрацию (role 50)
session_revokedСессия отозвана в Redis multi-session (logout / превышен лимит сессий)

Источник: api-new/core/services/v1/auth/auth.service.js. Полный shape ответа — там же.

Правила кеша

  • TTL — 30 секунд, не больше. Дольше — теряете быструю реакцию на revocation.
  • Ключ — sha256(token). Не храните plaintext.
  • Кеш per-process (Map) или Redis — оба ОК. Для нескольких реплик предпочтительнее Redis.
  • Circuit breaker: если api-new недоступен дольше N секунд — отвечайте 503, не «open mode».

Чего НЕ делать

  • ❌ Не просите JWT_KEY — его не дают.
  • ❌ Не подключайте jsonwebtoken / jwks-client в зависимости.
  • ❌ Не используйте /.well-known/jwks.json — там пока пустой keys[] (HS256, нет публичного ключа). Endpoint оставлен на будущее под RS256 (tech-spec §5).

Регистрация tool'а в реестре

Tool регистрирует админ Платформы одной POST-командой к api-new:

bash
curl -X POST https://api-new.aseller.io/api/v1/admins/tools \
  -H "Authorization: Bearer <admin access JWT>" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Demo Tool",
    "code": "demo_tool",
    "toolUrl": "https://demo-tool.aseller.io",
    "standaloneUrl": "https://demo-tool.aseller.io",
    "mfName": "demoTool",
    "mfRemoteEntry": "https://demo-tool.aseller.io/remoteEntry.js",
    "mfExposedModule": "./app",
    "backendBaseUrl": "https://demo-tool.aseller.io/api",
    "swaggerUrl": "https://demo-tool.aseller.io/openapi.json",
    "allowedRoles": ["10", "20"],
    "permissionKey": "tool.demo_tool",
    "mcpEnabled": false,
    "isActive": true
  }'

Поля payload

ПолеНазначениеОбязательное
titleОтображаемое названиеда
toolUrlОсновной URL (легаси / fallback для standaloneUrl)да
codeУникальный slug, используется в permissionKey и фильтрахнет, но рекомендуется
mfNameИмя MF-контейнера (federation.name в Vite)нет
mfRemoteEntryURL remoteEntry.js (или mf-manifest.json)нет
mfExposedModuleПуть exposed module (./app)нет
backendBaseUrlBase URL backend tool'а (для MCP-агрегации)нет
swaggerUrlURL OpenAPI-спеки (для MCP-агрегации)нет
standaloneUrlURL для прямого открытия. Если пусто — берётся toolUrlнет
allowedRolesМассив строковых кодов ролей: "0" ADMIN, "10" CLIENT, "20" SUPERVISOR и т.д.да
permissionKeyУникальный ключ permission. См. ниженет
mcpEnabledВключить tool в tools/list MCP-агрегации (готовность — E6)нет, default false
isActiveВиден ли в /mf-registryнет, default true

Точное DTO — api-new/core/common/schemas/admins/tools/_adminCreateToolDtoSchema.js.

permissionKey → permission

При POST с permissionKey: "tool.demo_tool" api-new в той же транзакции:

  1. Создаёт external_tools запись.
  2. Через Permission.findOrCreate создаёт запись в permissions c allowedUrls: ['mf-tool:demo_tool'] и role: 0.

Permission видна только админам. Выдаётся юзерам через существующий механизм Платформы: permissions_users напрямую или permission_groups_userspermission_groups_permissions. Никакого отдельного UI или endpoint для этого делать не нужно.

При PATCH с новым permissionKey — то же самое для нового ключа. При DELETE tool'а — permission получает status=100 (soft-delete), grants не удаляются для audit.

Как tool появляется у юзера

Host UI делает один запрос:

GET /api/v1/integrations/mf-registry
Authorization: Bearer <user-JWT>

Фильтр на стороне api-new (mfRegistry.service.js):

is_active = true
AND allowed_roles @> [user.role]
AND (
  user.role = 0 (ADMIN)              -- админ видит всё
  OR permission_key IS NULL          -- публичный tool
  OR permission_key ∈ effective permissions юзера
)

Effective permissions берутся из session:permissions:{userId} (Redis-кеш, наполняется логином).

Реальный response shape

json
{
  "count": 1,
  "rows": [
    {
      "_id": "uuid",
      "code": "demo_tool",
      "title": "Demo Tool",
      "mfName": "demoTool",
      "mfRemoteEntry": "https://demo-tool.aseller.io/remoteEntry.js",
      "mfExposedModule": "./app",
      "standaloneUrl": "https://demo-tool.aseller.io",
      "guideUrl": null,
      "images": []
    }
  ]
}

Источник: api-new/core/services/v1/integrations/dto/_mfRegistryResponseDtoSchema.js. Whitelist полей — там же; ничего «лишнего» из БД (например, backendBaseUrl, permissionKey) в публичный response не утекает.

MCP (опционально)

Если в регистрации tool'а указано mcpEnabled: true, то планируется его автоматическое включение в tools/list MCP-агрегации — единого endpoint'а POST /api/v1/mcp для AI-агентов (Claude Desktop, OpenAI и любых JSON-RPC 2.0 клиентов).

С этапа E1.5 (июнь 2026) AI видит весь read-only API api-new

MCP tools/list отдаёт два слоя:

  1. Curated shortcutsget_my_info, get_my_shops, get_products (built-in семантические обёртки).
  2. Auto-discovered GET endpoints — все GET-роуты api-new автоматически попадают в tools/list если у владельца ключа есть permission на этот URL (фильтр по permissions.allowed_urls или admin-bypass). Имя строится как get_<sanitized_path>. inputSchema берётся из OpenAPI спеки и разделяется на params (path-параметры) и query (querystring).

Под капотом tools/call для auto-tools вызывает endpoint через fastify.inject с синтезированным ACCESS-токеном владельца ключа — то есть AI получает ровно те же данные, что и через прямой HTTP-запрос с access-токеном.

POST/PATCH/DELETE и aggregation MF-tools (через ExternalTool.mcp_enabled + swaggerUrl) запланированы на этап E6.

Если хотите, чтобы ваш tool уже сейчас был достижим для Claude:

  • Поддерживайте GET /openapi.json со спекой backend'а (потребуется E6).
  • Используйте стандартные OpenAPI operationId и схемы — sanitizer ожидает ^[a-z0-9_]+$ (tech-spec §6.8).
  • Проверьте, что backend стабильно валидирует JWT через /auth/validate (E6 будет проксировать токен пользователя в backend tool'а).

Подключение Claude Desktop к api-new MCP MVP — отдельный гайд: api-new/docs/migration/MCP_CLAUDE_DESKTOP_GUIDE.md.

Чек-лист «готов ли мой tool к регистрации»

Перед тем как просить админа POST /admins/tools:

  1. Frontend собирается с @originjs/vite-plugin-federation (или webpack 5 ModuleFederationPlugin для React).
  2. name MF-контейнера уникален в экосистеме (не пересекается с другими tools).
  3. Exposed module реализует mount(element, hostContext) и unmount(element).
  4. mount идемпотентен: повторный вызов с тем же element ре-рендерит, а не падает.
  5. shared: {} в MF-конфиге (изоляция React/Vue/antd версий от Host UI).
  6. remoteEntry.js отдаётся по стабильному URL, ассеты — по immutable hash-путям.
  7. Frontend читает JWT только через hostContext.getToken(), не лезет в Host storage.
  8. Standalone-режим работает по своему URL, редиректит на /login?returnTo= при отсутствии токена.
  9. Парсит fragment #access_token=... после редиректа и сразу чистит URL через history.replaceState.
  10. Backend валидирует токены только через POST /auth/validate с кешем 30s по sha256(token).
  11. Backend не имеет в зависимостях jsonwebtoken / jwks-client.
  12. CORS на backend настроен под домены Host UI и (если нужно) api-new.
  13. Зарегистрирован домен standalone_url в whitelist у Host UI (через DevOps) — иначе redirect с токенами не сработает.
  14. Если backend используется для MCP в будущем — GET /openapi.json отдаёт валидную OpenAPI 3 спеку.
  15. Согласован permissionKey (формат tool.<code>) и allowedRoles с админом Платформы.

FAQ / частые ошибки

Q: Можно ли встроить tool через iframe? Нет. Сейчас разрешён только Module Federation (tech-spec §A12, business §П9). Это закрытое решение, альтернатив не будет.

Q: Как получить JWT_KEY, чтобы валидировать токен локально и не ходить на api-new? Никак. JWT_KEY живёт только в api-new. Валидация — через POST /api/v1/auth/validate с кешем 30s. Это даёт мгновенную revocation и убирает single secret distribution (tech-spec §A14).

Q: Что использовать — /.well-known/jwks.json или /auth/validate? Сейчас — только /auth/validate. JWKS отдаёт пустой keys[] потому что api-new подписывает токены HS256, у которого нет публичного ключа. JWKS станет рабочим, когда (и если) api-new перейдёт на RS256. Менять интеграцию заранее не нужно — /auth/validate останется и после миграции.

Q: Хочу разные права на разные функции внутри одного tool'а — как это сделать? Не делать. По правилу декомпозиции (tech-spec §A8, §4.8) один tool = одна бизнес-функция. Если внутри возникают разные доступы — разбейте на несколько отдельных external_tools записей с разными permissionKey. Tool не должен иметь собственного admin-UI для прав.

Q: Можно ли менять permissionKey после регистрации? Да: PATCH /api/v1/admins/tools/:guid с новым permissionKey. api-new создаст новую permission. Старая останется (нужно вручную обновить выдачи в permissions_users / permission_groups_permissions).

Q: Tool появился в /mf-registry у админа, но не у клиента — что не так? Проверьте три вещи: (1) allowed_roles содержит код роли клиента (как строка: "10"); (2) у клиента в effective permissions есть ваш permissionKey — посмотреть через GET /api/v1/me или соответствующий permissions endpoint; (3) is_active = true.

Q: Как обновить tool без downtime? Деплойте новый remoteEntry.js по тому же URL (или используйте mf-manifest.json с immutable ассетами). Host UI fetch'нет manifest при следующей навигации. Поскольку у каждого пользователя mount → unmount → mount происходит независимо, версия будет подхвачена постепенно.

Ссылки

  • Полное ТЗ миграции: api-new/docs/migration/tech-spec.md (§2 архитектура, §3 sequence-диаграммы, §4 ExternalTool, §10 auth)
  • Бизнес-обоснование: api-new/docs/migration/business.md
  • Roadmap: api-new/docs/migration/roadmap.md
  • Token introspection (детально): api-new/docs/migration/AUTH_INTROSPECTION.md
  • MCP / Claude Desktop: api-new/docs/migration/MCP_CLAUDE_DESKTOP_GUIDE.md
  • Реальный код фильтра /mf-registry: api-new/core/services/v1/integrations/mfRegistry.service.js
  • Реальный код /auth/validate: api-new/core/services/v1/auth/auth.service.js
  • DTO регистрации tool: api-new/core/common/schemas/admins/tools/_adminCreateToolDtoSchema.js

Обновлено:

Документация по инфраструктуре