Как написать 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 обязан экспонировать модуль с двумя функциями:
export function mount(element: HTMLElement, hostContext: HostContext): void
export function unmount(element: HTMLElement): voidmount должен быть идемпотентным: повторный вызов с тем же element ре-рендерит remote с новым контекстом (например, при смене темы или языка), а не пересоздаёт корневой компонент.
HostContext
Минимальный контекст, который Host UI передаёт в remote:
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:
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:
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:
- Tool читает JWT из
localStorage(или собственного хранилища). - Если токена нет / истёк —
window.location.replace(...):https://platform.aseller.io/login?returnTo=<encoded current URL> - Host UI логинит юзера и возвращает 302 на исходный URL с токенами в URL fragment (не query!):
https://your-tool.aseller.io/some-path#access_token=...&refresh_token=...&expires_in=60 - 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)
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:
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_expired | jwt.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:
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) | нет |
mfRemoteEntry | URL remoteEntry.js (или mf-manifest.json) | нет |
mfExposedModule | Путь exposed module (./app) | нет |
backendBaseUrl | Base URL backend tool'а (для MCP-агрегации) | нет |
swaggerUrl | URL OpenAPI-спеки (для MCP-агрегации) | нет |
standaloneUrl | URL для прямого открытия. Если пусто — берётся 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 в той же транзакции:
- Создаёт
external_toolsзапись. - Через
Permission.findOrCreateсоздаёт запись вpermissionscallowedUrls: ['mf-tool:demo_tool']иrole: 0.
Permission видна только админам. Выдаётся юзерам через существующий механизм Платформы: permissions_users напрямую или permission_groups_users → permission_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
{
"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 отдаёт два слоя:
- Curated shortcuts —
get_my_info,get_my_shops,get_products(built-in семантические обёртки). - 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:
- Frontend собирается с
@originjs/vite-plugin-federation(или webpack 5 ModuleFederationPlugin для React). nameMF-контейнера уникален в экосистеме (не пересекается с другими tools).- Exposed module реализует
mount(element, hostContext)иunmount(element). mountидемпотентен: повторный вызов с тем же element ре-рендерит, а не падает.shared: {}в MF-конфиге (изоляция React/Vue/antd версий от Host UI).remoteEntry.jsотдаётся по стабильному URL, ассеты — по immutable hash-путям.- Frontend читает JWT только через
hostContext.getToken(), не лезет в Host storage. - Standalone-режим работает по своему URL, редиректит на
/login?returnTo=при отсутствии токена. - Парсит fragment
#access_token=...после редиректа и сразу чистит URL черезhistory.replaceState. - Backend валидирует токены только через
POST /auth/validateс кешем 30s поsha256(token). - Backend не имеет в зависимостях
jsonwebtoken/jwks-client. - CORS на backend настроен под домены Host UI и (если нужно) api-new.
- Зарегистрирован домен
standalone_urlв whitelist у Host UI (через DevOps) — иначе redirect с токенами не сработает. - Если backend используется для MCP в будущем —
GET /openapi.jsonотдаёт валидную OpenAPI 3 спеку. - Согласован
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