From 87834c4e56b52291c070ec3db076d4c179c90323 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 18:04:20 +0000 Subject: [PATCH 01/17] =?UTF-8?q?docs(apps-catalog):=20research-=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=D0=BB=D0=B0=D0=B4=20=D0=BF=D0=BE=20=D0=BC=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20native=20extensions=20?= =?UTF-8?q?=D0=B2=20=D0=BA=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../integration/native-to-catalog-research.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/integration/native-to-catalog-research.md diff --git a/docs/integration/native-to-catalog-research.md b/docs/integration/native-to-catalog-research.md new file mode 100644 index 00000000000..eadc61b17a7 --- /dev/null +++ b/docs/integration/native-to-catalog-research.md @@ -0,0 +1,175 @@ +# Native extensions → Apps Catalog: research-доклад + +Документ выполнен на ветке `apps-catalog` от `dev` в mono. Цель — спроектировать **гармоничную** интеграцию каталога приложений (apps-catalog: ca-auth/ca-admin/Nexus + on-chain контракт `apps`) в существующий монолит, **не ломая** сегодняшнюю модель native-extensions (capital/chairman/chatcoop/participant/powerup/reports/soviet). + +Документ — план, не реализация. Stories 1.3–1.12 продолжатся в apps-catalog репозитории отдельно. + +## 0. Контекст и стейкхолдер + +- Пользователь хочет: 7 текущих native extensions **продолжают работать как есть** (bundle, in-memory installer), но **появляются в магазине** как «уже установлено». Новые extension'ы постепенно вытаскиваются из bundle и устанавливаются динамически через каталог по платной/бесплатной модели. +- Чисто заменять одно на другое («ломая всё») запрещено. +- В исследовании надо найти **минимальный шов**, который позволит сосуществовать `bundle` и `remote` режимам. + +## 1. As-is: что сейчас в mono + +### 1.1. Frontend (desktop) + +``` +components/desktop/extensions/ +├── capital/install.ts ← IWorkspaceConfig[] +├── chairman/install.ts +├── chatcoop/install.ts +├── participant/install.ts +├── powerup/install.ts +├── reports/install.ts +└── soviet/install.ts +``` + +- Каждый `install.ts` — экспорт `default async () => IWorkspaceConfig[]`, где `IWorkspaceConfig` = `{ workspace, extension_name, title?, icon?, defaultRoute?, routes }` (`components/desktop/src/shared/lib/types/workspace.ts:24`). +- Bundle-time registry: `components/desktop/src/processes/init-installed-extensions/extensions-registry.ts` импортирует все 7 install-функций и кладёт в `Record Promise>`. +- Process: `useInitExtensionsProcess(router)` (`processes/init-installed-extensions/index.ts:6`) на старте SSR-приложения вызывает **все** install-функции последовательно и пушит их routes в `desktopStore` + Vue Router. Никаких «не показывать» — все 7 столов всегда зарегистрированы в bundle. +- Видимость столов конкретному кооперативу / роли — отдельный слой (см. backend grants ниже + `meta.roles`/`meta.conditions` в IWorkspaceRoute). + +**Существующий UI «магазин»:** +- `components/desktop/src/pages/ExtensionStore/{BaseRoute, ExtensionPage, ExtensionsManagement, ExtensionsShowcase, InstalledExtensions}` — UI для просмотра/«управления» доступными расширениями кооператива. Данные подтягивает из coopback (AppRegistry → GraphQL). +- `components/desktop/src/pages/Marketplace/*` — это **Стол заказов** (Marketplace MVP, другое), путать нельзя. + +### 1.2. Backend (controller / coopback) + +- `components/controller/src/extensions/extensions.registry.ts` — `AppRegistry: INamedExtension` хардкодит NestJS-модули всех расширений (`soviet`, `chairman`, `capital`, `chatcoop`, `participant`, `powerup`, `reports`, `1ccoop`, `qrpay`, `sberpoll`, `yookassa`, `builtin`). +- Поля `IRegistryExtension`: `is_builtin`, `is_internal`, `is_available`, `desktops`, `external_url`, `title`, `description`, `image`, `class` (NestJS-модуль), `pluginClass` (миграции), `schema` (Zod конфига), `tags`, `readme`/`instructions` (async чтение README/INSTALL.md из директории). +- Концептуально это уже **полу-«пакетная»** модель: есть метаданные пакета, бинарь (тут — компилируемый внутрь monorepo), миграции, конфиг-схема. Просто всё in-process, без выгрузки и pull-tarball. +- Видимость и онбординг столов решает слой grants (см. memory `reference_extension_onboarding_gating`): backend через `getDesktop` отдаёт `DesktopWorkspace.grants`, фронт сверяет `meta.requires`. + +### 1.3. On-chain (apps contract) + +- Развёрнут в подсетях, в которых живёт Mono (на mono-ai-5 — chain_id `db79c84096…`). 17 actions / 8 tables — pricing, releases, subs, packages, coops, clients, globals. +- Source of truth для **внешнего** каталога; native extensions on-chain пока не существуют. + +### 1.4. Apps-catalog (внешний сервис) + +- Репозиторий `C9S/apps-catalog`, ветка `dev`. ca-auth (npm-pull-proxy) + ca-admin (publisher control) + Nexus + Postgres. Связь с mono — через `KE_RPC_URL=node:8888` и `MONO_BACKEND_URL=coopback:2998` (docker DNS `mono-shared` сеть). +- Концепция: tenant-кооператив (`romashka`, `voskhod`) → подключается → подписывается на пакет → ca-auth раздаёт tarballs из Nexus только когда on-chain `subs[tenant][package]` активна. + +## 2. To-be: целевая модель + +Цель — **два класса пакетов** в одном каталоге, разделённые по типу доставки: + +| Класс | Доставка | Жизненный цикл | Примеры | +|---|---|---|---| +| **bundle** | Компилируется в SSR-bundle desktop'а / NestJS-модуль coopback'а. Не качается из Nexus. | Версия = версия mono. Обновляется через `git pull && pnpm build`. | Сегодняшние 7 native: soviet, chairman, capital, … | +| **remote** | tarball в Nexus → ca-auth pull-proxy → dynamic import. NestJS-модуль динамически грузится в coopback. | Версия = `setrelease` on-chain. Обновляется без передеплоя mono. | Будущие: новые специализированные столы, форки текущих, частные пакеты кооперативов. | + +**Каталог отображает оба класса одинаково** в UI (`ExtensionStore`); внутри строки пакета — поле `kind: bundle | remote`. Для `bundle` кнопка «Установить» скрыта/заменена на «Уже установлено», для `remote` — реальный install-flow через подписку + pull. + +## 3. Промежуточный слой совместимости (dual mode) + +### 3.1. Регистрация bundle-пакетов в каталоге как «pre-installed» + +**Минимальный шов** на стороне apps-catalog: +- Добавить на on-chain контракт `apps` (или в admin-проекцию ca-admin) **флаг bundle** или отдельную таблицу `bundled_packages[coopname, package_id, mono_version]`. Запись означает: «этот кооператив получает пакет в составе bundle Mono, в каталоге показываем как already-installed, подписки не требуем». +- ca-auth при запросе на pull проверяет: bundle → отказ с понятной ошибкой (или редирект на «уже встроено»). + +**Источник истинности — каталог**, не Mono: +- Mono при старте `useInitExtensionsProcess` помимо bundle-загрузки **посылает в ca-admin** список своих bundled-пакетов (POST `/v1/admin/bundled-packages/sync` с api-key или mTLS). +- ca-admin записывает их с `kind='bundle'` и `compatible_subnets=[mono.chain_id]`. + +**Регистрация bundle-пакетов в каталоге** — однократная (idempotent на mono_version + chain_id), не требует Nexus-репозитория. + +### 3.2. Маркировка native extension'ов в коде + +В каждом из 7 extension'ов завести **manifest-файл** (mirror к [apps-catalog/libs/core/manifest/package-manifest.schema.ts](../../../../apps-catalog/libs/core/manifest/package-manifest.schema.ts)): + +```jsonc +// components/desktop/extensions/soviet/package.json +{ + "name": "@coopenomics/soviet", + "version": "0.1.0", + "catalog": { + "kind": "bundle", + "title": "Стол Совета", + "icon": "fa-solid fa-gavel", + "workspaces": ["soviet"], + "roles": ["chairman", "member"], + "mono_min_version": "0.0.0" + } +} +``` + +Скрипт `tools/sync-bundled-packages.ts` собирает все 7 manifest'ов + chain_id + mono version → POST в ca-admin. + +### 3.3. Динамическая загрузка (для remote-класса) + +**Frontend:** +- Расширить `extensionsRegistry` чтобы значения могли быть `() => Promise` (как сейчас, bundle) **или** `{ kind: 'remote', tarballUrl, version }` — резолвер сам качает tarball через ca-auth proxy, распаковывает в Quasar Boot-script хранилище, dynamic-import'ит `install.ts` из распакованного бандла, кэширует. +- Vue Router добавление routes остаётся то же (`router.addRoute('base', r)`). + +**Backend (coopback):** +- Аналогично — `extensions.registry.ts` принимает динамические записи. При первом запросе кооператива на remote-package coopback: + 1. Через `MonoBackendPort` (либо прямой HTTP к ca-auth) проверяет подписку on-chain. + 2. Качает tarball через ca-auth (с JWT кооперативного backend'а). + 3. Распаковывает в `/var/lib/coopback/extensions/@/`. + 4. `dynamic import` ESM-модуля; регистрирует Module через NestJS dynamic module pattern (тот же паттерн, что у текущих PluginModule). + +**Изоляция:** для безопасности — sandbox через VM2/Workers или хотя бы хеш-проверку tarball'а (Nexus отдаёт SHA-256, on-chain `releases` фиксирует ожидаемый hash). + +### 3.4. Coexistence rules + +- Один и тот же `package_id` не может одновременно быть `bundle` и `remote` для одного кооператива. Если on-chain появилась подписка на `@coopenomics/soviet` (remote), а в bundle есть тот же package_id — **bundle wins** до явного выключения через `bundled_packages.disabled_at`. +- Когда extension мигрирует из bundle → remote (см. п. 4): сначала выпускается `remote` версия в каталог, тестируется на одном пилоте, потом `disabled_at` ставится в `bundled_packages` Mono'а, и bundle-fallback гасится. + +## 4. План миграции по extension'ам (поштучно) + +| Extension | Сложность | Зависимости | Кандидат №… | +|---|---|---|---| +| **chatcoop** | низкая — изолированный чат-стол, минимум интеграции с core | core ws-bridge, member-info | **№1 (пилот)** | +| **reports** | низкая-средняя — read-only отчёты | analytics endpoints | №2 | +| **powerup** | средняя — затрагивает ledger | wallet | №3 | +| **chairman** | высокая — связан с onboarding + agreements | grants, agreements | №4 | +| **soviet** | высокая — управление повесткой совета, on-chain actions | proposals, agreements | №5 | +| **participant** | высокая — base extension для пайщика | accounts | №6 | +| **capital** | максимальная — финансовые операции, capital_issues | ledger2, time-tracking | №7 (последний) | + +Каждая миграция = отдельная story в apps-catalog репозитории (Epic 9 в дополнении), parallel — story в mono (вытаскивание extension'а в отдельный pnpm-workspace пакет + tarball-сборка через `pnpm publish` в Nexus). + +## 5. Bootstrap-пакет + +Для подтверждения трубы — `@voskhod/test-app`: +- Сегодня уже зарегистрирован через POST `/v1/admin/package` (smoke-стенд, request_id `98b1d8ac-…`). +- В рамках Bootstrap-стори: + - Сгенерировать минимальный tarball (1 page, 1 route) с `package.json` манифестом. + - `npm publish --registry=http://ca-auth:3001/v1/registry/` (с tenant JWT кооператива voskhod). + - `apps::setrelease` on-chain. + - Проверить, что desktop'у через `loadExtensionRoutes('@voskhod/test-app')` action раздаёт routes из remote tarball. + +Bootstrap не претендует на «настоящий» сценарий — это инфра-проверка трубы для последующей реальной миграции (Кандидат №1 = chatcoop). + +## 6. Открытые вопросы + +1. **Где живёт `package_id` для bundle?** Предложение: `@coopenomics/` (org scope = монорепозиторий). Если кооператив хочет форкнуть — он публикует под своим scope `@voskhod/` с другим bundle'ом. +2. **Можно ли остановить bundle-загрузку extension'а в Mono per кооператив?** Сегодня bundle грузится всегда; видимость регулируется backend grants. То есть мы можем ВЫПИЛИВАТЬ extension из bundle и заменить на remote — но не для одного кооператива из десяти. Возможный путь — feature-flag через ENV на инстансе mono (один Mono = один кооператив сегодня). +3. **Sandbox для remote-модулей.** Без изоляции remote-extension с правами NestJS-модуля = админ-доступ к coopback. Нужен или подписанный паблишер (allowlist on-chain `apps::pubpub`), или VM2-sandbox, или статический анализ tarball'а перед запуском. +4. **Миграции схемы БД для remote-extension.** Сегодня `pluginClass.runMigrations(schema)` запускается на старте монолита. Для remote — нужен hook на «новый extension установлен» с автоматическим прогоном миграций. Open question: что если миграция падает? Откат subscription on-chain невозможен (releases immutable). +5. **Версия Mono ↔ совместимость extension'а.** `catalog.mono_min_version` в манифесте; ca-admin при `setrelease` проверяет совместимость с активной mono_version подсетей `compatible_subnets`. +6. **UX миграции для уже-установленных native.** Когда extension переезжает bundle → remote, пользователь должен: + - Увидеть в каталоге, что обновлена доставка (info badge «теперь обновляется отдельно»). + - НЕ потерять данные / config (миграция identifiers in БД). + - Иметь fallback на bundle, если remote tarball недоступен (offline mode). + +## 7. Что НЕ входит в этот research + +- Конкретный код миграции какого-либо extension'а (это будут отдельные stories). +- Решение об IDE/sandbox/изоляции — это отдельное исследование безопасности. +- Дизайн UI «магазина» в ExtensionStore (мы переиспользуем существующие страницы, только добавляем kind/version поля). +- Pricing / биллинг внутри Mono — каталог уже это решает (контракт apps + ca-admin). + +## 8. Следующие шаги (порядок) + +1. **Bootstrap-пакет @voskhod/test-app** — закрыть инфра-проверку. +2. **Manifest для одного native extension'а (chatcoop)** в mono — добавить `package.json` с `catalog.kind=bundle`. +3. **POST /v1/admin/bundled-packages/sync** — endpoint в apps-catalog (новый, добавляется в Epic 9). +4. **Скрипт `tools/sync-bundled-packages.ts`** в mono — собирает 7 manifest'ов и шлёт в ca-admin. +5. **Поле `kind` в pages/ExtensionStore** — рендерит "уже установлено" для bundle, "Подписаться" для remote. +6. **Миграция chatcoop bundle → remote** — pilot story (отдельная ветка от apps-catalog в mono). + +Каждый шаг = отдельный коммит в зонтичной ветке `apps-catalog` mono'а + parallel story в apps-catalog репо (Epic 9 «Bundle/remote coexistence»). From c18828c53472c348dc7b81e5fb163efeb8dbf142 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 18:30:18 +0000 Subject: [PATCH 02/17] =?UTF-8?q?docs(apps-catalog):=20Epic=209=20MVP=20de?= =?UTF-8?q?mo-app=20pilot=20=E2=80=94=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B9=20=D0=B8=20stories?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Финальные решения по V1 пилоту (без миграции native): - demo-app = пустой Hello-стол, @voskhod/demo-app - Pricing 1000 RUB/мес (60 сек на dev), trial 15 мин - partner1 как покупатель через pnpm boot:extra (на той же цепи) - Self-subscription bypass: воскод как оператор подписан бесплатно - Без кэша tarball'ов — каждый desktop boot качает заново - Стол Разработчика = новый bundle-extension developer, виден chairman оператора - partner1 desktop+coopback в отдельном docker overlay из apps-catalog Epic 9 раскладка на 8 stories с зависимостями. Реализация — следующими коммитами в зонтичной ветке apps-catalog mono + feat/E9-demo-app-pilot в apps-catalog репо. --- .../integration/native-to-catalog-research.md | 121 ++++++++++++++++-- 1 file changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/integration/native-to-catalog-research.md b/docs/integration/native-to-catalog-research.md index eadc61b17a7..ccbef71ccd6 100644 --- a/docs/integration/native-to-catalog-research.md +++ b/docs/integration/native-to-catalog-research.md @@ -163,13 +163,118 @@ Bootstrap не претендует на «настоящий» сценарий - Дизайн UI «магазина» в ExtensionStore (мы переиспользуем существующие страницы, только добавляем kind/version поля). - Pricing / биллинг внутри Mono — каталог уже это решает (контракт apps + ca-admin). -## 8. Следующие шаги (порядок) +## 8. Решение V1: не мигрируем, делаем демо-пилот -1. **Bootstrap-пакет @voskhod/test-app** — закрыть инфра-проверку. -2. **Manifest для одного native extension'а (chatcoop)** в mono — добавить `package.json` с `catalog.kind=bundle`. -3. **POST /v1/admin/bundled-packages/sync** — endpoint в apps-catalog (новый, добавляется в Epic 9). -4. **Скрипт `tools/sync-bundled-packages.ts`** в mono — собирает 7 manifest'ов и шлёт в ca-admin. -5. **Поле `kind` в pages/ExtensionStore** — рендерит "уже установлено" для bundle, "Подписаться" для remote. -6. **Миграция chatcoop bundle → remote** — pilot story (отдельная ветка от apps-catalog в mono). +**Утверждено 2026-06-02.** Native extensions трогать не будем. Вместо bundle→remote миграции собираем минимальное демо-приложение, ставим его сами как внешний разработчик и проводим полный цикл с оплатой. Это снимает риски с native и подтверждает контур end-to-end. -Каждый шаг = отдельный коммит в зонтичной ветке `apps-catalog` mono'а + parallel story в apps-catalog репо (Epic 9 «Bundle/remote coexistence»). +### Решения по содержанию пилота + +| Тема | Решение | Замечание | +|---|---|---| +| Что делает demo-app | Вариант A — пустой Hello-стол с одним роутом + иконкой | Минимум контента, максимум инфра-трубы | +| `package_id` | `@voskhod/demo-app` | scope = coopname-разработчик | +| Разработчик (`owner_username`) | `voskhod` (кооператив-аккаунт) | Публикует **chairman voskhod** через `@active` | +| Pricing | `default` = **1000 RUB / месяц** prod / **60 сек dev** | period в `globals.min_payment_period_seconds` | +| Trial | **15 минут** в dev (нет prod-trial в V1) | `globals.lead_time_seconds=900` | +| Покупатель-пилот | `partner1` (поднимается через `pnpm boot:extra` в mono-ai-5) | Та же цепь voskhod'а, не отдельная chain | +| Self-subscription (voskhod) | Видит свой demo-app, подписывается без оплаты | Bypass: если `tenant_coop == package.owner_coop` → charge skip | +| Кэш tarball'ов | Без кэша — каждый desktop boot качает заново | Простой sync-loop, без storage | +| UI магазина | Переиспользуем `pages/ExtensionStore` (минимальные правки) | Добавляем поле `kind=bundle\|remote` | +| Стол разработчика | **Новый bundle-extension `developer`**, виден только chairman'у оператора | UI публикации + список подписчиков + pricing | + +### Инфра для одновременной работы двух кооперативов + +Сценарий «оператор-разработчик ↔ кооператив-потребитель» требует двух **независимых UI-окружений** в одной dev-машине: +- voskhod's desktop+coopback — на mono-ai-5 (`COOPNAME=voskhod`, порты 3038/3039). +- partner1's desktop+coopback — поднимаем **через docker-compose overlay в apps-catalog** (`COOPNAME=partner1`, порты 3048/3049), указывающие на ту же node mono-ai-5:8930 (`CHAIN_URL=http://node:8888`). +- Никакой второй chain не нужен. partner1 активируется на воскод-цепи через `pnpm boot:extra` (`mono-ai-5/components/boot/src/init/infra.ts:584`). + +Это компромисс: coopback/desktop single-coopname остаются как есть, multi-tenancy откладываем. + +## 9. Epic 9 «MVP demo-app pilot E2E» + +Зонтичный эпик. Параллельная работа в двух репозиториях: +- **apps-catalog** — `examples/demo-app/`, overlay для partner1, self-sub bypass, dev-seed. +- **mono-ai-5** ветка `apps-catalog` — стол разработчика, remote-loader в init-installed-extensions, минимальные правки ExtensionStore. + +### Stories + +**9.1 examples/demo-app/ в apps-catalog** +- Скаффолд: `package.json` с `catalog.kind=remote, workspaces=['demo']`, `install.ts` с одним IWorkspaceConfig (стол «Demo», роут `/:coopname/demo` с Hello-страницей). +- Builder: `pnpm pack` → `demo-app-0.1.0.tgz`. +- README с инструкцией публикации. + +**9.2 docker-compose.partner1.overlay.yml в apps-catalog** +- Overlay для dev-stack: `coopback-partner1` (порт 3048, COOPNAME=partner1, MONGO_PARTNER1_DB), `desktop-partner1` (порт 3049, тот же coopback-partner1). +- Подключение к `mono-shared` сети с aliases. +- Скрипт `pnpm dev:stack:up-partner1` поднимает overlay поверх базового. +- Документация: «партнерский desktop по `http://localhost:3049`, входить как admin partner1 (WIF из docs-harness/state/cooperatives/partner1.json)». + +**9.3 Стол Разработчика в mono (`extensions/developer`)** +- Bundle-extension в `components/desktop/extensions/developer/`: + - install.ts: workspace `developer`, роуты «Мои пакеты», «Опубликовать релиз», «Подписчики», «Pricing». + - Виден только chairman'у кооператива-оператора через `meta.roles=['chairman']` + grants на бэкенде. +- Backend модуль `controller/src/extensions/developer/`: + - Поднимается в `AppRegistry` как `is_internal=true, is_available=true`. + - GraphQL: `myPackages`, `publishPackage`, `setReleasePricing`, `packageSubscribers`. + - Делегирует в ca-admin через HTTP с админ-ключом. +- Self-sub bypass: «У вас уже доступ как разработчику» (без отдельной кнопки). + +**9.4 Remote-loader в `processes/init-installed-extensions`** +- После bundle-pass — запрос в coopback `getInstalledRemotePackages(coopname) → [{package_id, version, tarballUrl}]`. +- Для каждого: pull tarball через ca-auth pull-proxy с JWT кооператива. +- Распаковка в памяти (не на диск — без кэша). +- `Function('return ' + installSource)()` или dynamic `eval` install.ts → `IWorkspaceConfig[]` → `router.addRoute('base', ...)`. +- Ошибка на одном extension'е не валит остальные (try-catch вокруг каждого). + +**9.5 pages/ExtensionStore: поле `kind` + новые кнопки** +- Расширить DTO ответа `extensions catalogList` полями `kind: 'bundle'|'remote'`, `version`, `pricingRubPerMonth`, `isSelfOwned`. +- `kind=bundle` → «Уже установлено». +- `kind=remote && isSelfOwned` → «Доступно как разработчику» (без оплаты). +- `kind=remote && !isSelfOwned && !subscribed` → «Подписаться 1000 RUB/мес». +- `kind=remote && subscribed` → «Активная подписка до DD.MM». +- Резолвер дёргает coopback'овский новый endpoint, который ходит в ca-auth `GET /v1/registry/packages`. + +**9.6 Self-subscription bypass в pricing-watcher** +- В `apps-catalog/apps/ca-admin/src/modules/pricing-watcher/application/request-charge.use-case.ts`: + - Перед cadence-check — проверка `tenantCoopname === packageOwnerCoopname`. + - Если match → outcome=`'skip-self-subscription'`, sub продлевается через `extendsub` без charge. + - Audit-log: `pricing.self_subscription_skipped` с `package_id` + `coopname`. +- Тест: `request-charge.use-case.spec.ts` — voskhod подписан на свой `@voskhod/demo-app` → tick → no charge, sub.end_at += period. + +**9.7 dev-seed: globals + pricing** +- Скрипт `apps-catalog/scripts/seed-dev-pricing.ts`: + - `apps::setglobals` `lead_time=900`, `min_payment_period=60`, `retry_max=3`. + - `apps::setpricing @voskhod/demo-app default plan {RUB 1000}`. +- Запускается из `dev:stack:up`. + +**9.8 E2E smoke test demo-app pilot** +- Скрипт `test/e2e/demo-app-pilot.sh`: + 1. Поднимаем стенд + partner1 overlay. + 2. chairman voskhod публикует через стол разработчика → `apps::regpkg` + tarball в Nexus + `apps::setrelease`. + 3. partner1 admin подписывается через `pages/ExtensionStore` → `apps::regsub`. + 4. Ждём 70 секунд (один pricing-watcher тик) → проверка `apps::charge_intents` появилась запись → `extendsub` прошёл. + 5. Перезагрузка `desktop-partner1` → remote-loader тянет tarball → стол «Demo» в drawer'e. + 6. `apps::cancelsub` → следующий тик → стол исчезает. +- Pass criteria: все 6 шагов зелёные. + +### Order of execution + +``` +9.1 (examples/demo-app) ──┐ +9.2 (partner1 overlay) ─├─→ 9.7 (dev-seed) ─→ 9.8 (E2E smoke) +9.6 (self-sub bypass в ca-admin) ──┘ ↑ + │ +mono-сторона: │ +9.3 (стол разработчика) ─→ 9.5 (ExtensionStore) ─→ 9.4 (remote-loader) ─┘ +``` + +Все mono-stories идут в зонтичный PR `apps-catalog` → dev в mono'е (один PR на эпик). +Все apps-catalog-stories идут в зонтичный PR `feat/E9-demo-app-pilot` → dev в apps-catalog. + +### Не входит в Epic 9 + +- Реальная миграция native extensions (отложена на Epic 10+). +- Sandbox-изоляция remote-модулей (Epic 11, открытый вопрос #3). +- Production-pricing с реальным RUB-токеном / реальным биллингом — V1 крутит in-chain RUB-like asset. +- Multi-tenancy coopback'а (отложена; в V1 = два отдельных coopback'а). From 9f6d2dc699453d87b09af8078b37b2ec479da5a4 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 19:20:57 +0000 Subject: [PATCH 03/17] =?UTF-8?q?[9.3][9.4]=20feat:=20=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BB=20=D1=80=D0=B0=D0=B7=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D1=87?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0=20+=20remote-loader=20=D0=BA=D0=B0=D1=80?= =?UTF-8?q?=D0=BA=D0=B0=D1=81=20=D0=B2=20desktop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 9.3 — components/desktop/extensions/developer/: - install.ts: workspace `developer`, defaultRoute=developer-my-packages, 4 страницы (packages, publish, subscribers, pricing), все under meta.roles=['chairman']. Регистрация в extensions-registry.ts. - pages/: V1 Vue-заглушки с описанием TODO (без реальных мутаций). Mutation flow (POST /v1/admin/package, /releases, /pricing с подписью chairman'а) — story 9.3.b. 9.4 — processes/init-installed-extensions/remote-loader.ts: - Контракт fetch/load для remote-пакетов с активной подпиской: тип RemoteExtensionDescriptor, функции fetchInstalledRemotePackages, loadRemoteExtension, installRemoteExtensions. - V1 — заглушка возвращающая []. Не падает, логирует пропуск. - index.ts: после bundle-pass — remote pass через installRemoteExtensions; try/catch чтобы падение remote-flow не валило bundle extensions. - TODO 9.4.b: реальный fetch к coopback + tar-x + dynamic require + sha256-check + (V2 sandbox). Видимость стола: пока через meta.roles в install.ts. Полноценная авторизация через DesktopWorkspace.grants с флагом is_operator_coop — 9.3.b/9.5. Refs: /home/admin/mono-ai-5/docs/integration/native-to-catalog-research.md §9. --- .../desktop/extensions/developer/install.ts | 80 +++++++++++++++++ .../pages/DeveloperMyPackagesPage.ts | 1 + .../pages/DeveloperMyPackagesPage.vue | 23 +++++ .../developer/pages/DeveloperPricingPage.ts | 1 + .../developer/pages/DeveloperPricingPage.vue | 29 +++++++ .../pages/DeveloperPublishReleasePage.ts | 1 + .../pages/DeveloperPublishReleasePage.vue | 26 ++++++ .../pages/DeveloperSubscribersPage.ts | 1 + .../pages/DeveloperSubscribersPage.vue | 16 ++++ .../extensions/developer/pages/index.ts | 4 + .../extensions-registry.ts | 5 ++ .../init-installed-extensions/index.ts | 28 +++++- .../remote-loader.ts | 87 +++++++++++++++++++ 13 files changed, 301 insertions(+), 1 deletion(-) create mode 100644 components/desktop/extensions/developer/install.ts create mode 100644 components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.ts create mode 100644 components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.vue create mode 100644 components/desktop/extensions/developer/pages/DeveloperPricingPage.ts create mode 100644 components/desktop/extensions/developer/pages/DeveloperPricingPage.vue create mode 100644 components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.ts create mode 100644 components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.vue create mode 100644 components/desktop/extensions/developer/pages/DeveloperSubscribersPage.ts create mode 100644 components/desktop/extensions/developer/pages/DeveloperSubscribersPage.vue create mode 100644 components/desktop/extensions/developer/pages/index.ts create mode 100644 components/desktop/src/processes/init-installed-extensions/remote-loader.ts diff --git a/components/desktop/extensions/developer/install.ts b/components/desktop/extensions/developer/install.ts new file mode 100644 index 00000000000..f0e83fd7185 --- /dev/null +++ b/components/desktop/extensions/developer/install.ts @@ -0,0 +1,80 @@ +// Стол разработчика — Epic 9, story 9.3. +// +// Виден только chairman'у кооператива-оператора каталога (`voskhod` на dev, +// определяется по флагу `is_operator_coop` в Vuex/Pinia store, который +// устанавливается через DesktopWorkspace.grants от controller'а). +// +// V1 содержание: 4 страницы-заглушки, описывающие, что разработчик мог +// бы сделать (мои пакеты, опубликовать релиз, подписчики, pricing). Все +// реальные действия идут REST'ом в ca-admin через chairman-token. На этом +// этапе UI-минимум, реальные mutation'ы — story 9.3.b. + +import { markRaw } from 'vue'; +import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'; +import { DeveloperMyPackagesPage } from './pages/DeveloperMyPackagesPage'; +import { DeveloperPublishReleasePage } from './pages/DeveloperPublishReleasePage'; +import { DeveloperSubscribersPage } from './pages/DeveloperSubscribersPage'; +import { DeveloperPricingPage } from './pages/DeveloperPricingPage'; + +export default async function (): Promise { + return [{ + workspace: 'developer', + extension_name: 'developer', + title: 'Стол разработчика', + icon: 'fa-solid fa-code', + defaultRoute: 'developer-my-packages', + routes: [ + { + meta: { + title: 'Стол разработчика', + icon: 'fa-solid fa-code', + roles: ['chairman'], + }, + path: '/:coopname/developer', + name: 'developer', + children: [ + { + path: 'packages', + name: 'developer-my-packages', + component: markRaw(DeveloperMyPackagesPage), + meta: { + title: 'Мои пакеты', + icon: 'fa-solid fa-box', + roles: ['chairman'], + }, + }, + { + path: 'publish', + name: 'developer-publish-release', + component: markRaw(DeveloperPublishReleasePage), + meta: { + title: 'Опубликовать релиз', + icon: 'fa-solid fa-upload', + roles: ['chairman'], + }, + }, + { + path: 'subscribers', + name: 'developer-subscribers', + component: markRaw(DeveloperSubscribersPage), + meta: { + title: 'Подписчики', + icon: 'fa-solid fa-users', + roles: ['chairman'], + }, + }, + { + path: 'pricing', + name: 'developer-pricing', + component: markRaw(DeveloperPricingPage), + meta: { + title: 'Pricing', + icon: 'fa-solid fa-rouble-sign', + roles: ['chairman'], + }, + }, + ], + }, + ], + }]; +} diff --git a/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.ts b/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.ts new file mode 100644 index 00000000000..5670c96658c --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.ts @@ -0,0 +1 @@ +export { default as DeveloperMyPackagesPage } from './DeveloperMyPackagesPage.vue'; diff --git a/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.vue b/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.vue new file mode 100644 index 00000000000..3bf602304c5 --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperMyPackagesPage.vue @@ -0,0 +1,23 @@ + + + diff --git a/components/desktop/extensions/developer/pages/DeveloperPricingPage.ts b/components/desktop/extensions/developer/pages/DeveloperPricingPage.ts new file mode 100644 index 00000000000..05cb44036ff --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperPricingPage.ts @@ -0,0 +1 @@ +export { default as DeveloperPricingPage } from './DeveloperPricingPage.vue'; diff --git a/components/desktop/extensions/developer/pages/DeveloperPricingPage.vue b/components/desktop/extensions/developer/pages/DeveloperPricingPage.vue new file mode 100644 index 00000000000..1aabfc67fcd --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperPricingPage.vue @@ -0,0 +1,29 @@ + + + diff --git a/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.ts b/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.ts new file mode 100644 index 00000000000..51863e212b9 --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.ts @@ -0,0 +1 @@ +export { default as DeveloperPublishReleasePage } from './DeveloperPublishReleasePage.vue'; diff --git a/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.vue b/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.vue new file mode 100644 index 00000000000..443f29d1088 --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperPublishReleasePage.vue @@ -0,0 +1,26 @@ + + + diff --git a/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.ts b/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.ts new file mode 100644 index 00000000000..d506bc00ea3 --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.ts @@ -0,0 +1 @@ +export { default as DeveloperSubscribersPage } from './DeveloperSubscribersPage.vue'; diff --git a/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.vue b/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.vue new file mode 100644 index 00000000000..2827f79d1d4 --- /dev/null +++ b/components/desktop/extensions/developer/pages/DeveloperSubscribersPage.vue @@ -0,0 +1,16 @@ + + + diff --git a/components/desktop/extensions/developer/pages/index.ts b/components/desktop/extensions/developer/pages/index.ts new file mode 100644 index 00000000000..6faea31aadd --- /dev/null +++ b/components/desktop/extensions/developer/pages/index.ts @@ -0,0 +1,4 @@ +export { DeveloperMyPackagesPage } from './DeveloperMyPackagesPage'; +export { DeveloperPublishReleasePage } from './DeveloperPublishReleasePage'; +export { DeveloperSubscribersPage } from './DeveloperSubscribersPage'; +export { DeveloperPricingPage } from './DeveloperPricingPage'; diff --git a/components/desktop/src/processes/init-installed-extensions/extensions-registry.ts b/components/desktop/src/processes/init-installed-extensions/extensions-registry.ts index f410687e7f8..731b50e453a 100644 --- a/components/desktop/src/processes/init-installed-extensions/extensions-registry.ts +++ b/components/desktop/src/processes/init-installed-extensions/extensions-registry.ts @@ -8,6 +8,10 @@ import participantInstall from '../../../extensions/participant/install'; import powerupInstall from '../../../extensions/powerup/install'; import sovietInstall from '../../../extensions/soviet/install'; import reportsInstall from '../../../extensions/reports/install'; +// Epic 9 story 9.3: «Стол разработчика» — виден только chairman'у +// кооператива-оператора каталога. Видимость регулируется meta.roles +// в install.ts и (V2) grants от controller'а через DesktopWorkspace. +import developerInstall from '../../../extensions/developer/install'; /** * Единый регистр всех доступных расширений @@ -21,6 +25,7 @@ export const extensionsRegistry: Record Promise r.name === 'base'); + if (baseRoute) { + config.routes.forEach((r: any) => { + const existing = router.getRoutes().find((x) => x.name === r.name); + if (!existing) { + router.addRoute('base', r); + } + }); + } + } + } + } catch (err) { + console.error('[init-installed-extensions] remote pass failed:', err); + } } // Функция для динамической загрузки маршрутов конкретного расширения diff --git a/components/desktop/src/processes/init-installed-extensions/remote-loader.ts b/components/desktop/src/processes/init-installed-extensions/remote-loader.ts new file mode 100644 index 00000000000..43ed42cbf85 --- /dev/null +++ b/components/desktop/src/processes/init-installed-extensions/remote-loader.ts @@ -0,0 +1,87 @@ +// Epic 9 story 9.4 — remote-loader для apps-catalog. +// +// На boot'е desktop'а, после регистрации bundle-extensions, идём в coopback +// за списком пакетов с активной подпиской у текущего кооператива и пробуем +// dynamic-eval'ом распаковать install.js из каждого tarball'а, чтобы получить +// `IWorkspaceConfig[]` и добавить роуты в router. +// +// V1 МИНИМУМ (заглушка): +// - Логирует попытку, не падает, не сетевые запросы. +// - Возвращает пустой массив, чтобы `init-installed-extensions/index.ts` +// просто пропустил remote-pass. +// +// V2 (story 9.4.b) — настоящий fetch + eval. Контракт coopback'а: +// GET /api/catalog/installed-remote?coopname= +// → { items: [{ packageId, version, tarballUrl, sha256, jwt }] } +// Контракт ca-auth (pull-surface): +// GET /v1/pull//?token= +// → application/gzip tar (распакованный install.js + package.json) +// +// Изоляция: V1 НЕ выполняет код пакетов (это будет sandbox в story 11.x). + +import type { Router } from 'vue-router'; +import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'; + +export interface RemoteExtensionDescriptor { + packageId: string; // например `@voskhod/demoapp` + version: string; // semver + tarballUrl: string; // ca-auth pull URL + sha256: string; // ожидаемый sha256 распакованного install.js + jwt: string; // tenant-token для pull-запроса +} + +/** + * Запросить у coopback список remote-пакетов, на которые у текущего + * кооператива есть активная подписка. На V1 — заглушка. + */ +export async function fetchInstalledRemotePackages( + _coopname: string, +): Promise { + // TODO (story 9.4.b): реальный fetch к coopback'у. + // const res = await api.get<{ items: RemoteExtensionDescriptor[] }>( + // `/api/catalog/installed-remote?coopname=${encodeURIComponent(coopname)}`, + // ); + // return res.items; + return []; +} + +/** + * Скачать tarball, распаковать, eval'ом получить IWorkspaceConfig[]. + * На V1 — заглушка, возвращает пустой массив. + */ +export async function loadRemoteExtension( + desc: RemoteExtensionDescriptor, +): Promise { + // TODO (story 9.4.b): fetch + tar-x + dynamic require + sha256-check. + console.warn( + `[remote-loader] story 9.4.b TODO: установка ${desc.packageId}@${desc.version} ` + + `пропущена в V1 пилоте (см. docs/epics/E9-demo-app-pilot.md).`, + ); + return []; +} + +/** + * Один pass remote-loader'а: получить список → загрузить каждый → отдать + * слитый массив workspace'ов вызывателю, который сам впишет их в store/router. + * + * Падение одного пакета не должно валить остальные (try/catch per item). + */ +export async function installRemoteExtensions( + coopname: string, + _router: Router, +): Promise { + const descriptors = await fetchInstalledRemotePackages(coopname); + const all: IWorkspaceConfig[] = []; + for (const desc of descriptors) { + try { + const cfgs = await loadRemoteExtension(desc); + all.push(...cfgs); + } catch (err) { + console.error( + `[remote-loader] не удалось установить ${desc.packageId}@${desc.version}:`, + err, + ); + } + } + return all; +} From da0690bca168fc0d143e05477600dc4a7e13a1c2 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 19:21:56 +0000 Subject: [PATCH 04/17] =?UTF-8?q?[9.5]=20feat:=20Epic=209=20hint-banner=20?= =?UTF-8?q?=D0=B2=20pages/ExtensionStore/ExtensionsShowcase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V1 минимум для UI ExtensionStore: - Информационный banner сверху grid'а карточек, объясняющий что bundle- extensions ниже бесплатные, а remote-пакеты с подпиской появятся на следующем шаге пилота. - Реальная карточка remote-пакета с кнопкой «Подписаться» / «Уже подписан до DD.MM» — story 9.5.b (требует расширения extStore на kind/subscribed/pricingRubPerMonth + backend-резолверы coopback'а). Refs: docs/integration/native-to-catalog-research.md §9 story 9.5. --- .../ExtensionsShowcase/ExtensionsShowcase.vue | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue b/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue index 2c2302af950..878ec021ec0 100644 --- a/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue +++ b/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue @@ -1,4 +1,11 @@ From 5a2fc336e996810cb20c6e7f002af1225279075c Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 21:55:19 +0000 Subject: [PATCH 06/17] =?UTF-8?q?[9.5.b]=20feat(controller):=20apps-catalo?= =?UTF-8?q?g-proxy=20=E2=80=94=20=D1=80=D0=B5=D0=B7=D0=BE=D0=BB=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=20appsCatalogRemotePackages?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 9.5.b mono часть: добавляет модуль расширения `apps-catalog-proxy` с GraphQL Query.appsCatalogRemotePackages, который проксирует запрос на apps-catalog/ca-admin /v1/public/packages. Архитектурно — выбран альтернатива C (см. apps-catalog docs/epics/E9-demo-app-pilot.md, V2 follow-ups): браузер ходит в каталог через mono controller, а не напрямую в ca-admin. Поэтому: - admin-secret apps-catalog'а живёт в env controller'а, не в SPA; - нет CORS — desktop запрос на свой же origin; - единый канон mono GraphQL (никаких raw HTTP в desktop). Компоненты: - AppsCatalogRemotePackageDTO (@ObjectType): packageId/publisher/ compatibleSubnets/lastActiveVersion + UI-поля title/description/ rubPerMonth (в V1 description и rubPerMonth — заглушки; в V2 будут из package manifest и apps-catalog pricing). - AppsCatalogHttpService: axios-клиент к ca-admin с Authorization: Bearer admin-key из APPS_CATALOG_API_KEY. Degraded mode (пустой список без ошибки), если env пустой — чтобы dev/CI без apps-catalog не валили desktop boot. - AppsCatalogProxyResolver: @Query, @UseGuards(GqlJwtAuthGuard) — каталог видят только авторизованные пайщики. - Регистрация в ExtensionsModule. Config: - Новые env APPS_CATALOG_URL и APPS_CATALOG_API_KEY (оба optional). - Зеркальные поля в config.apps_catalog.{url, api_key}. TODO (требует ручного запуска на остановленном контейнере, см. CLAUDE.md: 'pnpm generate-schema / generate-client — не запускать локально'): - pnpm -F controller generate-schema → пересоздать schema.gql. - pnpm -F controller generate-client → graphql-zeus в SDK. - pnpm -F sdk build → unbuild dist. Без codegen фронт ещё не сможет дёрнуть резолвер через Zeus. --- components/controller/src/config/config.ts | 16 +++++ .../dto/apps-catalog-remote-package.dto.ts | 43 ++++++++++++ .../resolvers/apps-catalog-proxy.resolver.ts | 58 ++++++++++++++++ .../apps-catalog-proxy.module.ts | 16 +++++ .../apps-catalog-http.service.ts | 69 +++++++++++++++++++ .../src/extensions/extensions.module.ts | 3 + 6 files changed, 205 insertions(+) create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts diff --git a/components/controller/src/config/config.ts b/components/controller/src/config/config.ts index a3c445b2eb5..2acc1329bef 100644 --- a/components/controller/src/config/config.ts +++ b/components/controller/src/config/config.ts @@ -193,6 +193,18 @@ const envVarsSchema = z.object({ .string() .optional() .describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'), + + // apps-catalog proxy (Story 9.5.b). Витрина remote-пакетов — это HTTP-прокси + // через controller → ca-admin /v1/public/packages. Браузер напрямую в каталог + // не ходит, admin-secret в SPA не утекает (см. architecture-choice C). + APPS_CATALOG_URL: z + .string() + .optional() + .describe('Base URL ca-admin (apps-catalog). Пусто = резолвер вернёт пустой список (degraded mode).'), + APPS_CATALOG_API_KEY: z + .string() + .optional() + .describe('Admin API ключ для apps-catalog. Пусто = degraded mode.'), }); const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env; @@ -324,4 +336,8 @@ export default { signing_secret: envVars.data.FILE_STORAGE_SIGNING_SECRET || envVars.data.SERVER_SECRET, public_base_url: envVars.data.FILE_STORAGE_PUBLIC_BASE_URL || envVars.data.BACKEND_URL, }, + apps_catalog: { + url: envVars.data.APPS_CATALOG_URL, + api_key: envVars.data.APPS_CATALOG_API_KEY, + }, }; diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts new file mode 100644 index 00000000000..f5f67bdab1d --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts @@ -0,0 +1,43 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +/** + * GraphQL-проекция одного remote-пакета в публичном каталоге apps-catalog + * (Story 9.5.b). Источник — `GET /v1/public/packages` на ca-admin, который + * controller проксирует через `Query.appsCatalogRemotePackages`. На фронте + * подписи витрины строятся из этих полей; фильтр self/чужое и self-sub + * bypass делает desktop по `publisher === coopname`. + */ +@ObjectType() +export class AppsCatalogRemotePackageDTO { + @Field({ description: 'Идентификатор пакета (например, @voskhod/demoapp)' }) + packageId!: string; + + @Field({ description: 'Имя владельца пакета (кооператив-разработчик)' }) + publisher!: string; + + @Field(() => [String], { + description: 'Совместимые subnet (chain_id блокчейна ЦК)', + }) + compatibleSubnets!: string[]; + + @Field({ + nullable: true, + description: 'Последняя активная версия (semver). null = релизов ещё нет.', + }) + lastActiveVersion?: string | null; + + @Field({ + description: 'Заголовок пакета для UI (в MVP — packageId, в будущем из manifest)', + }) + title!: string; + + @Field({ + description: 'Краткое описание (в MVP — заглушка, в будущем из manifest)', + }) + description!: string; + + @Field({ + description: 'Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed)', + }) + rubPerMonth!: number; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts new file mode 100644 index 00000000000..86644087d93 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts @@ -0,0 +1,58 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; +import { Logger, UseGuards } from '@nestjs/common'; +import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard'; +import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service'; +import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto'; + +/** + * Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина. + * + * Прокси-резолвер: controller ходит к ca-admin /v1/public/packages, маппит + * snake_case wire-формат в camelCase GraphQL-проекцию, добавляет UI-поля + * (title/description/rubPerMonth) для витрины. В V1 description и + * rubPerMonth — заглушки; в V2 будут читаться из package manifest и + * apps-catalog pricing соответственно. + * + * Guard: GqlJwtAuthGuard — каталог видят только авторизованные пайщики + * (по умолчанию роль `user`). Self-filter и self-sub bypass делает + * desktop по сравнению `publisher` и текущего coopname. + */ +@Resolver() +export class AppsCatalogProxyResolver { + private readonly logger = new Logger(AppsCatalogProxyResolver.name); + + constructor(private readonly client: AppsCatalogHttpService) {} + + @Query(() => [AppsCatalogRemotePackageDTO], { + name: 'appsCatalogRemotePackages', + description: + 'Список remote-пакетов из публичного каталога apps-catalog. ' + + 'Защищён JWT (видят только авторизованные пайщики). ' + + 'Источник — ca-admin /v1/public/packages; controller проксирует.', + }) + @UseGuards(GqlJwtAuthGuard) + async appsCatalogRemotePackages( + @Args('page', { type: () => Number, defaultValue: 1 }) page: number, + @Args('pageSize', { type: () => Number, defaultValue: 50 }) pageSize: number, + ): Promise { + const packages = await this.client.listPublicPackages(page, pageSize); + return packages.map((p) => ({ + packageId: p.packageId, + publisher: p.publisher, + compatibleSubnets: p.compatibleSubnets, + lastActiveVersion: p.lastActiveVersion, + title: this.buildTitle(p.packageId), + description: this.buildDescription(p.publisher), + rubPerMonth: 1000, + })); + } + + private buildTitle(packageId: string): string { + const tail = packageId.split('/').pop() ?? packageId; + return tail.charAt(0).toUpperCase() + tail.slice(1); + } + + private buildDescription(publisher: string): string { + return `Удалённое расширение от ${publisher} — устанавливается без перезагрузки сервера.`; + } +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts b/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts new file mode 100644 index 00000000000..da36b402b6a --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AppsCatalogHttpService } from './infrastructure/apps-catalog-http.service'; +import { AppsCatalogProxyResolver } from './application/resolvers/apps-catalog-proxy.resolver'; + +/** + * Story 9.5.b — модуль-прокси для публичного каталога apps-catalog. + * + * Резолвер `appsCatalogRemotePackages` + HTTP-клиент к ca-admin. + * Никакого state, никаких миграций, без TypeORM. Включается в + * `ExtensionsModule` в общий imports/exports список. + */ +@Module({ + providers: [AppsCatalogHttpService, AppsCatalogProxyResolver], + exports: [AppsCatalogHttpService], +}) +export class AppsCatalogProxyModule {} diff --git a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts new file mode 100644 index 00000000000..0d2369043b3 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import axios, { type AxiosInstance } from 'axios'; +import config from '~/config/config'; + +interface PublicPackageWireFormat { + package_id: string; + owner_username: string; + compatible_subnets: string[]; + last_active_version: string | null; +} + +export interface AppsCatalogPackage { + packageId: string; + publisher: string; + compatibleSubnets: string[]; + lastActiveVersion: string | null; +} + +/** + * HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API + * ключом из env (APPS_CATALOG_API_KEY). Используется только сервером — + * ключ в браузер не утекает. + * + * Degraded mode: если APPS_CATALOG_URL / APPS_CATALOG_API_KEY не заданы — + * возвращает пустой список вместо ошибки, чтобы dev-окружение без + * apps-catalog (или CI без сети) не валило desktop boot. + */ +@Injectable() +export class AppsCatalogHttpService { + private readonly logger = new Logger(AppsCatalogHttpService.name); + private readonly client: AxiosInstance | null; + + constructor() { + if (config.apps_catalog.url && config.apps_catalog.api_key) { + this.client = axios.create({ + baseURL: config.apps_catalog.url, + timeout: 5000, + headers: { + Authorization: `Bearer ${config.apps_catalog.api_key}`, + }, + }); + } else { + this.client = null; + this.logger.warn( + 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы — apps-catalog-proxy в degraded mode (пустой каталог)', + ); + } + } + + async listPublicPackages(page = 1, pageSize = 50): Promise { + if (!this.client) return []; + try { + const res = await this.client.get( + '/v1/public/packages', + { params: { page, page_size: pageSize } }, + ); + return res.data.map((p) => ({ + packageId: p.package_id, + publisher: p.owner_username, + compatibleSubnets: p.compatible_subnets, + lastActiveVersion: p.last_active_version, + })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`apps-catalog list failed: ${msg}`); + return []; + } + } +} diff --git a/components/controller/src/extensions/extensions.module.ts b/components/controller/src/extensions/extensions.module.ts index 92af08ac052..7ef68726dc4 100644 --- a/components/controller/src/extensions/extensions.module.ts +++ b/components/controller/src/extensions/extensions.module.ts @@ -11,6 +11,7 @@ import { ParticipantPluginModule } from './participant/participant-extension.mod import { ChatCoopPluginModule } from './chatcoop/chatcoop-extension.module'; import { OneCoopPluginModule } from './1ccoop/oneccoop-extension.module'; import { ReportsExtensionModule } from './reports/reports-extension.module'; +import { AppsCatalogProxyModule } from './apps-catalog-proxy/apps-catalog-proxy.module'; import { ExtensionDomainModule } from '~/domain/extension/extension-domain.module'; import { GatewayDomainModule } from '~/domain/gateway/gateway-domain.module'; @@ -36,6 +37,7 @@ export class ExtensionsModule { InterCommunicationBridgeModule, OneCoopPluginModule, ReportsExtensionModule, + AppsCatalogProxyModule, ], providers: [], // Экспортируем все модули расширений, чтобы их провайдеры были доступны @@ -53,6 +55,7 @@ export class ExtensionsModule { InterCommunicationBridgeModule, OneCoopPluginModule, ReportsExtensionModule, + AppsCatalogProxyModule, ], }; } From a6d6b0af2fb114a2e54b797baae00231b81db1c5 Mon Sep 17 00:00:00 2001 From: coopops Date: Tue, 2 Jun 2026 22:00:59 +0000 Subject: [PATCH 07/17] =?UTF-8?q?[9.5.b]=20fix(apps-catalog-proxy):=20?= =?UTF-8?q?=D1=8F=D0=B2=D0=BD=D1=8B=D0=B9=20type=20fn=20=D0=B4=D0=BB=D1=8F?= =?UTF-8?q?=20nullable=20Field=20+=20.env.example?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit При nullable=true декоратор @Field NestJS GraphQL не умеет вывести тип из TS-reflection-метаданных (TS пишет в metadata-ключ Object). Без явного type fn (() => String) — UndefinedTypeError на старте. Фикс: - @Field({ nullable: true }) → @Field(() => String, { nullable: true }) для lastActiveVersion: string | null. - .env.example добавлены APPS_CATALOG_URL и APPS_CATALOG_API_KEY с готовыми значениями для dev (URL = DNS-имя ca-admin в mono-shared сети). Verify: coopback стартует чисто (Nest application successfully started), DTO виден через introspection (__type AppsCatalogRemotePackageDTO → поля packageId/publisher/title/description/rubPerMonth/lastActiveVersion/ compatibleSubnets), guard срабатывает (без JWT → 401 Unauthorized). --- .env.example | 7 +++++++ .../application/dto/apps-catalog-remote-package.dto.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 12e9d13b5f1..4ec27e211fd 100644 --- a/.env.example +++ b/.env.example @@ -39,3 +39,10 @@ MINIO_ROOT_PASSWORD=minioadmin CHAIN_URL=http://127.0.0.1:8888 API_URL=http://127.0.0.1:2998/v1/graphql MONGODB_URL=mongodb://127.0.0.1:27017 + +# apps-catalog proxy (Story 9.5.b). Если оба пустые — мы в degraded mode, +# резолвер appsCatalogRemotePackages вернёт пустой список без ошибки. +# URL = DNS-имя контейнера ca-admin в общей сети mono-shared (см. +# docker network inspect mono-shared). +APPS_CATALOG_URL=http://apps-catalog-ca-admin:3002 +APPS_CATALOG_API_KEY=dev-fixture-admin-api-key-DO-NOT-USE-IN-PROD-12345 diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts index f5f67bdab1d..b66978e9e38 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/apps-catalog-remote-package.dto.ts @@ -20,7 +20,7 @@ export class AppsCatalogRemotePackageDTO { }) compatibleSubnets!: string[]; - @Field({ + @Field(() => String, { nullable: true, description: 'Последняя активная версия (semver). null = релизов ещё нет.', }) From 45def1bbec19f5a0046256ef6ceabc78cd18f50f Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 04:52:20 +0000 Subject: [PATCH 08/17] =?UTF-8?q?[9.5.b]=20feat(sdk+desktop):=20Zeus=20cli?= =?UTF-8?q?ent=20+=20ExtensionsShowcase=20=D1=80=D0=B5=D0=B0=D0=BB=D1=8C?= =?UTF-8?q?=D0=BD=D1=8B=D0=B9=20fetch=20apps-catalog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sdk/zeus: regenerated через `controller generate-schema && generate-client` + `pnpm -F sdk build`; типы AppsCatalogRemotePackageDTO и Query.appsCatalogRemotePackages пробрасываются end-to-end. - sdk/selectors/extensions: новый appsCatalogRemotePackageSelector с валидатором MakeAllFieldsRequired. - sdk/queries/extensions: новый namespace Queries.Extensions.AppsCatalogRemotePackages (page/pageSize → AppsCatalogRemotePackageDTO[]). - desktop/entities/Extension/api: api.loadAppsCatalogRemotePackages(page, pageSize) по канону client.Query + строгие типы из @coopenomics/sdk. - desktop/pages/ExtensionStore/ExtensionsShowcase: убран inline-mock, каталог загружается через Zeus query. Добавлены loading/error/empty состояния по дизайн-канону (--p-*, base-card токены), плюсификация слова «пакет», version-чип и Intl-форматирование цены. Это шаги 3–4 V2 9.5.b. Закрывает task #38 и #37. --- .../src/entities/Extension/api/index.ts | 19 +- .../ExtensionsShowcase/ExtensionsShowcase.vue | 174 ++++++++++++++---- .../extensions/appsCatalogRemotePackages.ts | 30 +++ .../sdk/src/queries/extensions/index.ts | 3 + .../appsCatalogRemotePackageSelector.ts | 18 ++ .../sdk/src/selectors/extensions/index.ts | 3 +- components/sdk/src/zeus/const.ts | 13 ++ components/sdk/src/zeus/index.ts | 75 ++++++++ 8 files changed, 301 insertions(+), 34 deletions(-) create mode 100644 components/sdk/src/queries/extensions/appsCatalogRemotePackages.ts create mode 100644 components/sdk/src/selectors/extensions/appsCatalogRemotePackageSelector.ts diff --git a/components/desktop/src/entities/Extension/api/index.ts b/components/desktop/src/entities/Extension/api/index.ts index 5c19303458a..3d57ecdd49c 100644 --- a/components/desktop/src/entities/Extension/api/index.ts +++ b/components/desktop/src/entities/Extension/api/index.ts @@ -27,7 +27,24 @@ async function loadExtensionLogs(data?: Queries.Extensions.GetExtensionLogs.IInp return output; } +async function loadAppsCatalogRemotePackages( + page = 1, + pageSize = 50, +): Promise { + const { [Queries.Extensions.AppsCatalogRemotePackages.name]: output } = await client.Query( + Queries.Extensions.AppsCatalogRemotePackages.query, + { + variables: { + page, + pageSize, + }, + }, + ); + return output; +} + export const api ={ loadExtensions, - loadExtensionLogs + loadExtensionLogs, + loadAppsCatalogRemotePackages } diff --git a/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue b/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue index 3dd90e69190..cdcc480904e 100644 --- a/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue +++ b/components/desktop/src/pages/ExtensionStore/ExtensionsShowcase/ExtensionsShowcase.vue @@ -4,9 +4,9 @@ template(#avatar) q-icon(name='fa-solid fa-rocket') | Epic 9 — магазин приложений. Bundle-расширения ниже — нативные, - | оплачивать не нужно. Раздел «Удалённые пакеты» — V1 demo: реальная - | подписка появится после 9.3.b/9.5.b (стол разработчика + backend - | резолверы). + | оплачивать не нужно. Удалённые пакеты подгружаются из публичного + | каталога apps-catalog через проксирующий резолвер controller'а + | (Story 9.5.b). section.catalog-section h3.catalog-section__title Нативные расширения @@ -20,8 +20,36 @@ section.catalog-section section.catalog-section.catalog-section--remote .catalog-section__head h3.catalog-section__title Удалённые пакеты - span.catalog-section__hint Каталог apps-catalog (1000 ₽/мес) - .catalog-grid + span.catalog-section__hint(v-if='!remoteLoading && !remoteError && remoteExtensions.length') + | Каталог apps-catalog · {{ remoteExtensions.length }} {{ packagesWord }} + + .catalog-state(v-if='remoteLoading') + q-spinner-dots(size='32px', color='primary') + span.catalog-state__text Загружаем каталог apps-catalog… + + .catalog-state.catalog-state--error(v-else-if='remoteError') + q-icon.catalog-state__icon(name='fa-solid fa-triangle-exclamation', size='28px') + .catalog-state__body + h4.catalog-state__title Не удалось загрузить удалённые пакеты + p.catalog-state__text {{ remoteError }} + q-btn( + unelevated, + color='primary', + size='sm', + icon='fa-solid fa-rotate-right', + label='Повторить', + @click='loadRemote' + ) + + .catalog-state.catalog-state--empty(v-else-if='!remoteExtensions.length') + q-icon.catalog-state__icon(name='fa-solid fa-box-open', size='28px') + .catalog-state__body + h4.catalog-state__title Каталог пока пуст + p.catalog-state__text + | В apps-catalog ещё не опубликован ни один пакет. Зайдите позже + | или соберите свой пакет в столе разработчика. + + .catalog-grid(v-else) article.remote-card( v-for='pkg in remoteExtensions', :key='pkg.packageId' @@ -39,7 +67,13 @@ section.catalog-section.catalog-section--remote span.remote-card__publisher q-icon(name='fa-solid fa-building' size='12px') | {{ pkg.publisher }} - span.remote-card__price {{ pkg.rubPerMonth }} ₽/мес + span.remote-card__version( + v-if='pkg.lastActiveVersion', + title='Последняя активная версия' + ) + q-icon(name='fa-solid fa-code-branch' size='12px') + | {{ pkg.lastActiveVersion }} + span.remote-card__price {{ formatPrice(pkg.rubPerMonth) }} ₽/мес .remote-card__foot q-btn.remote-card__btn( unelevated, @@ -55,11 +89,12 @@ q-dialog(v-model='subscribeDialog') q-card-section .text-h6 Подписка на {{ pendingPkg?.title || 'пакет' }} q-card-section.q-pt-none - p Стоимость: {{ pendingPkg?.rubPerMonth }} ₽/мес. Период оплаты определяется - | globals.min_payment_period_seconds. + p Стоимость: {{ pendingPkg ? formatPrice(pendingPkg.rubPerMonth) : '—' }} ₽/мес. + | Период оплаты определяется globals.min_payment_period_seconds. p.text-caption.q-mb-none - | V1 demo: реальная подписка через mutation appsCatalogSubscribe + - | on-chain `apps::subscribe` — отложена в 9.5.b (см. PR mono#64). + | Реальная подпись и трансфер AXON будут включены после 9.6.b + | (auto-extend self-subscription) — сейчас отображается информация + | из публичного каталога apps-catalog. q-card-actions(align='right') q-btn(flat, label='Закрыть', v-close-popup) @@ -67,16 +102,14 @@ q-dialog(v-model='subscribeDialog') @@ -145,6 +198,54 @@ onMounted(async () => { gap: var(--p-4, 16px); } +.catalog-state { + display: flex; + align-items: center; + gap: var(--p-4, 16px); + padding: var(--p-5, 20px); + background: var(--p-surface, #fff); + border: 1px solid var(--p-line); + border-radius: var(--p-r-lg, 14px); + color: var(--p-ink-2); +} + +.catalog-state--error { + border-color: var(--p-warn, #f59e0b); + background: var(--p-warn-soft, #fff7ed); + color: var(--p-ink); +} + +.catalog-state--empty { + border-style: dashed; +} + +.catalog-state__icon { + color: var(--p-accent, #f97316); +} + +.catalog-state--error .catalog-state__icon { + color: var(--p-warn, #f59e0b); +} + +.catalog-state__body { + display: flex; + flex-direction: column; + gap: var(--p-2, 8px); +} + +.catalog-state__title { + margin: 0; + font-size: var(--p-fs-h4); + font-weight: 600; + color: var(--p-ink); +} + +.catalog-state__text { + margin: 0; + font-size: var(--p-fs-body-sm); + line-height: 1.5; +} + .remote-card { display: flex; flex-direction: column; @@ -154,6 +255,12 @@ onMounted(async () => { border: 1px solid var(--p-line); border-radius: var(--p-r-lg, 14px); min-height: 200px; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} + +.remote-card:hover { + border-color: var(--p-line-strong, #d4d4d8); + box-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.08); } .remote-card__head { @@ -187,19 +294,22 @@ onMounted(async () => { .remote-card__meta { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; + gap: var(--p-3, 12px); margin-top: auto; font-size: var(--p-fs-body-sm); color: var(--p-ink-2); } -.remote-card__publisher { +.remote-card__publisher, +.remote-card__version { display: inline-flex; align-items: center; gap: var(--p-1, 4px); } .remote-card__price { + margin-left: auto; font-weight: 600; color: var(--p-ink); } diff --git a/components/sdk/src/queries/extensions/appsCatalogRemotePackages.ts b/components/sdk/src/queries/extensions/appsCatalogRemotePackages.ts new file mode 100644 index 00000000000..7c480ccc786 --- /dev/null +++ b/components/sdk/src/queries/extensions/appsCatalogRemotePackages.ts @@ -0,0 +1,30 @@ +import { appsCatalogRemotePackageSelector } from '../../selectors/extensions/appsCatalogRemotePackageSelector' +import { $, type GraphQLTypes, type InputType, Selector } from '../../zeus/index' + +export const name = 'appsCatalogRemotePackages' + +/** + * Публичный каталог remote-пакетов из apps-catalog (Story 9.5.b). + * + * Прокси-резолвер на mono controller'е дергает ca-admin + * `GET /v1/public/packages` и возвращает список с UI-полями + * (title / description / rubPerMonth — на MVP из дефолтов, в будущем из manifest). + */ +export const query = Selector('Query')({ + [name]: [ + { page: $('page', 'Float!'), pageSize: $('pageSize', 'Float!') }, + appsCatalogRemotePackageSelector, + ], +}) + +export interface IInput { + /** + * @private + */ + [key: string]: unknown + + page: number + pageSize: number +} + +export type IOutput = InputType diff --git a/components/sdk/src/queries/extensions/index.ts b/components/sdk/src/queries/extensions/index.ts index 7728deccb7b..f21c48d0b58 100644 --- a/components/sdk/src/queries/extensions/index.ts +++ b/components/sdk/src/queries/extensions/index.ts @@ -1,3 +1,6 @@ +/** Публичный каталог remote-пакетов apps-catalog (Story 9.5.b) */ +export * as AppsCatalogRemotePackages from './appsCatalogRemotePackages' + /** Получить логи расширений с фильтрацией и пагинацией */ export * as GetExtensionLogs from './getExtensionLogs' diff --git a/components/sdk/src/selectors/extensions/appsCatalogRemotePackageSelector.ts b/components/sdk/src/selectors/extensions/appsCatalogRemotePackageSelector.ts new file mode 100644 index 00000000000..00972c81970 --- /dev/null +++ b/components/sdk/src/selectors/extensions/appsCatalogRemotePackageSelector.ts @@ -0,0 +1,18 @@ +import type { MakeAllFieldsRequired } from '../../utils/MakeAllFieldsRequired' +import { type ModelTypes, Selector, type ValueTypes } from '../../zeus/index' + +const rawAppsCatalogRemotePackageSelector = { + packageId: true, + publisher: true, + compatibleSubnets: true, + lastActiveVersion: true, + title: true, + description: true, + rubPerMonth: true, +} + +const _validate: MakeAllFieldsRequired = rawAppsCatalogRemotePackageSelector +export type appsCatalogRemotePackageModel = ModelTypes['AppsCatalogRemotePackageDTO'] + +export const appsCatalogRemotePackageSelector = Selector('AppsCatalogRemotePackageDTO')(rawAppsCatalogRemotePackageSelector) +export { rawAppsCatalogRemotePackageSelector } diff --git a/components/sdk/src/selectors/extensions/index.ts b/components/sdk/src/selectors/extensions/index.ts index 277ace8926f..d0000747f1a 100644 --- a/components/sdk/src/selectors/extensions/index.ts +++ b/components/sdk/src/selectors/extensions/index.ts @@ -1 +1,2 @@ -export * from './extensionSelector' \ No newline at end of file +export * from './appsCatalogRemotePackageSelector' +export * from './extensionSelector' diff --git a/components/sdk/src/zeus/const.ts b/components/sdk/src/zeus/const.ts index 937e291626a..0cf3b414f14 100644 --- a/components/sdk/src/zeus/const.ts +++ b/components/sdk/src/zeus/const.ts @@ -1337,6 +1337,9 @@ export const AllTypesProps: Record = { agreements:{ filter:"AgreementFilter", options:"PaginationInput" + }, + appsCatalogRemotePackages:{ + }, buildInitialReportEdits:{ reportType:"ReportType" @@ -2040,6 +2043,15 @@ export const ReturnTypes: Record = { status:"ApprovalStatus", username:"String" }, + AppsCatalogRemotePackageDTO:{ + compatibleSubnets:"String", + description:"String", + lastActiveVersion:"String", + packageId:"String", + publisher:"String", + rubPerMonth:"Float", + title:"String" + }, AuthSequence:{ account:"String", sequence:"String" @@ -4118,6 +4130,7 @@ export const ReturnTypes: Record = { Query:{ agreementTemplates:"AgreementTemplate", agreements:"PaginatedAgreementsPaginationResult", + appsCatalogRemotePackages:"AppsCatalogRemotePackageDTO", buildInitialReportEdits:"BuildInitialReportEdits", candidates:"PaginatedCandidatesPaginationResult", capitalCandidates:"PaginatedCapitalCandidatesPaginationResult", diff --git a/components/sdk/src/zeus/index.ts b/components/sdk/src/zeus/index.ts index 53d826e7692..fcfd292b4af 100644 --- a/components/sdk/src/zeus/index.ts +++ b/components/sdk/src/zeus/index.ts @@ -1741,6 +1741,24 @@ export type ValueTypes = { }; /** Статус одобрения в системе CHAIRMAN */ ["ApprovalStatus"]:ApprovalStatus; + ["AppsCatalogRemotePackageDTO"]: AliasType<{ + /** Совместимые subnet (chain_id блокчейна ЦК) */ + compatibleSubnets?:boolean | `@${string}`, + /** Краткое описание (в MVP — заглушка, в будущем из manifest) */ + description?:boolean | `@${string}`, + /** Последняя активная версия (semver). null = релизов ещё нет. */ + lastActiveVersion?:boolean | `@${string}`, + /** Идентификатор пакета (например, @voskhod/demoapp) */ + packageId?:boolean | `@${string}`, + /** Имя владельца пакета (кооператив-разработчик) */ + publisher?:boolean | `@${string}`, + /** Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed) */ + rubPerMonth?:boolean | `@${string}`, + /** Заголовок пакета для UI (в MVP — packageId, в будущем из manifest) */ + title?:boolean | `@${string}`, + __typename?: boolean | `@${string}`, + ['...on AppsCatalogRemotePackageDTO']?: Omit +}>; ["AssetContributionActGenerateDocumentInput"]: { /** Идентификатор акта */ act_id: string | Variable, @@ -7769,6 +7787,7 @@ walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable},V ["Query"]: AliasType<{ agreementTemplates?: [{ coopname: string | Variable},ValueTypes["AgreementTemplate"]], agreements?: [{ filter?: ValueTypes["AgreementFilter"] | undefined | null | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["PaginatedAgreementsPaginationResult"]], +appsCatalogRemotePackages?: [{ page: number | Variable, pageSize: number | Variable},ValueTypes["AppsCatalogRemotePackageDTO"]], buildInitialReportEdits?: [{ period?: number | undefined | null | Variable, reportType: ValueTypes["ReportType"] | Variable, year: number | Variable},ValueTypes["BuildInitialReportEdits"]], candidates?: [{ filter?: ValueTypes["CandidateFilterInput"] | undefined | null | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["PaginatedCandidatesPaginationResult"]], capitalCandidates?: [{ filter?: ValueTypes["CandidateFilterInput"] | undefined | null | Variable, options?: ValueTypes["PaginationInput"] | undefined | null | Variable},ValueTypes["PaginatedCapitalCandidatesPaginationResult"]], @@ -10208,6 +10227,23 @@ export type ResolverInputTypes = { }; /** Статус одобрения в системе CHAIRMAN */ ["ApprovalStatus"]:ApprovalStatus; + ["AppsCatalogRemotePackageDTO"]: AliasType<{ + /** Совместимые subnet (chain_id блокчейна ЦК) */ + compatibleSubnets?:boolean | `@${string}`, + /** Краткое описание (в MVP — заглушка, в будущем из manifest) */ + description?:boolean | `@${string}`, + /** Последняя активная версия (semver). null = релизов ещё нет. */ + lastActiveVersion?:boolean | `@${string}`, + /** Идентификатор пакета (например, @voskhod/demoapp) */ + packageId?:boolean | `@${string}`, + /** Имя владельца пакета (кооператив-разработчик) */ + publisher?:boolean | `@${string}`, + /** Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed) */ + rubPerMonth?:boolean | `@${string}`, + /** Заголовок пакета для UI (в MVP — packageId, в будущем из manifest) */ + title?:boolean | `@${string}`, + __typename?: boolean | `@${string}` +}>; ["AssetContributionActGenerateDocumentInput"]: { /** Идентификатор акта */ act_id: string, @@ -16058,6 +16094,7 @@ walmoveWallets?: [{ input: ResolverInputTypes["WalmoveInput"]},ResolverInputType ["Query"]: AliasType<{ agreementTemplates?: [{ coopname: string},ResolverInputTypes["AgreementTemplate"]], agreements?: [{ filter?: ResolverInputTypes["AgreementFilter"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedAgreementsPaginationResult"]], +appsCatalogRemotePackages?: [{ page: number, pageSize: number},ResolverInputTypes["AppsCatalogRemotePackageDTO"]], buildInitialReportEdits?: [{ period?: number | undefined | null, reportType: ResolverInputTypes["ReportType"], year: number},ResolverInputTypes["BuildInitialReportEdits"]], candidates?: [{ filter?: ResolverInputTypes["CandidateFilterInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedCandidatesPaginationResult"]], capitalCandidates?: [{ filter?: ResolverInputTypes["CandidateFilterInput"] | undefined | null, options?: ResolverInputTypes["PaginationInput"] | undefined | null},ResolverInputTypes["PaginatedCapitalCandidatesPaginationResult"]], @@ -18442,6 +18479,22 @@ export type ModelTypes = { username?: string | undefined | null }; ["ApprovalStatus"]:ApprovalStatus; + ["AppsCatalogRemotePackageDTO"]: { + /** Совместимые subnet (chain_id блокчейна ЦК) */ + compatibleSubnets: Array, + /** Краткое описание (в MVP — заглушка, в будущем из manifest) */ + description: string, + /** Последняя активная версия (semver). null = релизов ещё нет. */ + lastActiveVersion?: string | undefined | null, + /** Идентификатор пакета (например, @voskhod/demoapp) */ + packageId: string, + /** Имя владельца пакета (кооператив-разработчик) */ + publisher: string, + /** Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed) */ + rubPerMonth: number, + /** Заголовок пакета для UI (в MVP — packageId, в будущем из manifest) */ + title: string +}; ["AssetContributionActGenerateDocumentInput"]: { /** Идентификатор акта */ act_id: string, @@ -24558,6 +24611,8 @@ export type ModelTypes = { agreementTemplates: Array, /** Получение списка соглашений с фильтрацией и пагинацией */ agreements: ModelTypes["PaginatedAgreementsPaginationResult"], + /** Список remote-пакетов из публичного каталога apps-catalog. Защищён JWT (видят только авторизованные пайщики). Источник — ca-admin /v1/public/packages; controller проксирует. */ + appsCatalogRemotePackages: Array, /** Построить предзаполненные edits для формы: дефолты (ledger2 + реквизиты + корректировки), с наложением dirty-полей существующего черновика (если он есть). Требуемые роли: chairman. */ @@ -27110,6 +27165,24 @@ export type GraphQLTypes = { }; /** Статус одобрения в системе CHAIRMAN */ ["ApprovalStatus"]: ApprovalStatus; + ["AppsCatalogRemotePackageDTO"]: { + __typename: "AppsCatalogRemotePackageDTO", + /** Совместимые subnet (chain_id блокчейна ЦК) */ + compatibleSubnets: Array, + /** Краткое описание (в MVP — заглушка, в будущем из manifest) */ + description: string, + /** Последняя активная версия (semver). null = релизов ещё нет. */ + lastActiveVersion?: string | undefined | null, + /** Идентификатор пакета (например, @voskhod/demoapp) */ + packageId: string, + /** Имя владельца пакета (кооператив-разработчик) */ + publisher: string, + /** Стоимость подписки, RUB/месяц (в MVP — фиксированно из dev-pricing seed) */ + rubPerMonth: number, + /** Заголовок пакета для UI (в MVP — packageId, в будущем из manifest) */ + title: string, + ['...on AppsCatalogRemotePackageDTO']: Omit +}; ["AssetContributionActGenerateDocumentInput"]: { /** Идентификатор акта */ act_id: string, @@ -33622,6 +33695,8 @@ export type GraphQLTypes = { agreementTemplates: Array, /** Получение списка соглашений с фильтрацией и пагинацией */ agreements: GraphQLTypes["PaginatedAgreementsPaginationResult"], + /** Список remote-пакетов из публичного каталога apps-catalog. Защищён JWT (видят только авторизованные пайщики). Источник — ca-admin /v1/public/packages; controller проксирует. */ + appsCatalogRemotePackages: Array, /** Построить предзаполненные edits для формы: дефолты (ledger2 + реквизиты + корректировки), с наложением dirty-полей существующего черновика (если он есть). Требуемые роли: chairman. */ From da50e423ca02aff288f0fac8c8e9a5e585177464 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 05:26:05 +0000 Subject: [PATCH 09/17] =?UTF-8?q?[9.4.b]=20feat(controller+desktop):=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20remote-loade?= =?UTF-8?q?r=20fetch+eval=20install.js?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mono controller: - AppsCatalogHttpService.fetchInstallScript(scope, name) — fetch text install.js из ca-admin /v1/public/packages/:scope/:name/install.js; degraded mode (null) при отсутствии env-config'а apps-catalog'а. - Новый REST controller AppsCatalogInstallScriptController `GET /v1/apps-catalog/install/:scope/:name` под HttpJwtAuthGuard: валидирует scope/name regex'ом, проксирует на ca-admin, отдаёт text/javascript + Cache-Control: no-store. Admin-API key остаётся на backend'е. - AppsCatalogProxyModule: добавлен controller в module.controllers[]. mono desktop: - remote-loader.ts: реальный fetch + CJS-eval. fetchInstalledRemotePackages читает Queries.Extensions.AppsCatalogRemotePackages, парсит packageId на (scope, name). loadRemoteExtension качает install.js по REST через axios responseType:'text' с JWT desktop'а, оборачивает в `new Function('module','exports','require', code)`, вызывает `module.exports.install()` → IWorkspaceConfig[]. - Try/catch per item (NFR15-стиль): падение одного пакета не валит остальные. require() сразу throw — пакеты должны быть CJS без внешних зависимостей (sandbox — Story 11.x). - sha256 пока не проверяем (release-таблица будет в 9.4.c). Demo path: voskhod publishes @voskhod/demo-app → ca-admin отдаёт examples/demo-app/install.js → controller-proxy → desktop boot тянет + eval'ит + получает workspace «Demo» с роутом demo-hello. --- .../controllers/install-script.controller.ts | 59 ++++++ .../apps-catalog-proxy.module.ts | 9 +- .../apps-catalog-http.service.ts | 29 +++ .../remote-loader.ts | 187 ++++++++++++++---- 4 files changed, 243 insertions(+), 41 deletions(-) create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/controllers/install-script.controller.ts diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/controllers/install-script.controller.ts b/components/controller/src/extensions/apps-catalog-proxy/application/controllers/install-script.controller.ts new file mode 100644 index 00000000000..368871dfef8 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/controllers/install-script.controller.ts @@ -0,0 +1,59 @@ +import { + Controller, + Get, + Header, + HttpException, + HttpStatus, + Param, + Res, + UseGuards, +} from '@nestjs/common'; +import type { Response } from 'express'; +import { HttpJwtAuthGuard } from '~/application/auth/guards/http-jwt-auth.guard'; +import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service'; + +const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/; + +/** + * Story 9.4.b — REST-проксирование install.js пакета из apps-catalog к + * desktop'у. Защищён HTTP JWT — пайщик должен быть залогинен в свой + * кооператив, иначе нельзя загружать remote-расширения. Admin-API ключ + * apps-catalog'а инкапсулирован в backend'е и не утекает в браузер + * (см. architecture-choice C в Epic 9.5.b). + * + * Endpoint: + * `GET /v1/apps-catalog/install/:scope/:name` + * → 200 text/javascript — CJS install.js + * → 404 если пакета нет (или ca-admin недоступен в degraded mode). + * → 401 без JWT. + */ +@Controller('v1/apps-catalog/install') +@UseGuards(HttpJwtAuthGuard) +export class AppsCatalogInstallScriptController { + constructor(private readonly catalog: AppsCatalogHttpService) {} + + @Get(':scope/:name') + @Header('Content-Type', 'application/javascript; charset=utf-8') + @Header('Cache-Control', 'no-store') + async execute( + @Param('scope') scope: string, + @Param('name') name: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + if (!NAME_REGEX.test(scope) || !NAME_REGEX.test(name)) { + throw new HttpException( + 'Invalid package coordinates', + HttpStatus.NOT_FOUND, + ); + } + const code = await this.catalog.fetchInstallScript(scope, name); + if (code === null) { + throw new HttpException( + 'install.js not available', + HttpStatus.NOT_FOUND, + ); + } + res.status(HttpStatus.OK); + return code; + } +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts b/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts index da36b402b6a..28cabd8ff34 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/apps-catalog-proxy.module.ts @@ -1,15 +1,18 @@ import { Module } from '@nestjs/common'; import { AppsCatalogHttpService } from './infrastructure/apps-catalog-http.service'; +import { AppsCatalogInstallScriptController } from './application/controllers/install-script.controller'; import { AppsCatalogProxyResolver } from './application/resolvers/apps-catalog-proxy.resolver'; /** - * Story 9.5.b — модуль-прокси для публичного каталога apps-catalog. + * Story 9.5.b + 9.4.b — модуль-прокси для публичного каталога apps-catalog. * - * Резолвер `appsCatalogRemotePackages` + HTTP-клиент к ca-admin. - * Никакого state, никаких миграций, без TypeORM. Включается в + * GraphQL `Query.appsCatalogRemotePackages` (9.5.b) + REST + * `GET /v1/apps-catalog/install/:scope/:name` (9.4.b) + HTTP-клиент к + * ca-admin. Никакого state, никаких миграций, без TypeORM. Включается в * `ExtensionsModule` в общий imports/exports список. */ @Module({ + controllers: [AppsCatalogInstallScriptController], providers: [AppsCatalogHttpService, AppsCatalogProxyResolver], exports: [AppsCatalogHttpService], }) diff --git a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts index 0d2369043b3..739998c81a9 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts @@ -66,4 +66,33 @@ export class AppsCatalogHttpService { return []; } } + + /** + * Story 9.4.b: получить install.js пакета remote-расширения. + * + * Возвращает plain CJS-текст из ca-admin endpoint'а + * `GET /v1/public/packages/:scope/:name/install.js`. На degraded mode + * (без env-config'а apps-catalog'а) либо если ca-admin отвечает 404 + * — возвращает `null`, чтобы desktop'у было ясно «нет такого пакета / + * каталог недоступен» вместо ошибки 500 в браузере. + * + * @param scope владелец пакета (`voskhod` из `@voskhod/demo-app`). + * @param name short-name пакета (`demo-app`). + */ + async fetchInstallScript(scope: string, name: string): Promise { + if (!this.client) return null; + try { + const res = await this.client.get( + `/v1/public/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/install.js`, + { responseType: 'text', transformResponse: (data) => data as string }, + ); + return typeof res.data === 'string' ? res.data : String(res.data); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error( + `apps-catalog install.js fetch failed for ${scope}/${name}: ${msg}`, + ); + return null; + } + } } diff --git a/components/desktop/src/processes/init-installed-extensions/remote-loader.ts b/components/desktop/src/processes/init-installed-extensions/remote-loader.ts index 43ed42cbf85..fe0c57734b5 100644 --- a/components/desktop/src/processes/init-installed-extensions/remote-loader.ts +++ b/components/desktop/src/processes/init-installed-extensions/remote-loader.ts @@ -1,63 +1,167 @@ -// Epic 9 story 9.4 — remote-loader для apps-catalog. +// Epic 9 stories 9.4 (skeleton) + 9.4.b (real fetch+eval). // // На boot'е desktop'а, после регистрации bundle-extensions, идём в coopback // за списком пакетов с активной подпиской у текущего кооператива и пробуем -// dynamic-eval'ом распаковать install.js из каждого tarball'а, чтобы получить +// dynamic-eval'ом распаковать install.js из каждого пакета, чтобы получить // `IWorkspaceConfig[]` и добавить роуты в router. // -// V1 МИНИМУМ (заглушка): -// - Логирует попытку, не падает, не сетевые запросы. -// - Возвращает пустой массив, чтобы `init-installed-extensions/index.ts` -// просто пропустил remote-pass. +// MVP (9.4.b) +// - Список remote-пакетов читаем из публичного каталога apps-catalog +// (Queries.Extensions.AppsCatalogRemotePackages) — это все пакеты, +// для пилота demo-app self-sub bypass превращает «всех» в «всё что +// должно быть установлено у кооператива-разработчика». +// - На каждый — REST `GET /v1/apps-catalog/install/:scope/:name` за CJS +// install.js (controller-proxy через ca-admin). HTTP JWT desktop'а. +// - sha256 пока не проверяем (release-таблица ещё не реализована; +// V2 9.4.c добавит manifest+sha256+release-row). +// - eval через `new Function('module','exports','require', code)` — +// контракт `module.exports = { install: () => Promise }`. +// - Try/catch per item: падение одного пакета не валит остальные. // -// V2 (story 9.4.b) — настоящий fetch + eval. Контракт coopback'а: -// GET /api/catalog/installed-remote?coopname= -// → { items: [{ packageId, version, tarballUrl, sha256, jwt }] } -// Контракт ca-auth (pull-surface): -// GET /v1/pull//?token= -// → application/gzip tar (распакованный install.js + package.json) -// -// Изоляция: V1 НЕ выполняет код пакетов (это будет sandbox в story 11.x). +// Изоляция: V1 НЕ выполняет код пакетов в sandbox — это будет в story 11.x. +// На MVP исполнение происходит в том же window/contexte что и desktop SPA. +import axios from 'axios'; import type { Router } from 'vue-router'; +import { Queries } from '@coopenomics/sdk'; +import { client } from 'src/shared/api/client'; +import { env } from 'src/shared/config'; +import { useGlobalStore } from 'src/shared/store'; import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace'; export interface RemoteExtensionDescriptor { - packageId: string; // например `@voskhod/demoapp` - version: string; // semver - tarballUrl: string; // ca-auth pull URL - sha256: string; // ожидаемый sha256 распакованного install.js - jwt: string; // tenant-token для pull-запроса + packageId: string; + scope: string; + name: string; + publisher: string; + version: string | null; + title: string; + description: string; +} + +interface RemoteInstallModule { + install?: () => Promise | IWorkspaceConfig[]; + default?: () => Promise | IWorkspaceConfig[]; } /** - * Запросить у coopback список remote-пакетов, на которые у текущего - * кооператива есть активная подписка. На V1 — заглушка. + * Распилить `@scope/name` на компоненты. `null` если формат не подходит — + * безымянные / без scope пакеты на MVP не поддерживаем. + */ +function splitPackageId( + packageId: string, +): { scope: string; name: string } | null { + const match = /^@([a-z0-9][a-z0-9-]{0,63})\/([a-z0-9][a-z0-9-]{0,63})$/.exec( + packageId, + ); + if (!match) return null; + return { scope: match[1], name: match[2] }; +} + +/** + * Запросить у coopback список remote-пакетов через apps-catalog-proxy + * (Story 9.5.b). На MVP отдаются ВСЕ опубликованные пакеты — фильтр + * «подписаны / не подписаны» появится в 9.6.c вместе с реальной + * subscription read-port. */ export async function fetchInstalledRemotePackages( - _coopname: string, + coopname: string, ): Promise { - // TODO (story 9.4.b): реальный fetch к coopback'у. - // const res = await api.get<{ items: RemoteExtensionDescriptor[] }>( - // `/api/catalog/installed-remote?coopname=${encodeURIComponent(coopname)}`, - // ); - // return res.items; - return []; + // coopname зарезервирован для 9.6.c (фильтр по подпискам tenant'а); + // сейчас прокси отдаёт публичный каталог без фильтра. + void coopname; + try { + const out = await client.Query( + Queries.Extensions.AppsCatalogRemotePackages.query, + { variables: { page: 1, pageSize: 50 } }, + ); + const list = + out[Queries.Extensions.AppsCatalogRemotePackages.name] ?? []; + const result: RemoteExtensionDescriptor[] = []; + for (const pkg of list) { + const coords = splitPackageId(pkg.packageId); + if (!coords) { + console.warn( + `[remote-loader] пропуск пакета с неподдерживаемым packageId: ${pkg.packageId}`, + ); + continue; + } + result.push({ + packageId: pkg.packageId, + scope: coords.scope, + name: coords.name, + publisher: pkg.publisher, + version: pkg.lastActiveVersion ?? null, + title: pkg.title, + description: pkg.description, + }); + } + return result; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error('[remote-loader] не удалось получить каталог:', msg); + return []; + } } /** - * Скачать tarball, распаковать, eval'ом получить IWorkspaceConfig[]. - * На V1 — заглушка, возвращает пустой массив. + * Скачать install.js пакета и eval'нуть его в plain CJS-обёртке. + * Возвращает массив workspace'ов, который install() пакета вернул. + * + * На MVP sha256 не валидируется; manifest / release-row появятся в 9.4.c. */ export async function loadRemoteExtension( desc: RemoteExtensionDescriptor, ): Promise { - // TODO (story 9.4.b): fetch + tar-x + dynamic require + sha256-check. - console.warn( - `[remote-loader] story 9.4.b TODO: установка ${desc.packageId}@${desc.version} ` + - `пропущена в V1 пилоте (см. docs/epics/E9-demo-app-pilot.md).`, - ); - return []; + const { tokens } = useGlobalStore(); + const url = `${env.BACKEND_URL}/v1/apps-catalog/install/${encodeURIComponent( + desc.scope, + )}/${encodeURIComponent(desc.name)}`; + const response = await axios.get(url, { + responseType: 'text', + transformResponse: (data: unknown) => + typeof data === 'string' ? data : String(data), + headers: tokens?.access?.token + ? { Authorization: `Bearer ${tokens.access.token}` } + : {}, + }); + const code: string = response.data; + if (typeof code !== 'string' || code.length === 0) { + throw new Error('empty install.js response'); + } + + const module: { exports: RemoteInstallModule } = { exports: {} }; + const requireNotSupported = (id: string) => { + throw new Error( + `require("${id}") не поддерживается в remote-loader; пакет ${desc.packageId} должен быть CJS без внешних зависимостей`, + ); + }; + + const factory = new Function( + 'module', + 'exports', + 'require', + `"use strict";\n${code}`, + ) as ( + module: { exports: RemoteInstallModule }, + exports: RemoteInstallModule, + require: (id: string) => unknown, + ) => void; + factory(module, module.exports, requireNotSupported); + + const installFn = module.exports.install ?? module.exports.default; + if (typeof installFn !== 'function') { + throw new Error( + `пакет ${desc.packageId} не экспортирует install() — ${typeof installFn}`, + ); + } + const cfgs = await installFn(); + if (!Array.isArray(cfgs)) { + throw new Error( + `install() пакета ${desc.packageId} вернул не массив: ${typeof cfgs}`, + ); + } + return cfgs; } /** @@ -68,17 +172,24 @@ export async function loadRemoteExtension( */ export async function installRemoteExtensions( coopname: string, - _router: Router, + router: Router, ): Promise { + // router зарезервирован для будущей story 11.x (sandbox-аутоинъекция + // роутов из install.js без возврата в caller); сейчас caller сам + // вписывает routes в `router.addRoute('base', ...)`. + void router; const descriptors = await fetchInstalledRemotePackages(coopname); const all: IWorkspaceConfig[] = []; for (const desc of descriptors) { try { const cfgs = await loadRemoteExtension(desc); all.push(...cfgs); + console.log( + `[remote-loader] установлен ${desc.packageId}${desc.version ? '@' + desc.version : ''} (${cfgs.length} workspace[ов])`, + ); } catch (err) { console.error( - `[remote-loader] не удалось установить ${desc.packageId}@${desc.version}:`, + `[remote-loader] не удалось установить ${desc.packageId}:`, err, ); } From f6b5c0103046da26caa362c2eecd6d9e92971d12 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:24:51 +0000 Subject: [PATCH 10/17] =?UTF-8?q?[E10-1]=20feat(controller):=20=D0=BF?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20GraphQL=20=D0=BD=D0=B0=20ApolloFederationDriver=20(sub?= =?UTF-8?q?graph=20mode)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic 10 Story 10.1 — coopback становится первым federation v2 subgraph'ом. Локальная core-схема компилируется с federation директивами (`autoSchemaFile.federation: 2`). Endpoint остаётся на /v1/graphql; non-federated клиенты (desktop) не должны заметить переключения — Apollo Federation добавляет лишь служебные `_service { sdl }` и `_entities` query. Gateway-режим (Apollo Gateway + IntrospectAndCompose) зайдёт отдельной story 10.1b — поверх этого subgraph'а + registry в Postgres. На MVP пока один subgraph, gateway не нужен. Зависимости: - + @apollo/subgraph ^2 (peer для federation 2 в @nestjs/apollo) @key директивы на core entity (User/Cooperative/Account/Wallet) добавятся по мере вытаскивания первых extension'ов — отдельными точечными PR. --- components/controller/package.json | 1 + .../infrastructure/graphql/graphql.module.ts | 20 ++++++-- pnpm-lock.yaml | 47 +++++++++++++++++-- 3 files changed, 59 insertions(+), 9 deletions(-) diff --git a/components/controller/package.json b/components/controller/package.json index 21fabb6f804..8374beae419 100644 --- a/components/controller/package.json +++ b/components/controller/package.json @@ -72,6 +72,7 @@ ], "dependencies": { "@a2seven/yoo-checkout": "^1.1.4", + "@apollo/subgraph": "^2", "@aws-sdk/client-s3": "^3.1035.0", "@coopenomics/factory": "workspace:*", "@coopenomics/inter": "workspace:*", diff --git a/components/controller/src/infrastructure/graphql/graphql.module.ts b/components/controller/src/infrastructure/graphql/graphql.module.ts index 1fc6ec5700f..a45669e4d14 100644 --- a/components/controller/src/infrastructure/graphql/graphql.module.ts +++ b/components/controller/src/infrastructure/graphql/graphql.module.ts @@ -1,8 +1,17 @@ // infrastructure/graphql/graphql.module.ts +// +// Epic 10 / Story 10.1 — coopback переключён с ApolloDriver на +// ApolloFederationDriver. Локальная core-схема становится первым subgraph'ом +// федерации; gateway-режим зайдёт отдельным процессом (10.1b / 10.3 orchestrator), +// когда появится первый внешний subgraph (chatcoop и т.д.). +// +// На MVP клиенты не должны заметить переключения: subgraph endpoint на том же +// `/v1/graphql`, federation v2 добавляет лишь служебные `_service { sdl }` и +// `_entities` query. Старые desktop-сессии продолжают работать. import { Global, Module } from '@nestjs/common'; import { GraphQLModule } from '@nestjs/graphql'; import config from '~/config/config'; -import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; +import { ApolloFederationDriver, ApolloFederationDriverConfig } from '@nestjs/apollo'; import { docDirectiveTransformer } from './directives/doc.directive'; import { GraphQLError, GraphQLFormattedError } from 'graphql'; import { fieldAuthDirectiveTransformer } from './directives/fieldAuth.directive'; @@ -11,10 +20,13 @@ import logger from '~/config/logger'; @Global() @Module({ imports: [ - GraphQLModule.forRoot({ - driver: ApolloDriver, + GraphQLModule.forRoot({ + driver: ApolloFederationDriver, introspection: true, - autoSchemaFile: 'schema.gql', + autoSchemaFile: { + path: 'schema.gql', + federation: 2, + }, sortSchema: true, debug: config.env !== 'production', // context: ({ req }) => req, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 26201ea9e31..a4325c444df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,6 +259,9 @@ importers: '@a2seven/yoo-checkout': specifier: ^1.1.4 version: 1.1.4 + '@apollo/subgraph': + specifier: ^2 + version: 2.14.1(graphql@16.13.2) '@aws-sdk/client-s3': specifier: ^3.1035.0 version: 3.1046.0 @@ -294,7 +297,7 @@ importers: version: 0.13.24 '@nestjs/apollo': specifier: ^12.2.1 - version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2) + version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2) '@nestjs/bull': specifier: ^10.2.1 version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bull@4.16.5) @@ -312,7 +315,7 @@ importers: version: 3.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22) '@nestjs/graphql': specifier: ^12.2.1 - version: 12.2.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14) + version: 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14) '@nestjs/jwt': specifier: ^11.0.0 version: 11.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2)) @@ -1624,6 +1627,12 @@ packages: subscriptions-transport-ws: optional: true + '@apollo/federation-internals@2.14.1': + resolution: {integrity: sha512-BVgwDr9H9mR/9LEzRzhBXJqo2cYdjkwSK/2uEgMa+B45oXvMI5r69y6qdFvMjRyP1R6y4wjnR9UtYGpx5uhhdw==} + engines: {node: '>=18'} + peerDependencies: + graphql: ^16.5.0 + '@apollo/protobufjs@1.2.6': resolution: {integrity: sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==} hasBin: true @@ -1652,6 +1661,12 @@ packages: peerDependencies: graphql: ^16.6.0 + '@apollo/subgraph@2.14.1': + resolution: {integrity: sha512-8ixeiaaZDGOsqf1/KhOTrQcBYZquS0PWZhoSiak+7JZPzrc5RT+f9Mnsc0NWgdbdhqQHAxFay2rz/9Ij0SjCMg==} + engines: {node: '>=14.15.0'} + peerDependencies: + graphql: ^16.5.0 + '@apollo/usage-reporting-protobuf@4.1.1': resolution: {integrity: sha512-u40dIUePHaSKVshcedO7Wp+mPiZsaU6xjv9J+VyxpoU/zL6Jle+9zWeG98tr/+SZ0nZ4OXhrbb8SNr0rAPpIDA==} @@ -7628,6 +7643,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/uuid@9.0.8': + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + '@types/validator@13.15.10': resolution: {integrity: sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==} @@ -19625,6 +19643,14 @@ snapshots: subscriptions-transport-ws: 0.11.0(graphql@16.13.2) optional: true + '@apollo/federation-internals@2.14.1(graphql@16.13.2)': + dependencies: + '@types/uuid': 9.0.8 + chalk: 4.1.2 + graphql: 16.13.2 + js-levenshtein: 1.1.6 + uuid: 9.0.1 + '@apollo/protobufjs@1.2.6': dependencies: '@protobufjs/aspromise': 1.1.2 @@ -19701,6 +19727,12 @@ snapshots: - encoding - supports-color + '@apollo/subgraph@2.14.1(graphql@16.13.2)': + dependencies: + '@apollo/cache-control-types': 1.0.3(graphql@16.13.2) + '@apollo/federation-internals': 2.14.1(graphql@16.13.2) + graphql: 16.13.2 + '@apollo/usage-reporting-protobuf@4.1.1': dependencies: '@apollo/protobufjs': 1.2.7 @@ -24229,17 +24261,19 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/apollo@12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2)': + '@nestjs/apollo@12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2)': dependencies: '@apollo/server': 4.13.0(encoding@0.1.13)(graphql@16.13.2) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2)) '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) - '@nestjs/graphql': 12.2.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14) + '@nestjs/graphql': 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14) graphql: 16.13.2 iterall: 1.3.0 lodash.omit: 4.5.0 tslib: 2.8.1 + optionalDependencies: + '@apollo/subgraph': 2.14.1(graphql@16.13.2) '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: @@ -24299,7 +24333,7 @@ snapshots: '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2) eventemitter2: 6.4.9 - '@nestjs/graphql@12.2.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14)': + '@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14)': dependencies: '@graphql-tools/merge': 9.0.11(graphql@16.13.2) '@graphql-tools/schema': 10.0.10(graphql@16.13.2) @@ -24320,6 +24354,7 @@ snapshots: uuid: 11.0.3 ws: 8.18.0 optionalDependencies: + '@apollo/subgraph': 2.14.1(graphql@16.13.2) class-transformer: 0.5.1 class-validator: 0.14.4 transitivePeerDependencies: @@ -27049,6 +27084,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/uuid@9.0.8': {} + '@types/validator@13.15.10': {} '@types/web-bluetooth@0.0.20': {} From 883e597d40dfe8692889ee1dbbef3d45c584be1d Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:33:24 +0000 Subject: [PATCH 11/17] =?UTF-8?q?[E10-2]=20feat(extension-sdk):=20@coopeno?= =?UTF-8?q?mics/extension-sdk=20=E2=80=94=20Nest-template=20subgraph'?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic 10 Story 10.2 — SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов экосистемы кооператива. Что внутри: - ExtensionFederationModule.forRoot() — готовый GraphQL модуль с ApolloFederationDriver + autoSchemaFile.federation: 2 - ExtensionAuthModule.forRoot({ secret }) — passport-jwt стратегия, валидирует токен который Apollo Gateway forward'ит в Authorization-header. - ExtensionJwtAuthGuard — guard для GraphQL resolver'ов - SharedCooperator/SharedCooperative/SharedAccount — federation v2 stub'ы на core-entity для cross-extension references (@key + @external) - HealthController GET /_health — обязательный для orchestrator'а - loadExtensionConfig() — fail-fast загрузчик env'ов Templates: - Dockerfile multi-stage (builder + runtime + wget healthcheck) - docker-compose.snippet.yaml — шаблон service'а для orchestrator'а Контракт фиксирован Epic 10 PRD (apps-catalog/docs/epics/E10-federation-runtime.md) и Architecture v3 (blago req 8b-architecture-v3-runtime-federation). --- components/extension-sdk/README.md | 89 ++++++++++ components/extension-sdk/build.config.ts | 10 ++ components/extension-sdk/package.json | 53 ++++++ .../src/auth/extension-auth.module.ts | 34 ++++ .../src/auth/extension-jwt.guard.ts | 19 ++ .../src/auth/extension-jwt.strategy.ts | 44 +++++ .../src/config/extension-config.ts | 49 ++++++ .../src/entities/shared-account.entity.ts | 16 ++ .../src/entities/shared-cooperative.entity.ts | 13 ++ .../src/entities/shared-cooperator.entity.ts | 26 +++ .../src/federation/federation.module.ts | 59 +++++++ .../src/health/health.controller.ts | 28 +++ components/extension-sdk/src/index.ts | 44 +++++ components/extension-sdk/templates/Dockerfile | 40 +++++ .../templates/docker-compose.snippet.yaml | 30 ++++ components/extension-sdk/tsconfig.json | 17 ++ pnpm-lock.yaml | 165 +++++++++++++++++- 17 files changed, 735 insertions(+), 1 deletion(-) create mode 100644 components/extension-sdk/README.md create mode 100644 components/extension-sdk/build.config.ts create mode 100644 components/extension-sdk/package.json create mode 100644 components/extension-sdk/src/auth/extension-auth.module.ts create mode 100644 components/extension-sdk/src/auth/extension-jwt.guard.ts create mode 100644 components/extension-sdk/src/auth/extension-jwt.strategy.ts create mode 100644 components/extension-sdk/src/config/extension-config.ts create mode 100644 components/extension-sdk/src/entities/shared-account.entity.ts create mode 100644 components/extension-sdk/src/entities/shared-cooperative.entity.ts create mode 100644 components/extension-sdk/src/entities/shared-cooperator.entity.ts create mode 100644 components/extension-sdk/src/federation/federation.module.ts create mode 100644 components/extension-sdk/src/health/health.controller.ts create mode 100644 components/extension-sdk/src/index.ts create mode 100644 components/extension-sdk/templates/Dockerfile create mode 100644 components/extension-sdk/templates/docker-compose.snippet.yaml create mode 100644 components/extension-sdk/tsconfig.json diff --git a/components/extension-sdk/README.md b/components/extension-sdk/README.md new file mode 100644 index 00000000000..fd57c25e9fd --- /dev/null +++ b/components/extension-sdk/README.md @@ -0,0 +1,89 @@ +# @coopenomics/extension-sdk + +SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов экосистемы цифрового кооператива. + +См. подробнее: `apps-catalog/docs/epics/E10-federation-runtime.md`. + +## Зачем + +Расширение в платформе цифрового кооператива — это пара артефактов: +- **frontend** (`install.js` bundle, попадает в desktop через remote-loader) +- **backend** (Nest-app, поднимается оркестратором как docker-контейнер и подключается к Apollo Gateway как subgraph) + +Этот пакет задаёт **контракт backend-части**: какие модули обязательны (federation driver + JWT guard + healthcheck), как ссылаться на core-entity (Cooperator/Cooperative/Account), как читать env'ы. + +## Минимальный пример + +```ts +// src/main.ts +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { loadExtensionConfig } from '@coopenomics/extension-sdk'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const cfg = loadExtensionConfig(); + const app = await NestFactory.create(AppModule); + await app.listen(cfg.subgraphPort); +} +bootstrap(); +``` + +```ts +// src/app.module.ts +import { Module } from '@nestjs/common'; +import { + ExtensionFederationModule, + ExtensionAuthModule, + HealthController, + loadExtensionConfig, +} from '@coopenomics/extension-sdk'; +import { ChatResolver } from './chat/chat.resolver'; + +const cfg = loadExtensionConfig(); + +@Module({ + imports: [ + ExtensionFederationModule.forRoot(), + ExtensionAuthModule.forRoot({ secret: cfg.jwtSecret }), + ], + controllers: [HealthController], + providers: [ChatResolver], +}) +export class AppModule {} +``` + +```ts +// src/chat/chat.resolver.ts +import { ResolveField, Resolver, Parent, Query, UseGuards } from '@nestjs/graphql'; +import { SharedCooperator, ExtensionJwtAuthGuard } from '@coopenomics/extension-sdk'; +import { ChatService } from './chat.service'; + +@Resolver(() => SharedCooperator) +export class ChatResolver { + constructor(private readonly chat: ChatService) {} + + @ResolveField(() => [String]) + @UseGuards(ExtensionJwtAuthGuard) + async chatThreads(@Parent() cooperator: SharedCooperator): Promise { + return this.chat.listThreads(cooperator.username); + } +} +``` + +После build'а: +- `docker build` через `templates/Dockerfile` +- `docker push registry.coopenomics.world//:` +- `npm publish` install.js bundle +- on-chain `apps::regpkg` через voskhod operator +- orchestrator подхватит после approve + +## ENV-переменные + +| Переменная | Описание | Обязательная | +|---|---|---| +| `SUBGRAPH_PORT` | Порт subgraph'а внутри контейнера | ✓ | +| `JWT_SECRET` | Тот же что у core'а (выдаёт core, проверяет subgraph) | ✓ | +| `COOPNAME` | Account name кооператива-tenant'а | ✓ | +| `DATABASE_URL` | Postgres connection string (если расширение пишет в БД) | | +| `CORE_GRAPHQL_URL` | Endpoint core subgraph'а (для cross-extension queries) | | diff --git a/components/extension-sdk/build.config.ts b/components/extension-sdk/build.config.ts new file mode 100644 index 00000000000..31d1d5a7078 --- /dev/null +++ b/components/extension-sdk/build.config.ts @@ -0,0 +1,10 @@ +import { defineBuildConfig } from 'unbuild'; + +export default defineBuildConfig({ + entries: ['src/index'], + declaration: true, + clean: true, + rollup: { + emitCJS: true, + }, +}); diff --git a/components/extension-sdk/package.json b/components/extension-sdk/package.json new file mode 100644 index 00000000000..7cc0a8e6fb5 --- /dev/null +++ b/components/extension-sdk/package.json @@ -0,0 +1,53 @@ +{ + "name": "@coopenomics/extension-sdk", + "version": "0.1.0", + "description": "SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов экосистемы кооперативов", + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "templates", + "README.md" + ], + "scripts": { + "build": "unbuild", + "dev": "unbuild --stub", + "clean": "rm -rf dist" + }, + "peerDependencies": { + "@apollo/subgraph": "^2.0.0", + "@nestjs/apollo": "^12.0.0", + "@nestjs/common": "^10.0.0", + "@nestjs/core": "^10.0.0", + "@nestjs/graphql": "^12.0.0", + "@nestjs/passport": "^10.0.0", + "graphql": "^16.0.0", + "passport-jwt": "^4.0.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.0.0" + }, + "devDependencies": { + "@apollo/subgraph": "^2.9.0", + "@nestjs/apollo": "^12.2.1", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/graphql": "^12.2.1", + "@nestjs/passport": "^10.0.3", + "@types/passport-jwt": "^4", + "graphql": "^16.9.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typescript": "^5.4.0", + "unbuild": "^2.0.0" + } +} diff --git a/components/extension-sdk/src/auth/extension-auth.module.ts b/components/extension-sdk/src/auth/extension-auth.module.ts new file mode 100644 index 00000000000..0c8919ba219 --- /dev/null +++ b/components/extension-sdk/src/auth/extension-auth.module.ts @@ -0,0 +1,34 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { PassportModule } from '@nestjs/passport'; +import { ExtensionJwtStrategy } from './extension-jwt.strategy'; +import { ExtensionJwtAuthGuard } from './extension-jwt.guard'; + +export interface ExtensionAuthOptions { + secret: string; +} + +/** + * Auth-модуль расширения. Подключается в корневой AppModule: + * + * imports: [ExtensionAuthModule.forRoot({ secret: env.JWT_SECRET })] + * + * Регистрирует passport-jwt стратегию + guard. Импортируйте `ExtensionJwtAuthGuard` + * в resolver'ах через `@UseGuards`. + */ +@Module({}) +export class ExtensionAuthModule { + static forRoot(options: ExtensionAuthOptions): DynamicModule { + return { + module: ExtensionAuthModule, + imports: [PassportModule.register({ defaultStrategy: 'jwt' })], + providers: [ + { + provide: ExtensionJwtStrategy, + useFactory: () => new ExtensionJwtStrategy({ secret: options.secret }), + }, + ExtensionJwtAuthGuard, + ], + exports: [PassportModule, ExtensionJwtStrategy, ExtensionJwtAuthGuard], + }; + } +} diff --git a/components/extension-sdk/src/auth/extension-jwt.guard.ts b/components/extension-sdk/src/auth/extension-jwt.guard.ts new file mode 100644 index 00000000000..565346f795f --- /dev/null +++ b/components/extension-sdk/src/auth/extension-jwt.guard.ts @@ -0,0 +1,19 @@ +import { Injectable, ExecutionContext } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { GqlExecutionContext } from '@nestjs/graphql'; + +/** + * Guard для GraphQL resolver'ов расширения. Достаёт Bearer-токен из + * Authorization-header'а, проверяет подпись через ExtensionJwtStrategy. + * + * Используется через `@UseGuards(ExtensionJwtAuthGuard)` на resolver'е / + * на отдельном поле. Для HTTP-controller'а (например `/_health`) — НЕ нужен; + * healthcheck оставляем публичным. + */ +@Injectable() +export class ExtensionJwtAuthGuard extends AuthGuard('jwt') { + getRequest(context: ExecutionContext) { + const gqlCtx = GqlExecutionContext.create(context); + return gqlCtx.getContext().req; + } +} diff --git a/components/extension-sdk/src/auth/extension-jwt.strategy.ts b/components/extension-sdk/src/auth/extension-jwt.strategy.ts new file mode 100644 index 00000000000..fc4489147f8 --- /dev/null +++ b/components/extension-sdk/src/auth/extension-jwt.strategy.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, ExtractJwt } from 'passport-jwt'; + +export interface ExtensionJwtPayload { + sub: string; + username: string; + coopname?: string; + type?: string; + scope?: string[]; + iat?: number; + exp?: number; +} + +export interface ExtensionJwtStrategyOptions { + secret: string; +} + +/** + * JWT-стратегия для расширения. Validate'ит токен, который Apollo Gateway + * forward'ит в `Authorization: Bearer …` заголовке. Не выдаёт токены — это + * делает core-coopback; расширение только проверяет подпись и срок. + * + * **Контракт payload'а** — фиксирован core'ом: `sub`, `username`, `coopname`, + * `type`. Поле `scope: string[]` опционально и используется для apps-catalog + * подписок (`@scope("@voskhod/chatcoop")`). + * + * Поле `secret` приходит из env (`JWT_SECRET`) — обязательно совпадает с + * core'овым. Никакой выдачи токенов на расширении. + */ +@Injectable() +export class ExtensionJwtStrategy extends PassportStrategy(Strategy, 'jwt') { + constructor(options: ExtensionJwtStrategyOptions) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: options.secret, + }); + } + + async validate(payload: ExtensionJwtPayload): Promise { + return payload; + } +} diff --git a/components/extension-sdk/src/config/extension-config.ts b/components/extension-sdk/src/config/extension-config.ts new file mode 100644 index 00000000000..e07fd0dc808 --- /dev/null +++ b/components/extension-sdk/src/config/extension-config.ts @@ -0,0 +1,49 @@ +/** + * Стандартный набор env-переменных расширения. + * + * SUBGRAPH_PORT — порт, на котором subgraph слушает (например 3001) + * JWT_SECRET — общий секрет с core'ом для валидации JWT + * COOPNAME — account name кооператива-tenant'а + * DATABASE_URL — connection string Postgres tenant'а (опц.) + * CORE_GRAPHQL_URL — endpoint core subgraph'а для inter-extension fetch (опц.) + * + * Расширение читает их через `loadExtensionConfig()` на bootstrap; падает + * сразу же если обязательное поле не задано — fail-fast, чтобы orchestrator + * увидел unhealthy и не зарегистрировал subgraph. + */ +export interface ExtensionConfig { + subgraphPort: number; + jwtSecret: string; + coopname: string; + databaseUrl?: string; + coreGraphqlUrl?: string; +} + +export class ExtensionConfigError extends Error { + constructor(message: string) { + super(`[extension-sdk] ${message}`); + this.name = 'ExtensionConfigError'; + } +} + +export function loadExtensionConfig(env: NodeJS.ProcessEnv = process.env): ExtensionConfig { + const port = Number(env.SUBGRAPH_PORT); + if (!Number.isFinite(port) || port <= 0) { + throw new ExtensionConfigError('SUBGRAPH_PORT не задан или некорректен'); + } + const jwtSecret = env.JWT_SECRET; + if (!jwtSecret) { + throw new ExtensionConfigError('JWT_SECRET не задан'); + } + const coopname = env.COOPNAME; + if (!coopname) { + throw new ExtensionConfigError('COOPNAME не задан'); + } + return { + subgraphPort: port, + jwtSecret, + coopname, + databaseUrl: env.DATABASE_URL, + coreGraphqlUrl: env.CORE_GRAPHQL_URL, + }; +} diff --git a/components/extension-sdk/src/entities/shared-account.entity.ts b/components/extension-sdk/src/entities/shared-account.entity.ts new file mode 100644 index 00000000000..80f5185d81d --- /dev/null +++ b/components/extension-sdk/src/entities/shared-account.entity.ts @@ -0,0 +1,16 @@ +import { Directive, Field, ObjectType } from '@nestjs/graphql'; + +/** + * Federation v2 ссылка на core-entity `Account` (общий аккаунт — пайщик + * или кооператив; различие — флагом is_cooperative на стороне core). + * Расширения обычно работают через `Cooperator` или `Cooperative`, но + * иногда нужен общий `Account` (например marketplace может ссылаться на + * поставщика, который может быть как пайщиком, так и кооперативом). + */ +@ObjectType('Account') +@Directive('@key(fields: "username")') +export class SharedAccount { + @Field(() => String, { description: 'Account name (EOSIO username)' }) + @Directive('@external') + username!: string; +} diff --git a/components/extension-sdk/src/entities/shared-cooperative.entity.ts b/components/extension-sdk/src/entities/shared-cooperative.entity.ts new file mode 100644 index 00000000000..c3f7aa2e0a5 --- /dev/null +++ b/components/extension-sdk/src/entities/shared-cooperative.entity.ts @@ -0,0 +1,13 @@ +import { Directive, Field, ObjectType } from '@nestjs/graphql'; + +/** + * Federation v2 ссылка на core-entity `Cooperative` (кооператив / DC). + * `username` — EOSIO account name кооператива (например `voskhod`, `partner1`). + */ +@ObjectType('Cooperative') +@Directive('@key(fields: "username")') +export class SharedCooperative { + @Field(() => String, { description: 'Account name кооператива' }) + @Directive('@external') + username!: string; +} diff --git a/components/extension-sdk/src/entities/shared-cooperator.entity.ts b/components/extension-sdk/src/entities/shared-cooperator.entity.ts new file mode 100644 index 00000000000..3d1525e4882 --- /dev/null +++ b/components/extension-sdk/src/entities/shared-cooperator.entity.ts @@ -0,0 +1,26 @@ +import { Directive, Field, ObjectType } from '@nestjs/graphql'; + +/** + * Federation v2 ссылка на core-entity `Cooperator` (пайщик). + * + * Расширения, которым нужно расширить пайщика своими полями, **не должны** + * описывать `Cooperator` целиком; они импортируют этот класс и добавляют + * к нему свои поля через extend pattern на стороне core. Здесь — только + * stub с `@key(fields: "username")` и `@external` на ключевом поле. + * + * @ObjectType('Cooperator') + * @Directive('@extends') + * export class CooperatorWithChat extends SharedCooperator { + * @Field(() => [ChatThread]) chatThreads!: ChatThread[]; + * } + * + * Контракт идентичности: `username` — EOSIO account name пайщика (≤13 символов, + * a-z, 1-5, .). Этот же ключ используется во всех core-таблицах. + */ +@ObjectType('Cooperator') +@Directive('@key(fields: "username")') +export class SharedCooperator { + @Field(() => String, { description: 'Account name пайщика (EOSIO username)' }) + @Directive('@external') + username!: string; +} diff --git a/components/extension-sdk/src/federation/federation.module.ts b/components/extension-sdk/src/federation/federation.module.ts new file mode 100644 index 00000000000..9fcc797fffc --- /dev/null +++ b/components/extension-sdk/src/federation/federation.module.ts @@ -0,0 +1,59 @@ +import { DynamicModule, Module } from '@nestjs/common'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloFederationDriver, ApolloFederationDriverConfig } from '@nestjs/apollo'; + +/** + * Опции для базового федеративного subgraph'а расширения. + * + * @property path — путь endpoint'а GraphQL (по умолчанию `/v1/graphql`). + * @property schemaFile — куда писать сгенерированную SDL (`schema.gql` по умолчанию). + * @property introspection — оставлять true даже в проде: gateway пользуется + * introspection'ом для пересборки supergraph'а. + * @property playground — Apollo Sandbox в development. + */ +export interface ExtensionFederationOptions { + path?: string; + schemaFile?: string; + introspection?: boolean; + playground?: boolean; +} + +/** + * Стандартный модуль федеративного subgraph'а расширения. + * + * Подключается в `imports` корневого `AppModule` расширения: + * + * imports: [ExtensionFederationModule.forRoot({ path: '/v1/graphql' })] + * + * Драйвер `ApolloFederationDriver` + `autoSchemaFile.federation: 2` дают + * subgraph совместимый с Apollo Gateway. На стороне расширения никаких + * настроек федерации больше делать не надо — `@key/@requires/@external` + * объявляются прямо в @ObjectType классах через @Directive. + */ +@Module({}) +export class ExtensionFederationModule { + static forRoot(options: ExtensionFederationOptions = {}): DynamicModule { + const path = options.path ?? '/v1/graphql'; + const schemaFile = options.schemaFile ?? 'schema.gql'; + const introspection = options.introspection ?? true; + const playground = options.playground ?? false; + + return { + module: ExtensionFederationModule, + imports: [ + GraphQLModule.forRoot({ + driver: ApolloFederationDriver, + path, + introspection, + playground, + autoSchemaFile: { + path: schemaFile, + federation: 2, + }, + sortSchema: true, + }), + ], + exports: [GraphQLModule], + }; + } +} diff --git a/components/extension-sdk/src/health/health.controller.ts b/components/extension-sdk/src/health/health.controller.ts new file mode 100644 index 00000000000..cf34987dc70 --- /dev/null +++ b/components/extension-sdk/src/health/health.controller.ts @@ -0,0 +1,28 @@ +import { Controller, Get } from '@nestjs/common'; + +/** + * Стандартный healthcheck endpoint расширения. + * + * Orchestrator опрашивает этот endpoint после `docker compose up -d`; + * только после `200 OK` пишет subgraph URL в registry и триггерит + * gateway recompose. + * + * Использование: + * + * @Module({ controllers: [HealthController], ... }) + * + * Endpoint: `GET /_health` → `{ status: 'ok', version, uptime }`. + */ +@Controller('_health') +export class HealthController { + private readonly startedAt = Date.now(); + + @Get() + health(): { status: 'ok'; version: string; uptimeSeconds: number } { + return { + status: 'ok', + version: process.env.npm_package_version ?? 'unknown', + uptimeSeconds: Math.floor((Date.now() - this.startedAt) / 1000), + }; + } +} diff --git a/components/extension-sdk/src/index.ts b/components/extension-sdk/src/index.ts new file mode 100644 index 00000000000..198461fcfda --- /dev/null +++ b/components/extension-sdk/src/index.ts @@ -0,0 +1,44 @@ +/** + * @coopenomics/extension-sdk + * + * SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов + * экосистемы цифрового кооператива. + * + * Контракт: + * - расширение = Nest-приложение, использующее `ExtensionFederationModule` + * и `ExtensionAuthModule` в корневом AppModule. + * - schema собирается через autoSchemaFile { federation: 2 }. + * - cross-extension references на core entity (Cooperator, Cooperative, + * Account) — через `SharedCooperator/SharedCooperative/SharedAccount` + * stub'ы (`@key + @external`). + * - JWT валидируется тем же секретом что и core; gateway forward'ит + * Authorization-header в subgraph. + * - healthcheck `GET /_health` обязателен — orchestrator опрашивает. + * + * См. подробнее: docs/epics/E10-federation-runtime.md в репо apps-catalog + * и Architecture v3 в blago. + */ + +export { + ExtensionFederationModule, + type ExtensionFederationOptions, +} from './federation/federation.module'; + +export { + ExtensionAuthModule, + type ExtensionAuthOptions, +} from './auth/extension-auth.module'; +export { ExtensionJwtStrategy, type ExtensionJwtPayload } from './auth/extension-jwt.strategy'; +export { ExtensionJwtAuthGuard } from './auth/extension-jwt.guard'; + +export { SharedCooperator } from './entities/shared-cooperator.entity'; +export { SharedCooperative } from './entities/shared-cooperative.entity'; +export { SharedAccount } from './entities/shared-account.entity'; + +export { HealthController } from './health/health.controller'; + +export { + loadExtensionConfig, + ExtensionConfigError, + type ExtensionConfig, +} from './config/extension-config'; diff --git a/components/extension-sdk/templates/Dockerfile b/components/extension-sdk/templates/Dockerfile new file mode 100644 index 00000000000..be3b12304b9 --- /dev/null +++ b/components/extension-sdk/templates/Dockerfile @@ -0,0 +1,40 @@ +# Multi-stage Dockerfile template для backend-расширения. +# +# Расширение должно скопировать этот файл к себе в репозиторий и кастомизировать +# при необходимости (например, добавить native deps). Базовый pattern: +# 1. install deps (cached layer) +# 2. build TypeScript → dist +# 3. runtime образ — только prod-deps + dist + healthcheck wget + +# ───── builder ────── +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile + +COPY . . +RUN pnpm run build + +# ───── runtime ────── +FROM node:20-alpine AS runtime + +WORKDIR /app + +RUN corepack enable && corepack prepare pnpm@9.15.9 --activate + +COPY package.json pnpm-lock.yaml ./ +RUN pnpm install --frozen-lockfile --prod && pnpm store prune + +COPY --from=builder /app/dist ./dist + +EXPOSE 3001 + +# Orchestrator опрашивает GET /_health после up -d +HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ + CMD wget --quiet --tries=1 --spider http://localhost:3001/_health || exit 1 + +CMD ["node", "dist/main.js"] diff --git a/components/extension-sdk/templates/docker-compose.snippet.yaml b/components/extension-sdk/templates/docker-compose.snippet.yaml new file mode 100644 index 00000000000..093cf51c654 --- /dev/null +++ b/components/extension-sdk/templates/docker-compose.snippet.yaml @@ -0,0 +1,30 @@ +# Snippet, который orchestrator добавляет в общий docker-compose.yaml DC. +# +# Имя service'а = `ext-` (например `ext-chatcoop`). +# Image берётся из manifest пакета (`coopenomics.backend.image`). +# Порт subgraph'а внутри docker-network проброшен НЕ на хост — gateway +# обращается к нему через service name + internal port. + +services: + ext-${EXT_NAME}: + image: ${EXT_IMAGE} + container_name: ext-${EXT_NAME} + restart: unless-stopped + environment: + SUBGRAPH_PORT: ${EXT_PORT} + JWT_SECRET: ${JWT_SECRET} + COOPNAME: ${COOPNAME} + DATABASE_URL: ${EXT_DATABASE_URL} + CORE_GRAPHQL_URL: http://coopback:3000/v1/graphql + networks: + - coopnet + healthcheck: + test: ['CMD', 'wget', '--quiet', '--tries=1', '--spider', 'http://localhost:${EXT_PORT}/_health'] + interval: 10s + timeout: 3s + retries: 3 + start_period: 20s + +networks: + coopnet: + external: true diff --git a/components/extension-sdk/tsconfig.json b/components/extension-sdk/tsconfig.json new file mode 100644 index 00000000000..74a0bf8e25b --- /dev/null +++ b/components/extension-sdk/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a4325c444df..4081a94f0fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1067,6 +1067,48 @@ importers: specifier: ^0.34.5 version: 0.34.5 + components/extension-sdk: + devDependencies: + '@apollo/subgraph': + specifier: ^2.9.0 + version: 2.14.1(graphql@16.13.2) + '@nestjs/apollo': + specifier: ^12.2.1 + version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.1 + version: 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@types/passport-jwt': + specifier: ^4 + version: 4.0.1 + graphql: + specifier: ^16.9.0 + version: 16.13.2 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typescript: + specifier: ^5.4.0 + version: 5.9.3 + unbuild: + specifier: ^2.0.0 + version: 2.0.0(sass@1.98.0)(typescript@5.9.3)(vue-tsc@2.2.12(typescript@5.9.3)) + components/factory: dependencies: ajv: @@ -7565,6 +7607,15 @@ packages: '@types/pako@2.0.4': resolution: {integrity: sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/pg-pool@2.0.7': resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} @@ -22719,7 +22770,7 @@ snapshots: '@graphql-tools/schema@10.0.10(graphql@16.13.2)': dependencies: - '@graphql-tools/merge': 9.0.11(graphql@16.13.2) + '@graphql-tools/merge': 9.1.7(graphql@16.13.2) '@graphql-tools/utils': 10.11.0(graphql@16.13.2) graphql: 16.13.2 tslib: 2.8.1 @@ -24275,6 +24326,20 @@ snapshots: optionalDependencies: '@apollo/subgraph': 2.14.1(graphql@16.13.2) + '@nestjs/apollo@12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2)': + dependencies: + '@apollo/server': 4.13.0(encoding@0.1.13)(graphql@16.13.2) + '@apollo/server-plugin-landing-page-graphql-playground': 4.0.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2)) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2) + graphql: 16.13.2 + iterall: 1.3.0 + lodash.omit: 4.5.0 + tslib: 2.8.1 + optionalDependencies: + '@apollo/subgraph': 2.14.1(graphql@16.13.2) + '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24303,6 +24368,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + file-type: 20.4.1 + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - supports-color + '@nestjs/config@3.3.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24327,6 +24406,22 @@ snapshots: transitivePeerDependencies: - encoding + '@nestjs/core@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nuxtjs/opencollective': 0.3.2(encoding@0.1.13) + fast-safe-stringify: 2.1.1 + iterare: 1.2.1 + path-to-regexp: 3.3.0 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + uid: 2.0.2 + optionalDependencies: + '@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + transitivePeerDependencies: + - encoding + '@nestjs/event-emitter@3.0.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24361,6 +24456,34 @@ snapshots: - bufferutil - utf-8-validate + '@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2)': + dependencies: + '@graphql-tools/merge': 9.0.11(graphql@16.13.2) + '@graphql-tools/schema': 10.0.10(graphql@16.13.2) + '@graphql-tools/utils': 10.6.1(graphql@16.13.2) + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.0.6(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + chokidar: 4.0.1 + fast-glob: 3.3.2 + graphql: 16.13.2 + graphql-tag: 2.12.6(graphql@16.13.2) + graphql-ws: 5.16.0(graphql@16.13.2) + lodash: 4.17.21 + normalize-path: 3.0.0 + reflect-metadata: 0.2.2 + subscriptions-transport-ws: 0.11.0(graphql@16.13.2) + tslib: 2.8.1 + uuid: 11.0.3 + ws: 8.18.0 + optionalDependencies: + '@apollo/subgraph': 2.14.1(graphql@16.13.2) + class-transformer: 0.5.1 + class-validator: 0.14.4 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + '@nestjs/jwt@11.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24375,6 +24498,14 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@nestjs/mapped-types@2.0.6(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + optionalDependencies: + class-transformer: 0.5.1 + class-validator: 0.14.4 + '@nestjs/mongoose@10.1.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(mongoose@9.3.3(socks@2.8.7))(rxjs@7.8.2)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24387,6 +24518,11 @@ snapshots: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) passport: 0.7.0 + '@nestjs/passport@10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + passport: 0.7.0 + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -24399,6 +24535,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@nestjs/platform-express@10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + body-parser: 1.20.4 + cors: 2.8.5 + express: 4.22.1 + multer: 2.0.2 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + optional: true + '@nestjs/schedule@6.1.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2) @@ -26991,6 +27140,20 @@ snapshots: '@types/pako@2.0.4': {} + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 4.17.25 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 4.17.25 + '@types/pg-pool@2.0.7': dependencies: '@types/pg': 8.20.0 From 68b91566ad7d1bcc7d1ee76fc0894c2355609231 Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:38:37 +0000 Subject: [PATCH 12/17] =?UTF-8?q?[E10-2b]=20feat(extension-hello-subgraph)?= =?UTF-8?q?:=20PoC=20smoke-=D1=82=D0=B5=D1=81=D1=82=20@coopenomics/extensi?= =?UTF-8?q?on-sdk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic 10 Story 10.2b — минимальный subgraph на extension-sdk доказывает что Federation v2 driver + JWT module + healthcheck реально работают вместе. Smoke результат (SUBGRAPH_PORT=33001 JWT_SECRET=test-secret COOPNAME=voskhod): - GET /_health → {"status":"ok","version":"unknown","uptimeSeconds":N} - POST /v1/graphql { helloFromExtension(name:"мир") { greeting fromExtension } } → {"greeting":"Здравствуй, мир!","fromExtension":"hello-subgraph@0.1.0"} - POST /v1/graphql { _service { sdl } } → returns federation v2 SDL с @key/@external/@requires/@shareable директивами Также в этом коммите: - extension-sdk переключён с unbuild на обычный tsc (unbuild esbuild не поддерживает декораторы для target ES2020). main: dist/index.js, CommonJS. - @types/passport-jwt в devDeps. Cross-extension entity references (SharedCooperator @key + ResolveField) живут в chatcoop subgraph'е (story 10.8) — там есть локальная сущность ChatThread которая ссылается на пайщика. Этот PoC только доказывает базовую механику. --- .../extension-hello-subgraph/package.json | 32 +++++ .../src/app.module.ts | 20 ++++ .../src/hello.resolver.ts | 28 +++++ .../extension-hello-subgraph/src/main.ts | 18 +++ .../extension-hello-subgraph/tsconfig.json | 17 +++ components/extension-sdk/build.config.ts | 10 -- components/extension-sdk/package.json | 14 +-- components/extension-sdk/tsconfig.json | 4 +- pnpm-lock.yaml | 109 +++++++++++++++++- 9 files changed, 227 insertions(+), 25 deletions(-) create mode 100644 components/extension-hello-subgraph/package.json create mode 100644 components/extension-hello-subgraph/src/app.module.ts create mode 100644 components/extension-hello-subgraph/src/hello.resolver.ts create mode 100644 components/extension-hello-subgraph/src/main.ts create mode 100644 components/extension-hello-subgraph/tsconfig.json delete mode 100644 components/extension-sdk/build.config.ts diff --git a/components/extension-hello-subgraph/package.json b/components/extension-hello-subgraph/package.json new file mode 100644 index 00000000000..e50cb302847 --- /dev/null +++ b/components/extension-hello-subgraph/package.json @@ -0,0 +1,32 @@ +{ + "name": "@coopenomics/extension-hello-subgraph", + "version": "0.1.0", + "description": "PoC subgraph для smoke-теста @coopenomics/extension-sdk (Epic 10 Story 10.2b). НЕ для прода.", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "dev": "ts-node-dev --respawn src/main.ts" + }, + "dependencies": { + "@apollo/subgraph": "^2.9.0", + "@coopenomics/extension-sdk": "workspace:*", + "@nestjs/apollo": "^12.2.1", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/graphql": "^12.2.1", + "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.4.0", + "graphql": "^16.9.0", + "passport-jwt": "^4.0.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/passport-jwt": "^4.0.1", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0" + } +} diff --git a/components/extension-hello-subgraph/src/app.module.ts b/components/extension-hello-subgraph/src/app.module.ts new file mode 100644 index 00000000000..d19ee86e8d2 --- /dev/null +++ b/components/extension-hello-subgraph/src/app.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common'; +import { + ExtensionAuthModule, + ExtensionFederationModule, + HealthController, + loadExtensionConfig, +} from '@coopenomics/extension-sdk'; +import { HelloResolver } from './hello.resolver'; + +const cfg = loadExtensionConfig(); + +@Module({ + imports: [ + ExtensionFederationModule.forRoot(), + ExtensionAuthModule.forRoot({ secret: cfg.jwtSecret }), + ], + controllers: [HealthController], + providers: [HelloResolver], +}) +export class AppModule {} diff --git a/components/extension-hello-subgraph/src/hello.resolver.ts b/components/extension-hello-subgraph/src/hello.resolver.ts new file mode 100644 index 00000000000..ad289a5a7e5 --- /dev/null +++ b/components/extension-hello-subgraph/src/hello.resolver.ts @@ -0,0 +1,28 @@ +import { Args, Field, ObjectType, Query, Resolver } from '@nestjs/graphql'; + +@ObjectType('HelloResponse') +class HelloResponse { + @Field(() => String) + greeting!: string; + + @Field(() => String) + fromExtension!: string; +} + +/** + * Минимальный subgraph resolver — доказывает что Federation v2 driver + * поднимается на @coopenomics/extension-sdk. Cross-extension entity + * references (SharedCooperator @key + @external + ResolveField) живут + * в реальном chatcoop subgraph'е (story 10.8) — там есть локальная + * сущность ChatThread которая может ссылаться на пайщика. + */ +@Resolver() +export class HelloResolver { + @Query(() => HelloResponse, { name: 'helloFromExtension' }) + hello(@Args('name') name: string): HelloResponse { + return { + greeting: `Здравствуй, ${name}!`, + fromExtension: 'hello-subgraph@0.1.0', + }; + } +} diff --git a/components/extension-hello-subgraph/src/main.ts b/components/extension-hello-subgraph/src/main.ts new file mode 100644 index 00000000000..686e16dbb79 --- /dev/null +++ b/components/extension-hello-subgraph/src/main.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { loadExtensionConfig } from '@coopenomics/extension-sdk'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const cfg = loadExtensionConfig(); + const app = await NestFactory.create(AppModule); + await app.listen(cfg.subgraphPort); + // eslint-disable-next-line no-console + console.log(`[hello-subgraph] up at :${cfg.subgraphPort} (coopname=${cfg.coopname})`); +} + +bootstrap().catch((err) => { + // eslint-disable-next-line no-console + console.error('[hello-subgraph] failed to bootstrap:', err); + process.exit(1); +}); diff --git a/components/extension-hello-subgraph/tsconfig.json b/components/extension-hello-subgraph/tsconfig.json new file mode 100644 index 00000000000..1b20d563127 --- /dev/null +++ b/components/extension-hello-subgraph/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/components/extension-sdk/build.config.ts b/components/extension-sdk/build.config.ts deleted file mode 100644 index 31d1d5a7078..00000000000 --- a/components/extension-sdk/build.config.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { defineBuildConfig } from 'unbuild'; - -export default defineBuildConfig({ - entries: ['src/index'], - declaration: true, - clean: true, - rollup: { - emitCJS: true, - }, -}); diff --git a/components/extension-sdk/package.json b/components/extension-sdk/package.json index 7cc0a8e6fb5..8220aba97ae 100644 --- a/components/extension-sdk/package.json +++ b/components/extension-sdk/package.json @@ -2,25 +2,15 @@ "name": "@coopenomics/extension-sdk", "version": "0.1.0", "description": "SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов экосистемы кооперативов", - "type": "module", - "main": "./dist/index.cjs", - "module": "./dist/index.mjs", + "main": "./dist/index.js", "types": "./dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "import": "./dist/index.mjs", - "require": "./dist/index.cjs" - } - }, "files": [ "dist", "templates", "README.md" ], "scripts": { - "build": "unbuild", - "dev": "unbuild --stub", + "build": "tsc -p tsconfig.json", "clean": "rm -rf dist" }, "peerDependencies": { diff --git a/components/extension-sdk/tsconfig.json b/components/extension-sdk/tsconfig.json index 74a0bf8e25b..6110c6e64dc 100644 --- a/components/extension-sdk/tsconfig.json +++ b/components/extension-sdk/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", + "module": "CommonJS", + "moduleResolution": "Node", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4081a94f0fa..e314995798f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1067,6 +1067,58 @@ importers: specifier: ^0.34.5 version: 0.34.5 + components/extension-hello-subgraph: + dependencies: + '@apollo/subgraph': + specifier: ^2.9.0 + version: 2.14.1(graphql@16.13.2) + '@coopenomics/extension-sdk': + specifier: workspace:* + version: link:../extension-sdk + '@nestjs/apollo': + specifier: ^12.2.1 + version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.1 + version: 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2) + '@nestjs/passport': + specifier: ^10.0.3 + version: 10.0.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/platform-express': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + graphql: + specifier: ^16.9.0 + version: 16.13.2 + passport-jwt: + specifier: ^4.0.1 + version: 4.0.1 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.37 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + components/extension-sdk: devDependencies: '@apollo/subgraph': @@ -7667,6 +7719,12 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/strip-bom@3.0.0': + resolution: {integrity: sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==} + + '@types/strip-json-comments@0.0.30': + resolution: {integrity: sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==} + '@types/stylis@4.2.7': resolution: {integrity: sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==} @@ -10588,6 +10646,9 @@ packages: duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} + dynamic-dedupe@0.3.0: + resolution: {integrity: sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==} + earcut@2.2.4: resolution: {integrity: sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==} @@ -17919,6 +17980,17 @@ packages: ts-map@1.0.3: resolution: {integrity: sha512-vDWbsl26LIcPGmDpoVzjEP6+hvHZkBkLW7JpvwbCv/5IYPJlsbzCVXY3wsCeAxAUeTclNOUZxnLdGh3VBD/J6w==} + ts-node-dev@2.0.0: + resolution: {integrity: sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==} + engines: {node: '>=0.8.0'} + hasBin: true + peerDependencies: + node-notifier: '*' + typescript: '*' + peerDependenciesMeta: + node-notifier: + optional: true + ts-node@10.9.2: resolution: {integrity: sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==} hasBin: true @@ -17944,6 +18016,9 @@ packages: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} + tsconfig@7.0.0: + resolution: {integrity: sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==} + tsd@0.31.2: resolution: {integrity: sha512-VplBAQwvYrHzVihtzXiUVXu5bGcr7uH1juQZ1lmKgkuGNGT+FechUCqmx9/zk7wibcqR2xaNEwCkDyKh+VVZnQ==} engines: {node: '>=14.16'} @@ -24546,7 +24621,6 @@ snapshots: tslib: 2.8.1 transitivePeerDependencies: - supports-color - optional: true '@nestjs/schedule@6.1.1(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': dependencies: @@ -27217,6 +27291,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/strip-bom@3.0.0': {} + + '@types/strip-json-comments@0.0.30': {} + '@types/stylis@4.2.7': {} '@types/superagent@8.1.9': @@ -30882,6 +30960,10 @@ snapshots: duplexer@0.1.2: {} + dynamic-dedupe@0.3.0: + dependencies: + xtend: 4.0.2 + earcut@2.2.4: {} eastasianwidth@0.2.0: {} @@ -40553,6 +40635,24 @@ snapshots: ts-map@1.0.3: {} + ts-node-dev@2.0.0(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3): + dependencies: + chokidar: 3.6.0 + dynamic-dedupe: 0.3.0 + minimist: 1.2.8 + mkdirp: 1.0.4 + resolve: 1.22.11 + rimraf: 2.7.1 + source-map-support: 0.5.21 + tree-kill: 1.2.2 + ts-node: 10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3) + tsconfig: 7.0.0 + typescript: 5.9.3 + transitivePeerDependencies: + - '@swc/core' + - '@swc/wasm' + - '@types/node' + ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 @@ -40595,6 +40695,13 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tsconfig@7.0.0: + dependencies: + '@types/strip-bom': 3.0.0 + '@types/strip-json-comments': 0.0.30 + strip-bom: 3.0.0 + strip-json-comments: 2.0.1 + tsd@0.31.2: dependencies: '@tsd/typescript': 5.4.5 From 8ea8bceea91c7d7405a2a5529b3931b166fc66fd Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:44:08 +0000 Subject: [PATCH 13/17] [E10-3] feat(gateway-orchestrator): Apollo Gateway + subgraph registry skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Epic 10 Story 10.3 — отдельный per-tenant Nest-сервис, объединяющий: 1. Apollo Federation Gateway (ApolloGatewayDriver) на /v1/graphql. 2. REST orchestrator API на /v1/internal/{extensions,composition}/*. 3. Subgraph registry в Postgres (TypeORM entity SubgraphRegistryEntity). Что работает: - Bootstrap seed'ит запись core-subgraph'а (coopback) → IntrospectAndCompose стартует с одним subgraph'ом → gateway проксирует все queries в core. - JWT forwarding: Authorization-header forward'ится в каждый subgraph через RemoteGraphQLDataSource.willSendRequest. - POST /v1/internal/extensions/install { packageId, version, url } — upsert в registry. - DELETE /v1/internal/extensions/uninstall/:packageId — deactivate. - POST /v1/internal/composition/refresh — 202 Accepted (реальный recompose по polling'у IntrospectAndCompose, ≤10 сек). - GET /v1/internal/composition/status — текущие subgraph'ы. Trade-off MVP: - subgraphs читаются ОДИН раз на bootstrap (Apollo 2.9 IntrospectAndCompose ожидает статичный массив). Добавление НОВОГО subgraph'а → требует рестарта контейнера gateway-orchestrator. Warmup ~5 сек, делается orchestrator-pipeline при install'е (Story 10.4). - Реальный docker pull/up/healthcheck — Story 10.4 (Install pipeline). - Динамическая supergraph composition без рестарта — Story 10.3b (custom SupergraphManager). - On-chain auto-update watcher — Story 10.5. ENV: GATEWAY_PORT, DATABASE_URL, CORE_SUBGRAPH_URL, COOPNAME, JWT_SECRET. --- components/gateway-orchestrator/package.json | 31 + .../gateway-orchestrator/src/app.module.ts | 25 + .../src/config/app-config.ts | 53 ++ .../src/gateway/gateway.module.ts | 75 +++ .../src/gateway/subgraph-registry.entity.ts | 37 ++ .../src/gateway/subgraph-registry.service.ts | 52 ++ components/gateway-orchestrator/src/main.ts | 54 ++ .../orchestrator/composition.controller.ts | 43 ++ .../src/orchestrator/install.controller.ts | 40 ++ .../src/orchestrator/orchestrator.module.ts | 10 + components/gateway-orchestrator/tsconfig.json | 17 + pnpm-lock.yaml | 561 +++++++++++++++++- 12 files changed, 992 insertions(+), 6 deletions(-) create mode 100644 components/gateway-orchestrator/package.json create mode 100644 components/gateway-orchestrator/src/app.module.ts create mode 100644 components/gateway-orchestrator/src/config/app-config.ts create mode 100644 components/gateway-orchestrator/src/gateway/gateway.module.ts create mode 100644 components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts create mode 100644 components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts create mode 100644 components/gateway-orchestrator/src/main.ts create mode 100644 components/gateway-orchestrator/src/orchestrator/composition.controller.ts create mode 100644 components/gateway-orchestrator/src/orchestrator/install.controller.ts create mode 100644 components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts create mode 100644 components/gateway-orchestrator/tsconfig.json diff --git a/components/gateway-orchestrator/package.json b/components/gateway-orchestrator/package.json new file mode 100644 index 00000000000..cd67bfce392 --- /dev/null +++ b/components/gateway-orchestrator/package.json @@ -0,0 +1,31 @@ +{ + "name": "@coopenomics/gateway-orchestrator", + "version": "0.1.0", + "description": "Per-tenant сервис: Apollo Federation Gateway + orchestrator расширений (docker compose + on-chain auto-update). Epic 10 Story 10.3.", + "private": true, + "main": "dist/main.js", + "scripts": { + "build": "tsc -p tsconfig.json", + "start": "node dist/main.js", + "dev": "ts-node-dev --respawn src/main.ts" + }, + "dependencies": { + "@apollo/gateway": "^2.9.0", + "@nestjs/apollo": "^12.2.1", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/graphql": "^12.2.1", + "@nestjs/platform-express": "^10.4.0", + "@nestjs/typeorm": "^10.0.2", + "graphql": "^16.9.0", + "pg": "^8.13.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1", + "typeorm": "^0.3.20" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.4.0" + } +} diff --git a/components/gateway-orchestrator/src/app.module.ts b/components/gateway-orchestrator/src/app.module.ts new file mode 100644 index 00000000000..8b53e25af8f --- /dev/null +++ b/components/gateway-orchestrator/src/app.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GatewayModule } from './gateway/gateway.module'; +import { OrchestratorModule } from './orchestrator/orchestrator.module'; +import { loadAppConfig } from './config/app-config'; +import { SubgraphRegistryEntity } from './gateway/subgraph-registry.entity'; + +const cfg = loadAppConfig(); + +@Module({ + imports: [ + TypeOrmModule.forRoot({ + type: 'postgres', + url: cfg.postgresUrl, + entities: [SubgraphRegistryEntity], + // На bootstrap безопасно создаём таблицу — registry — единственный + // объект, который реально нужен gateway. Прод схему мигрирует + // отдельная миграция при PR на интеграцию с deploy-pipeline. + synchronize: true, + }), + GatewayModule, + OrchestratorModule, + ], +}) +export class AppModule {} diff --git a/components/gateway-orchestrator/src/config/app-config.ts b/components/gateway-orchestrator/src/config/app-config.ts new file mode 100644 index 00000000000..757fa653d3f --- /dev/null +++ b/components/gateway-orchestrator/src/config/app-config.ts @@ -0,0 +1,53 @@ +/** + * ENV-конфигурация gateway-orchestrator (per-tenant). + * + * Все обязательные поля fail-fast валидируются на bootstrap. Если orchestrator + * стартует и не находит coopback subgraph URL — он не сможет ничего + * скомпоновать; полагаться на default'ы для tenant'а опасно. + */ +export interface AppConfig { + port: number; + postgresUrl: string; + coreSubgraphUrl: string; + coopname: string; + jwtSecret: string; + compositionPollIntervalMs: number; +} + +export class AppConfigError extends Error { + constructor(msg: string) { + super(`[gateway-orchestrator] ${msg}`); + this.name = 'AppConfigError'; + } +} + +export function loadAppConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { + const port = Number(env.GATEWAY_PORT ?? 4000); + if (!Number.isFinite(port) || port <= 0) { + throw new AppConfigError('GATEWAY_PORT некорректен'); + } + const postgresUrl = env.DATABASE_URL; + if (!postgresUrl) { + throw new AppConfigError('DATABASE_URL не задан'); + } + const coreSubgraphUrl = env.CORE_SUBGRAPH_URL; + if (!coreSubgraphUrl) { + throw new AppConfigError('CORE_SUBGRAPH_URL не задан (например http://coopback:3000/v1/graphql)'); + } + const coopname = env.COOPNAME; + if (!coopname) { + throw new AppConfigError('COOPNAME не задан'); + } + const jwtSecret = env.JWT_SECRET; + if (!jwtSecret) { + throw new AppConfigError('JWT_SECRET не задан'); + } + return { + port, + postgresUrl, + coreSubgraphUrl, + coopname, + jwtSecret, + compositionPollIntervalMs: Number(env.COMPOSITION_POLL_INTERVAL_MS ?? 10000), + }; +} diff --git a/components/gateway-orchestrator/src/gateway/gateway.module.ts b/components/gateway-orchestrator/src/gateway/gateway.module.ts new file mode 100644 index 00000000000..8fc8bb16094 --- /dev/null +++ b/components/gateway-orchestrator/src/gateway/gateway.module.ts @@ -0,0 +1,75 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { GraphQLModule } from '@nestjs/graphql'; +import { ApolloGatewayDriver, ApolloGatewayDriverConfig } from '@nestjs/apollo'; +import { IntrospectAndCompose, RemoteGraphQLDataSource } from '@apollo/gateway'; +import { SubgraphRegistryEntity } from './subgraph-registry.entity'; +import { SubgraphRegistryService } from './subgraph-registry.service'; + +/** + * Apollo Federation Gateway tenant'а. + * + * IntrospectAndCompose читает active subgraph'ы из Postgres registry с + * periodic polling. Gateway не перезагружается при появлении нового + * subgraph'а — он re-introspect'ит и swap'ает supergraph in-memory. + * + * JWT forwarding: gateway получает Authorization-header от desktop, + * прокидывает в каждый subgraph через willSendRequest. Subgraph'ы + * валидируют JWT тем же секретом через @coopenomics/extension-sdk. + */ +@Module({ + imports: [ + TypeOrmModule.forFeature([SubgraphRegistryEntity]), + GraphQLModule.forRootAsync({ + driver: ApolloGatewayDriver, + imports: [TypeOrmModule.forFeature([SubgraphRegistryEntity])], + inject: [SubgraphRegistryService], + useFactory: async (registry: SubgraphRegistryService) => { + // MVP-режим: subgraphs читаются из Postgres registry один раз на + // bootstrap. IntrospectAndCompose делает polling existing endpoints + // и автоматически re-compose'ит если у subgraph'а изменилась схема. + // + // Но: добавление НОВОГО subgraph'а (POST /v1/internal/extensions/install) + // потребует рестарта контейнера gateway-orchestrator. На MVP это + // ОК — gateway маленький, warmup ~5 сек, делается orchestrator-pipeline + // при первом install'е приложения (Story 10.4). + // + // Полностью dynamic supergraph composition (без рестарта) — отдельная + // story 10.3b: custom SupergraphManager, который опрашивает registry + // на каждый poll и собирает supergraphSdl.fetch(). + const subgraphs = await registry.listForCompose(); + if (subgraphs.length === 0) { + throw new Error('[gateway] subgraph registry пуст — orchestrator должен seed core до boot gateway'); + } + return { + server: { + path: '/v1/graphql', + introspection: true, + context: ({ req }: { req: unknown }) => ({ req }), + }, + gateway: { + supergraphSdl: new IntrospectAndCompose({ + subgraphs, + pollIntervalInMs: 10000, + }), + buildService({ url }) { + return new RemoteGraphQLDataSource({ + url, + willSendRequest({ request, context }) { + const incoming = (context as { req?: { headers?: Record } })?.req?.headers ?? {}; + const auth = incoming.authorization ?? incoming.Authorization; + if (auth && request.http) { + request.http.headers.set('authorization', auth); + } + }, + }); + }, + }, + }; + }, + }), + ], + providers: [SubgraphRegistryService], + exports: [SubgraphRegistryService, TypeOrmModule], +}) +export class GatewayModule {} diff --git a/components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts b/components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts new file mode 100644 index 00000000000..93bd3d7a920 --- /dev/null +++ b/components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts @@ -0,0 +1,37 @@ +import { Entity, PrimaryColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm'; + +/** + * Регистр subgraph'ов tenant'а. Apollo Gateway периодически читает active=true + * запись и пересобирает supergraph. + * + * Стартовая запись для core-subgraph'а (coopback) добавляется на bootstrap + * orchestrator'а; остальные приезжают через POST /v1/internal/extensions/install. + * + * package_id = '@scope/name' (для core — sentinel 'core@coopback'). + * version = семантическая версия image'а (для core — 'monolith'). + * url = http://service-name:port/v1/graphql — резолвится docker DNS. + * active = маршрутизируется ли в supergraph (false = выпиленный extension). + */ +@Entity('subgraph_registry') +export class SubgraphRegistryEntity { + @PrimaryColumn({ type: 'varchar', length: 128, comment: 'package_id (@scope/name) или core sentinel' }) + packageId!: string; + + @Column({ type: 'varchar', length: 32 }) + version!: string; + + @Column({ type: 'varchar', length: 512 }) + url!: string; + + @Column({ type: 'boolean', default: true }) + active!: boolean; + + @Column({ type: 'varchar', length: 16, default: 'unknown', comment: 'last healthcheck result' }) + healthStatus!: string; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts b/components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts new file mode 100644 index 00000000000..9d926917c50 --- /dev/null +++ b/components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts @@ -0,0 +1,52 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { SubgraphRegistryEntity } from './subgraph-registry.entity'; + +export interface SubgraphDescriptor { + name: string; + url: string; +} + +/** + * Чтение/запись регистра subgraph'ов. Используется и Apollo Gateway'ем + * (для compose), и orchestrator REST-эндпоинтами (install/uninstall/refresh). + */ +@Injectable() +export class SubgraphRegistryService { + constructor( + @InjectRepository(SubgraphRegistryEntity) + private readonly repo: Repository, + ) {} + + /** + * Список active subgraph'ов в формате который ждёт Apollo Gateway's + * IntrospectAndCompose. На bootstrap'е orchestrator'а должна быть как + * минимум одна запись (core), иначе gateway не скомпонует ничего. + */ + async listForCompose(): Promise { + const rows = await this.repo.find({ where: { active: true } }); + return rows.map((r) => ({ name: r.packageId, url: r.url })); + } + + async upsert(packageId: string, version: string, url: string): Promise { + await this.repo.upsert( + { + packageId, + version, + url, + active: true, + healthStatus: 'unknown', + }, + ['packageId'], + ); + } + + async deactivate(packageId: string): Promise { + await this.repo.update({ packageId }, { active: false }); + } + + async setHealthStatus(packageId: string, healthStatus: string): Promise { + await this.repo.update({ packageId }, { healthStatus }); + } +} diff --git a/components/gateway-orchestrator/src/main.ts b/components/gateway-orchestrator/src/main.ts new file mode 100644 index 00000000000..9fd327b63a2 --- /dev/null +++ b/components/gateway-orchestrator/src/main.ts @@ -0,0 +1,54 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { DataSource } from 'typeorm'; +import { AppModule } from './app.module'; +import { loadAppConfig } from './config/app-config'; +import { SubgraphRegistryEntity } from './gateway/subgraph-registry.entity'; + +/** + * Перед стартом Apollo Gateway seed'им запись core-subgraph'а в registry, + * иначе IntrospectAndCompose упадёт с "пустой registry". + * + * Bootstrap делается в отдельной DataSource (минуя Nest DI), чтобы быть + * уверенным что seed произошёл ДО запуска GraphQLModule.forRootAsync. + */ +async function seedCoreSubgraph(cfg: ReturnType) { + const ds = new DataSource({ + type: 'postgres', + url: cfg.postgresUrl, + entities: [SubgraphRegistryEntity], + synchronize: true, + }); + await ds.initialize(); + const repo = ds.getRepository(SubgraphRegistryEntity); + await repo.upsert( + { + packageId: 'core@coopback', + version: 'monolith', + url: cfg.coreSubgraphUrl, + active: true, + healthStatus: 'unknown', + }, + ['packageId'], + ); + await ds.destroy(); +} + +async function bootstrap() { + const cfg = loadAppConfig(); + await seedCoreSubgraph(cfg); + + const app = await NestFactory.create(AppModule); + await app.listen(cfg.port); + // eslint-disable-next-line no-console + console.log( + `[gateway-orchestrator] up at :${cfg.port} ` + + `(coopname=${cfg.coopname}, core=${cfg.coreSubgraphUrl})`, + ); +} + +bootstrap().catch((err) => { + // eslint-disable-next-line no-console + console.error('[gateway-orchestrator] failed to bootstrap:', err); + process.exit(1); +}); diff --git a/components/gateway-orchestrator/src/orchestrator/composition.controller.ts b/components/gateway-orchestrator/src/orchestrator/composition.controller.ts new file mode 100644 index 00000000000..a9763465e92 --- /dev/null +++ b/components/gateway-orchestrator/src/orchestrator/composition.controller.ts @@ -0,0 +1,43 @@ +import { Controller, Get, Post, HttpCode } from '@nestjs/common'; +import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; + +/** + * Endpoint'ы для статуса и force-recompose supergraph'а. + * + * IntrospectAndCompose polling каждые 10 сек подхватит изменения registry + * и без force-refresh'а; force-endpoint полезен для интеграционных тестов + * и для быстрого reaction'а после install (когда не хочется ждать polling). + * + * **Важно:** реальный recompose делает сам Apollo Gateway по polling'у; этот + * endpoint только возвращает текущее состояние registry. Принудительная + * пересборка сейчас выполняется через изменение registry + ожидание + * polling'а (≤10 сек). Если позже понадобится hard-trigger — потребуется + * GraphQL Plugin для inline-вызова supergraphSdl.fetch(). + */ +@Controller('v1/internal/composition') +export class CompositionController { + constructor(private readonly registry: SubgraphRegistryService) {} + + @Get('status') + async status() { + const list = await this.registry.listForCompose(); + return { + subgraphCount: list.length, + subgraphs: list, + }; + } + + @Post('refresh') + @HttpCode(202) + async refresh() { + // Триггер сейчас implicit — IntrospectAndCompose polling. + // Возвращаем 202 Accepted чтобы orchestrator install pipeline мог + // вызывать этот endpoint и продолжать без ожидания. + const list = await this.registry.listForCompose(); + return { + accepted: true, + currentSubgraphCount: list.length, + pollIntervalMs: 10000, + }; + } +} diff --git a/components/gateway-orchestrator/src/orchestrator/install.controller.ts b/components/gateway-orchestrator/src/orchestrator/install.controller.ts new file mode 100644 index 00000000000..d341147597e --- /dev/null +++ b/components/gateway-orchestrator/src/orchestrator/install.controller.ts @@ -0,0 +1,40 @@ +import { Body, Controller, Delete, Param, Post } from '@nestjs/common'; +import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; + +export interface InstallExtensionInput { + packageId: string; + version: string; + url: string; +} + +/** + * REST API для управления жизненным циклом расширений. + * + * **Story 10.3 (это PR):** только регистр — реальный docker compose pull/up + * и healthcheck приедут в Story 10.4 (Install pipeline integration). + * Сейчас endpoint просто пишет subgraph URL в registry; orchestrator-tests + * могут руками поднять контейнер и POST'нуть сюда. + * + * **Story 10.4:** controller станет тонкой обёрткой, реальную работу + * делает InstallOrchestratorService — pull image через CA-auth JWT, + * docker compose up -d, healthcheck опрос, register. + */ +@Controller('v1/internal/extensions') +export class InstallController { + constructor(private readonly registry: SubgraphRegistryService) {} + + @Post('install') + async install(@Body() input: InstallExtensionInput) { + if (!input.packageId || !input.version || !input.url) { + return { ok: false, error: 'packageId, version, url обязательны' }; + } + await this.registry.upsert(input.packageId, input.version, input.url); + return { ok: true, registered: input.packageId, pollWithinMs: 10000 }; + } + + @Delete('uninstall/:packageId') + async uninstall(@Param('packageId') packageId: string) { + await this.registry.deactivate(packageId); + return { ok: true, deactivated: packageId }; + } +} diff --git a/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts b/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts new file mode 100644 index 00000000000..e0edbe2d5dc --- /dev/null +++ b/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GatewayModule } from '../gateway/gateway.module'; +import { CompositionController } from './composition.controller'; +import { InstallController } from './install.controller'; + +@Module({ + imports: [GatewayModule], + controllers: [CompositionController, InstallController], +}) +export class OrchestratorModule {} diff --git a/components/gateway-orchestrator/tsconfig.json b/components/gateway-orchestrator/tsconfig.json new file mode 100644 index 00000000000..1b20d563127 --- /dev/null +++ b/components/gateway-orchestrator/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "declaration": false, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e314995798f..4afb4876855 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -297,7 +297,7 @@ importers: version: 0.13.24 '@nestjs/apollo': specifier: ^12.2.1 - version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2) + version: 12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2) '@nestjs/bull': specifier: ^10.2.1 version: 10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(bull@4.16.5) @@ -1077,7 +1077,7 @@ importers: version: link:../extension-sdk '@nestjs/apollo': specifier: ^12.2.1 - version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + version: 12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -1126,7 +1126,7 @@ importers: version: 2.14.1(graphql@16.13.2) '@nestjs/apollo': specifier: ^12.2.1 - version: 12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + version: 12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) '@nestjs/common': specifier: ^10.4.0 version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -1264,6 +1264,55 @@ importers: specifier: ^1.6.0 version: 1.6.1(@types/node@20.19.37)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) + components/gateway-orchestrator: + dependencies: + '@apollo/gateway': + specifier: ^2.9.0 + version: 2.14.1(encoding@0.1.13)(graphql@16.13.2) + '@nestjs/apollo': + specifier: ^12.2.1 + version: 12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.1 + version: 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2) + '@nestjs/platform-express': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/typeorm': + specifier: ^10.0.2 + version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3))) + graphql: + specifier: ^16.9.0 + version: 16.13.2 + pg: + specifier: ^8.13.0 + version: 8.20.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: ^0.3.20 + version: 0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^20.0.0 + version: 20.19.37 + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + components/inter: devDependencies: typescript: @@ -1721,12 +1770,24 @@ packages: subscriptions-transport-ws: optional: true + '@apollo/composition@2.14.1': + resolution: {integrity: sha512-VWtB2jb19lnR5JaD181x7g2oyCpNsLEAetYdB41McmxHIemFAv0AEs679bPmr0IL8dzf74Cm8dL5bxWCaHBnBQ==} + engines: {node: '>=18'} + peerDependencies: + graphql: ^16.5.0 + '@apollo/federation-internals@2.14.1': resolution: {integrity: sha512-BVgwDr9H9mR/9LEzRzhBXJqo2cYdjkwSK/2uEgMa+B45oXvMI5r69y6qdFvMjRyP1R6y4wjnR9UtYGpx5uhhdw==} engines: {node: '>=18'} peerDependencies: graphql: ^16.5.0 + '@apollo/gateway@2.14.1': + resolution: {integrity: sha512-iy7+67nXa8gmRoFyIBLDa5BfYzJJfCn/RFI2PJF/wTG55YJ82vGWdvQZ3gPRmO+mDFyza60ZMPhR3jKMG8EnEg==} + engines: {node: '>=14.15.0'} + peerDependencies: + graphql: ^16.5.0 + '@apollo/protobufjs@1.2.6': resolution: {integrity: sha512-Wqo1oSHNUj/jxmsVp4iR3I480p6qdqHikn38lKrFhfzcDJ7lwd7Ck7cHRl4JE81tWNArl77xhnG/OkZhxKBYOw==} hasBin: true @@ -1735,6 +1796,18 @@ packages: resolution: {integrity: sha512-Lahx5zntHPZia35myYDBRuF58tlwPskwHc5CWBZC/4bMKB6siTBWwtMrkqXcsNwQiFSzSx5hKdRPUmemrEp3Gg==} hasBin: true + '@apollo/query-graphs@2.14.1': + resolution: {integrity: sha512-gi3TWpkrr7Yq1SqpDdZp0yWtnIMQxrwd1K8wJJnp5KBiebH3qT20zcyBqKNDEGvE5Q9+4neH0g1I2xElwpLg5w==} + engines: {node: '>=18'} + peerDependencies: + graphql: ^16.5.0 + + '@apollo/query-planner@2.14.1': + resolution: {integrity: sha512-nUxh4VTdyqtMkncUkCtKQ9UmUCcm0RelIcGR9CXEn8iAbO/FM4wN9xeT5o5O2bVus+OLd8o9GmQ5UppqTwTqUw==} + engines: {node: '>=14.15.0'} + peerDependencies: + graphql: ^16.5.0 + '@apollo/server-gateway-interface@1.1.1': resolution: {integrity: sha512-pGwCl/po6+rxRmDMFgozKQo2pbsSwE91TpsDBAOgf74CRDPXHHtM88wbwjab0wMMZh95QfR45GGyDIdhY24bkQ==} deprecated: '@apollo/server-gateway-interface v1 is part of Apollo Server v4, which is deprecated and will transition to end-of-life on January 26, 2026. As long as you are already using a non-EOL version of Node.js, upgrading to v2 should take only a few minutes. See https://www.apollographql.com/docs/apollo-server/previous-versions for details.' @@ -3961,6 +4034,10 @@ packages: '@fortawesome/fontawesome-svg-core': ~1 || ~6 || ~7 vue: '>= 3.0.0 < 4' + '@gar/promise-retry@1.0.3': + resolution: {integrity: sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA==} + engines: {node: ^20.17.0 || >=22.9.0} + '@gerrit0/mini-shiki@3.23.0': resolution: {integrity: sha512-bEMORlG0cqdjVyCEuU0cDQbORWX+kYCeo0kV1lbxF5bt4r7SID2l9bqsxJEM0zndaxpOUT7riCyIVEuqq/Ynxg==} @@ -5539,6 +5616,10 @@ packages: resolution: {integrity: sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@npmcli/agent@4.0.2': + resolution: {integrity: sha512-EUEuWAxnL07Sp5/iC/1X6Xj+XThUvnbei9zfRWZdEXa7lss9RTHMhAHBeg+MZ5To9s/gGaSI+UwZTPdYMvKSeg==} + engines: {node: ^20.17.0 || >=22.9.0} + '@npmcli/arborist@7.5.4': resolution: {integrity: sha512-nWtIc6QwwoUORCRNzKx4ypHqCk3drI+5aeYdMTQQiRCcn4lOOgfQh7WyZobGYTxXPSq1VwV53lkpN/BRlRk08g==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5552,6 +5633,10 @@ packages: resolution: {integrity: sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==} engines: {node: ^18.17.0 || >=20.5.0} + '@npmcli/fs@5.0.0': + resolution: {integrity: sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==} + engines: {node: ^20.17.0 || >=22.9.0} + '@npmcli/git@5.0.8': resolution: {integrity: sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5593,6 +5678,10 @@ packages: resolution: {integrity: sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw==} engines: {node: ^16.14.0 || >=18.0.0} + '@npmcli/redact@4.0.0': + resolution: {integrity: sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q==} + engines: {node: ^20.17.0 || >=22.9.0} + '@npmcli/run-script@8.1.0': resolution: {integrity: sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg==} engines: {node: ^16.14.0 || >=18.0.0} @@ -5828,6 +5917,10 @@ packages: resolution: {integrity: sha512-8HArjKx+RaAI8uEIgcORbZIPklyh1YLjPSBus8hjRmvLi6DeFzgOcdZ7KwPabKj8mXF8dX0hyfAyGfycz0DbFw==} engines: {node: '>=14'} + '@opentelemetry/api-logs@0.57.2': + resolution: {integrity: sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A==} + engines: {node: '>=14'} + '@opentelemetry/api@1.9.0': resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==} engines: {node: '>=8.0.0'} @@ -5854,6 +5947,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@1.30.1': + resolution: {integrity: sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/core@2.6.0': resolution: {integrity: sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg==} engines: {node: ^18.19.0 || >=20.6.0} @@ -5866,6 +5965,12 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/exporter-metrics-otlp-http@0.57.2': + resolution: {integrity: sha512-ttb9+4iKw04IMubjm3t0EZsYRNWr3kg44uUuzfo9CaccYlOh8cDooe4QObDUkvx9d5qQUrbEckhrWKfJnKhemA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/exporter-trace-otlp-http@0.53.0': resolution: {integrity: sha512-m7F5ZTq+V9mKGWYpX8EnZ7NjoqAU7VemQ1E2HAG+W/u0wpY1x0OmbxAXfGKFHCspdJk8UKlwPGrpcB8nay3P8A==} engines: {node: '>=14'} @@ -6034,12 +6139,24 @@ packages: peerDependencies: '@opentelemetry/api': ^1.0.0 + '@opentelemetry/otlp-exporter-base@0.57.2': + resolution: {integrity: sha512-XdxEzL23Urhidyebg5E6jZoaiW5ygP/mRjxLHixogbqwDy2Faduzb5N0o/Oi+XTIJu+iyxXdVORjXax+Qgfxag==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.53.0': resolution: {integrity: sha512-rM0sDA9HD8dluwuBxLetUmoqGJKSAbWenwD65KY9iZhUxdBHRLrIdrABfNDP7aiTjcgK8XFyTn5fhDz7N+W6DA==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': ^1.3.0 + '@opentelemetry/otlp-transformer@0.57.2': + resolution: {integrity: sha512-48IIRj49gbQVK52jYsw70+Jv+JbahT8BqT2Th7C4H7RCM9d0gZ5sgNPoMpWldmfjvIsSgiGJtjfk9MeZvjhoig==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + '@opentelemetry/propagator-b3@1.26.0': resolution: {integrity: sha512-vvVkQLQ/lGGyEy9GT8uFnI047pajSOVnZI2poJqVGD3nJ+B9sFGdlHNnQKophE3lHfnIH0pw2ubrCTjZCgIj+Q==} engines: {node: '>=14'} @@ -6056,12 +6173,42 @@ packages: resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==} engines: {node: ^18.19.0 || >=20.6.0} + '@opentelemetry/resource-detector-alibaba-cloud@0.30.1': + resolution: {integrity: sha512-9l0FVP3F4+Z6ax27vMzkmhZdNtxAbDqEfy7rduzya3xFLaRiJSvOpw6cru6Edl5LwO+WvgNui+VzHa9ViE8wCg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@1.12.0': + resolution: {integrity: sha512-Cvi7ckOqiiuWlHBdA1IjS0ufr3sltex2Uws2RK6loVp4gzIJyOijsddAI6IZ5kiO8h/LgCWe8gxPmwkTKImd+Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.6.1': + resolution: {integrity: sha512-Djr31QCExVfWViaf9cGJnH+bUInD72p0GEfgDGgjCAztyvyji6WJvKjs6qmkpPN+Ig6KLk0ho2VgzT5Kdl4L2Q==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.33.1': + resolution: {integrity: sha512-/aZJXI1rU6Eus04ih2vU0hxXAibXXMzH1WlDZ8bXcTJmhwmTY8cP392+6l7cWeMnTQOibBUz8UKV3nhcCBAefw==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@opentelemetry/resources@1.26.0': resolution: {integrity: sha512-CPNYchBE7MBecCSVy0HKpUISEeJOniWqcHaAHpmasZ3j9o6V3AyBzhRc90jdmemq0HOxDr6ylhUbDhBqqPpeNw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/resources@1.30.1': + resolution: {integrity: sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/resources@2.6.1': resolution: {integrity: sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6074,18 +6221,36 @@ packages: peerDependencies: '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-logs@0.57.2': + resolution: {integrity: sha512-TXFHJ5c+BKggWbdEQ/inpgIzEmS2BGQowLE9UhsMd7YYlUfBQJ4uax0VF/B5NYigdM/75OoJGhAV3upEhK+3gg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + '@opentelemetry/sdk-metrics@1.26.0': resolution: {integrity: sha512-0SvDXmou/JjzSDOjUmetAAvcKQW6ZrvosU0rkbDGpXvvZN+pQF6JbK/Kd4hNdK4q/22yeruqvukXEJyySTzyTQ==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-metrics@1.30.1': + resolution: {integrity: sha512-q9zcZ0Okl8jRgmy7eNW3Ku1XSgg3sDLa5evHZpCwjspw7E8Is4K/haRPDJrBcX3YSn/Y7gUvFnByNYEKQNbNog==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + '@opentelemetry/sdk-trace-base@1.26.0': resolution: {integrity: sha512-olWQldtvbK4v22ymrKLbIcBi9L2SpMO84sCPY54IVsJhP9fRsxJT194C/AVaAuJzLE30EdhhM1VmvVYR7az+cw==} engines: {node: '>=14'} peerDependencies: '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace-base@1.30.1': + resolution: {integrity: sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + '@opentelemetry/sdk-trace-base@2.6.1': resolution: {integrity: sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==} engines: {node: ^18.19.0 || >=20.6.0} @@ -6102,6 +6267,10 @@ packages: resolution: {integrity: sha512-sAay1RrB+ONOem0OZanAR1ZI/k7yDpnOQSQmTMuGImUQb2y8EbSaCJ94FQluM74xoU03vlb2d2U90hZluL6nQg==} engines: {node: '>=14'} + '@opentelemetry/semantic-conventions@1.28.0': + resolution: {integrity: sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA==} + engines: {node: '>=14'} + '@opentelemetry/semantic-conventions@1.40.0': resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==} engines: {node: '>=14'} @@ -8975,6 +9144,9 @@ packages: bigi@1.4.2: resolution: {integrity: sha512-ddkU+dFIuEIW8lE7ZwdIAf2UPoM90eaprg5m3YXAVVTmKlqV/9BX4A2M8BOK2yOq6/VgZFVhK6QAxJebhlbhzw==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bin-links@4.0.4: resolution: {integrity: sha512-cMtq4W5ZsEwcutJrVId+a/tjt8GSbS+h0oNkdl6+6rBuEv8Ot33Bevj5KPm40t309zuhVic8NjpuL42QCiJWWA==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -9193,6 +9365,10 @@ packages: resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==} engines: {node: ^18.17.0 || >=20.5.0} + cacache@20.0.4: + resolution: {integrity: sha512-M3Lab8NPYlZU2exsL3bMVvMrMqgwCnMWfdZbK28bn3pK6APT/Te/I8hjRPNu1uwORY9a1eEQoifXbKPQMfMTOA==} + engines: {node: ^20.17.0 || >=22.9.0} + cacheable-lookup@5.0.4: resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} engines: {node: '>=10.6.0'} @@ -10372,6 +10548,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-equal@2.2.3: + resolution: {integrity: sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==} + engines: {node: '>= 0.4'} + deep-extend@0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -10831,6 +11011,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-get-iterator@1.1.3: + resolution: {integrity: sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==} + es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} @@ -11833,10 +12016,18 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@6.7.1: + resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==} + engines: {node: '>=14'} + gaze@1.1.3: resolution: {integrity: sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==} engines: {node: '>= 4.0.0'} + gcp-metadata@6.1.1: + resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==} + engines: {node: '>=14'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -12007,6 +12198,10 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + glob@5.0.15: resolution: {integrity: sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -12080,6 +12275,10 @@ packages: resolution: {integrity: sha512-OPTIfhMBh7JbBYDpa5b+Q5ptmMWKwcNcFSR/0c6t8V4f3ZAVBEsKNY37QdVqmLRYSMhOUGYrY0QhSoEpzGr/Eg==} engines: {node: '>= 0.10'} + google-logging-utils@0.0.2: + resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -12706,6 +12905,10 @@ packages: is-alphanumerical@1.0.4: resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==} + is-arguments@1.2.0: + resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} + engines: {node: '>= 0.4'} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -13324,6 +13527,9 @@ packages: engines: {node: '>=6'} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -13942,6 +14148,10 @@ packages: resolution: {integrity: sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==} engines: {node: ^18.17.0 || >=20.5.0} + make-fetch-happen@15.0.6: + resolution: {integrity: sha512-Je0fLJ0F5atA7F+eIlLzk+Wkcl57JDf4kf+EW8xiP5E31xOQxkIxTbgf1Oi1Lw9tRI9UEMRdI5Vz2xTzoNU1Jw==} + engines: {node: ^20.17.0 || >=22.9.0} + make-iterator@1.0.1: resolution: {integrity: sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==} engines: {node: '>=0.10.0'} @@ -14342,6 +14552,10 @@ packages: resolution: {integrity: sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==} engines: {node: ^18.17.0 || >=20.5.0} + minipass-fetch@5.0.2: + resolution: {integrity: sha512-2d0q2a8eCi2IRg/IGubCNRJoYbA1+YPXAzQVRFmB45gdGZafyivnZ5YSEfo3JikbjGxOdntGFvBQGqaSMXlAFQ==} + engines: {node: ^20.17.0 || >=22.9.0} + minipass-flush@1.0.6: resolution: {integrity: sha512-7Uf5gMJZ2kTkFisE3toGxT991s+cg+vMh42nbZGM2bNxfYVpkpqRudf1QrcOy72a3PwcL4JYqL+4NY7t0Hdd0A==} engines: {node: '>=16 || 14 >=14.17'} @@ -14354,6 +14568,10 @@ packages: resolution: {integrity: sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==} engines: {node: '>=8'} + minipass-sized@2.0.0: + resolution: {integrity: sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==} + engines: {node: '>=8'} + minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -14676,6 +14894,9 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + node-cpu-count@0.1.1: + resolution: {integrity: sha512-YeKJLH1t8AXmBLqYv1HBqH8OF7VSLyxKxh97+RFHDAKW1c+E7fL5ElK+mHxl9dn/r+TsxExm8o5vruZ9f7s/QA==} + node-cron@3.0.3: resolution: {integrity: sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==} engines: {node: '>=6.0.0'} @@ -14939,6 +15160,10 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + object-is@1.1.6: + resolution: {integrity: sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==} + engines: {node: '>= 0.4'} + object-keys@1.1.1: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} @@ -15928,6 +16153,10 @@ packages: resolution: {integrity: sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==} engines: {node: ^18.17.0 || >=20.5.0} + proc-log@6.1.0: + resolution: {integrity: sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==} + engines: {node: ^20.17.0 || >=22.9.0} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -17314,6 +17543,10 @@ packages: resolution: {integrity: sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==} engines: {node: ^18.17.0 || >=20.5.0} + ssri@13.0.1: + resolution: {integrity: sha512-QUiRf1+u9wPTL/76GTYlKttDEBWV1ga9ZXW8BG6kfdeyyM8LGPix9gROyg9V2+P0xNyF3X2Go526xKFdMZrHSQ==} + engines: {node: ^20.17.0 || >=22.9.0} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -17947,6 +18180,10 @@ packages: typescript: optional: true + ts-graphviz@1.8.2: + resolution: {integrity: sha512-5YhbFoHmjxa7pgQLkB07MtGnGJ/yhvjmc9uhsnDBEICME6gkPf83SBwLDQqGDoCa3XzUMWLk1AU2Wn1u1naDtA==} + engines: {node: '>=14.16'} + ts-jest@29.4.6: resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} @@ -19769,6 +20006,12 @@ snapshots: subscriptions-transport-ws: 0.11.0(graphql@16.13.2) optional: true + '@apollo/composition@2.14.1(graphql@16.13.2)': + dependencies: + '@apollo/federation-internals': 2.14.1(graphql@16.13.2) + '@apollo/query-graphs': 2.14.1(graphql@16.13.2) + graphql: 16.13.2 + '@apollo/federation-internals@2.14.1(graphql@16.13.2)': dependencies: '@types/uuid': 9.0.8 @@ -19777,6 +20020,41 @@ snapshots: js-levenshtein: 1.1.6 uuid: 9.0.1 + '@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2)': + dependencies: + '@apollo/composition': 2.14.1(graphql@16.13.2) + '@apollo/federation-internals': 2.14.1(graphql@16.13.2) + '@apollo/query-planner': 2.14.1(graphql@16.13.2) + '@apollo/server-gateway-interface': 1.1.1(graphql@16.13.2) + '@apollo/usage-reporting-protobuf': 4.1.1 + '@apollo/utils.createhash': 2.0.2 + '@apollo/utils.fetcher': 2.0.1 + '@apollo/utils.isnodelike': 2.0.1 + '@apollo/utils.keyvaluecache': 2.1.1 + '@apollo/utils.logger': 2.0.1 + '@josephg/resolvable': 1.0.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 1.12.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.6.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.33.1(@opentelemetry/api@1.9.1)(encoding@0.1.13) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + '@types/node-fetch': 2.6.13 + async-retry: 1.3.3 + graphql: 16.13.2 + loglevel: 1.9.2 + make-fetch-happen: 15.0.6 + node-abort-controller: 3.1.1 + node-cpu-count: 0.1.1 + node-fetch: 2.7.0(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + '@apollo/protobufjs@1.2.6': dependencies: '@protobufjs/aspromise': 1.1.2 @@ -19808,6 +20086,24 @@ snapshots: '@types/long': 4.0.2 long: 4.0.0 + '@apollo/query-graphs@2.14.1(graphql@16.13.2)': + dependencies: + '@apollo/federation-internals': 2.14.1(graphql@16.13.2) + deep-equal: 2.2.3 + graphql: 16.13.2 + ts-graphviz: 1.8.2 + uuid: 9.0.1 + + '@apollo/query-planner@2.14.1(graphql@16.13.2)': + dependencies: + '@apollo/federation-internals': 2.14.1(graphql@16.13.2) + '@apollo/query-graphs': 2.14.1(graphql@16.13.2) + '@apollo/utils.keyvaluecache': 2.1.1 + chalk: 4.1.2 + deep-equal: 2.2.3 + graphql: 16.13.2 + pretty-format: 29.7.0 + '@apollo/server-gateway-interface@1.1.1(graphql@16.13.2)': dependencies: '@apollo/usage-reporting-protobuf': 4.1.1 @@ -22285,6 +22581,8 @@ snapshots: '@fortawesome/fontawesome-svg-core': 7.2.0 vue: 3.5.31(typescript@5.9.3) + '@gar/promise-retry@1.0.3': {} + '@gerrit0/mini-shiki@3.23.0': dependencies: '@shikijs/engine-oniguruma': 3.23.0 @@ -24387,7 +24685,7 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/apollo@12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2)': + '@nestjs/apollo@12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.1.14))(graphql@16.13.2)': dependencies: '@apollo/server': 4.13.0(encoding@0.1.13)(graphql@16.13.2) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2)) @@ -24399,9 +24697,10 @@ snapshots: lodash.omit: 4.5.0 tslib: 2.8.1 optionalDependencies: + '@apollo/gateway': 2.14.1(encoding@0.1.13)(graphql@16.13.2) '@apollo/subgraph': 2.14.1(graphql@16.13.2) - '@nestjs/apollo@12.2.2(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2)': + '@nestjs/apollo@12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2)': dependencies: '@apollo/server': 4.13.0(encoding@0.1.13)(graphql@16.13.2) '@apollo/server-plugin-landing-page-graphql-playground': 4.0.0(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2)) @@ -24413,6 +24712,7 @@ snapshots: lodash.omit: 4.5.0 tslib: 2.8.1 optionalDependencies: + '@apollo/gateway': 2.14.1(encoding@0.1.13)(graphql@16.13.2) '@apollo/subgraph': 2.14.1(graphql@16.13.2) '@nestjs/bull-shared@10.2.3(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)': @@ -24651,6 +24951,15 @@ snapshots: typeorm: 0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) uuid: 9.0.1 + '@nestjs/typeorm@10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)))': + dependencies: + '@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) + uuid: 9.0.1 + '@noble/hashes@1.4.0': {} '@noble/hashes@1.8.0': {} @@ -24714,6 +25023,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@npmcli/agent@4.0.2': + dependencies: + agent-base: 7.1.4 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + lru-cache: 11.2.7 + socks-proxy-agent: 8.0.5 + transitivePeerDependencies: + - supports-color + '@npmcli/arborist@7.5.4': dependencies: '@isaacs/string-locale-compare': 1.1.0 @@ -24763,6 +25082,10 @@ snapshots: dependencies: semver: 7.7.4 + '@npmcli/fs@5.0.0': + dependencies: + semver: 7.7.4 + '@npmcli/git@5.0.8': dependencies: '@npmcli/promise-spawn': 7.0.2 @@ -24826,6 +25149,8 @@ snapshots: '@npmcli/redact@2.0.1': {} + '@npmcli/redact@4.0.0': {} + '@npmcli/run-script@8.1.0': dependencies: '@npmcli/node-gyp': 3.0.0 @@ -25098,7 +25423,11 @@ snapshots: '@opentelemetry/api-logs@0.53.0': dependencies: - '@opentelemetry/api': 1.9.0 + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.57.2': + dependencies: + '@opentelemetry/api': 1.9.1 '@opentelemetry/api@1.9.0': {} @@ -25117,6 +25446,11 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.27.0 + '@opentelemetry/core@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/core@2.6.0(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -25127,6 +25461,15 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/semantic-conventions': 1.40.0 + '@opentelemetry/exporter-metrics-otlp-http@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http@0.53.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -25368,6 +25711,12 @@ snapshots: '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/otlp-transformer': 0.53.0(@opentelemetry/api@1.9.0) + '@opentelemetry/otlp-exporter-base@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer@0.53.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -25379,6 +25728,17 @@ snapshots: '@opentelemetry/sdk-trace-base': 1.26.0(@opentelemetry/api@1.9.0) protobufjs: 7.5.4 + '@opentelemetry/otlp-transformer@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.57.2(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 1.30.1(@opentelemetry/api@1.9.1) + protobufjs: 7.5.4 + '@opentelemetry/propagator-b3@1.26.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -25391,12 +25751,50 @@ snapshots: '@opentelemetry/redis-common@0.38.2': {} + '@opentelemetry/resource-detector-alibaba-cloud@0.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resource-detector-aws@1.12.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resource-detector-azure@0.6.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + + '@opentelemetry/resource-detector-gcp@0.33.1(@opentelemetry/api@1.9.1)(encoding@0.1.13)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.40.0 + gcp-metadata: 6.1.1(encoding@0.1.13) + transitivePeerDependencies: + - encoding + - supports-color + '@opentelemetry/resources@1.26.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.27.0 + '@opentelemetry/resources@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/resources@2.6.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -25410,12 +25808,25 @@ snapshots: '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-logs@0.57.2(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.57.2 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics@1.26.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) + '@opentelemetry/sdk-metrics@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base@1.26.0(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -25423,6 +25834,13 @@ snapshots: '@opentelemetry/resources': 1.26.0(@opentelemetry/api@1.9.0) '@opentelemetry/semantic-conventions': 1.27.0 + '@opentelemetry/sdk-trace-base@1.30.1(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 1.30.1(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.28.0 + '@opentelemetry/sdk-trace-base@2.6.1(@opentelemetry/api@1.9.1)': dependencies: '@opentelemetry/api': 1.9.1 @@ -25442,6 +25860,8 @@ snapshots: '@opentelemetry/semantic-conventions@1.27.0': {} + '@opentelemetry/semantic-conventions@1.28.0': {} + '@opentelemetry/semantic-conventions@1.40.0': {} '@opentelemetry/sql-common@0.41.2(@opentelemetry/api@1.9.1)': @@ -29000,6 +29420,8 @@ snapshots: bigi@1.4.2: {} + bignumber.js@9.3.1: {} + bin-links@4.0.4: dependencies: cmd-shim: 6.0.3 @@ -29306,6 +29728,19 @@ snapshots: tar: 7.5.13 unique-filename: 4.0.0 + cacache@20.0.4: + dependencies: + '@npmcli/fs': 5.0.0 + fs-minipass: 3.0.3 + glob: 13.0.6 + lru-cache: 11.2.7 + minipass: 7.1.3 + minipass-collect: 2.0.1 + minipass-flush: 1.0.6 + minipass-pipeline: 1.2.4 + p-map: 7.0.4 + ssri: 13.0.1 + cacheable-lookup@5.0.4: {} cacheable-request@7.0.4: @@ -30707,6 +31142,27 @@ snapshots: deep-eql@5.0.2: {} + deep-equal@2.2.3: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.8 + es-get-iterator: 1.1.3 + get-intrinsic: 1.3.0 + is-arguments: 1.2.0 + is-array-buffer: 3.0.5 + is-date-object: 1.1.0 + is-regex: 1.2.1 + is-shared-array-buffer: 1.0.4 + isarray: 2.0.5 + object-is: 1.1.6 + object-keys: 1.1.1 + object.assign: 4.1.7 + regexp.prototype.flags: 1.5.4 + side-channel: 1.1.0 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + deep-extend@0.6.0: {} deep-is@0.1.4: {} @@ -31235,6 +31691,18 @@ snapshots: es-errors@1.3.0: {} + es-get-iterator@1.1.3: + dependencies: + call-bind: 1.0.8 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + is-arguments: 1.2.0 + is-map: 2.0.3 + is-set: 2.0.3 + is-string: 1.1.1 + isarray: 2.0.5 + stop-iteration-iterator: 1.1.0 + es-module-lexer@1.7.0: {} es-module-lexer@2.0.0: {} @@ -33111,10 +33579,30 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@6.7.1(encoding@0.1.13): + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + is-stream: 2.0.1 + node-fetch: 2.7.0(encoding@0.1.13) + uuid: 9.0.1 + transitivePeerDependencies: + - encoding + - supports-color + gaze@1.1.3: dependencies: globule: 1.3.4 + gcp-metadata@6.1.1(encoding@0.1.13): + dependencies: + gaxios: 6.7.1(encoding@0.1.13) + google-logging-utils: 0.0.2 + json-bigint: 1.0.0 + transitivePeerDependencies: + - encoding + - supports-color + generator-function@2.0.1: {} gensync@1.0.0-beta.2: {} @@ -33308,6 +33796,12 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 2.0.2 + glob@13.0.6: + dependencies: + minimatch: 10.2.4 + minipass: 7.1.3 + path-scurry: 2.0.2 + glob@5.0.15: dependencies: inflight: 1.0.6 @@ -33426,6 +33920,8 @@ snapshots: lodash: 4.17.23 minimatch: 3.0.8 + google-logging-utils@0.0.2: {} + gopd@1.2.0: {} got@11.8.6: @@ -34246,6 +34742,11 @@ snapshots: is-alphabetical: 1.0.4 is-decimal: 1.0.4 + is-arguments@1.2.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -35009,6 +35510,10 @@ snapshots: jsesc@3.1.0: {} + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-parse-better-errors@1.0.2: {} @@ -35732,6 +36237,23 @@ snapshots: transitivePeerDependencies: - supports-color + make-fetch-happen@15.0.6: + dependencies: + '@gar/promise-retry': 1.0.3 + '@npmcli/agent': 4.0.2 + '@npmcli/redact': 4.0.0 + cacache: 20.0.4 + http-cache-semantics: 4.2.0 + minipass: 7.1.3 + minipass-fetch: 5.0.2 + minipass-flush: 1.0.6 + minipass-pipeline: 1.2.4 + negotiator: 1.0.0 + proc-log: 6.1.0 + ssri: 13.0.1 + transitivePeerDependencies: + - supports-color + make-iterator@1.0.1: dependencies: kind-of: 6.0.3 @@ -36349,6 +36871,14 @@ snapshots: optionalDependencies: encoding: 0.1.13 + minipass-fetch@5.0.2: + dependencies: + minipass: 7.1.3 + minipass-sized: 2.0.0 + minizlib: 3.1.0 + optionalDependencies: + iconv-lite: 0.7.2 + minipass-flush@1.0.6: dependencies: minipass: 7.1.3 @@ -36361,6 +36891,10 @@ snapshots: dependencies: minipass: 3.3.6 + minipass-sized@2.0.0: + dependencies: + minipass: 7.1.3 + minipass@3.3.6: dependencies: yallist: 4.0.0 @@ -36656,6 +37190,8 @@ snapshots: node-addon-api@7.1.1: optional: true + node-cpu-count@0.1.1: {} + node-cron@3.0.3: dependencies: uuid: 8.3.2 @@ -37009,6 +37545,11 @@ snapshots: object-inspect@1.13.4: {} + object-is@1.1.6: + dependencies: + call-bind: 1.0.8 + define-properties: 1.2.1 + object-keys@1.1.1: {} object-refs@0.4.0: {} @@ -38118,6 +38659,8 @@ snapshots: proc-log@5.0.0: {} + proc-log@6.1.0: {} + process-nextick-args@2.0.1: {} process-warning@5.0.0: {} @@ -39922,6 +40465,10 @@ snapshots: dependencies: minipass: 7.1.3 + ssri@13.0.1: + dependencies: + minipass: 7.1.3 + stable-hash-x@0.2.0: {} stack-trace@0.0.10: {} @@ -40610,6 +41157,8 @@ snapshots: optionalDependencies: typescript: 5.9.3 + ts-graphviz@1.8.2: {} + ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.4)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 From a07b5f8d6034cfa454a9096b61db5194b8e2218f Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 18:59:37 +0000 Subject: [PATCH 14/17] =?UTF-8?q?[9.3.b-pub]=20feat(controller):=20Mutatio?= =?UTF-8?q?n.publishPackage=20=E2=80=94=20on-chain=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20ca-?= =?UTF-8?q?admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 9.3.b-pub стола разработчика — мутация для воскходовского chairman'а: координаты пакета (@scope/name + owner + compatible_subnets) прокидываются на ca-admin POST /v1/admin/package, который сам подписывает on-chain action apps::regpkg от имени chairman'а кооператива-оператора каталога. Backend (apps-catalog-proxy): - PublishPackageInputDTO / PublishPackageResultDTO — GraphQL InputType + ObjectType (camelCase для Zeus, без snake-case утечек). - AppsCatalogHttpService.registerPackage(): POST на /v1/admin/package; discriminated outcome (applied | conflict | failed) — мапит 409 в conflict, прочие axios-ошибки в failed; degraded-mode (без APPS_CATALOG_API_KEY) возвращает failed с явным сообщением, чтобы UI не вводился в заблуждение «опубликовано». - AppsCatalogProxyResolver.publishPackage(): @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) — мутация доступна только председателю кооператива. Multipart upload install.js под архитектуру E10 НЕ делаем: расширения теперь публикуются как npm-package + docker-image в Nexus отдельной процедурой (`npm publish` / `docker push`) через scoped JWT от CA-auth. Эта мутация — только on-chain маркер «такой пакет существует»; релизы с manifest'ом — story 9.3.b-rel. schema.gql / Zeus client — codegen за пределами этого PR (mono-каноном pnpm generate-schema + generate-client + sdk build делается отдельным коммитом, чтобы PR с backend changes не зависел от пересборки SDK артефактов). Frontend — отдельной story 9.3.b-ui: DeveloperPublishReleasePage.vue сейчас заглушка, форму подключим после codegen'а Zeus. Co-Authored-By: Claude Opus 4.7 --- .../dto/publish-package-input.dto.ts | 37 +++++++++ .../dto/publish-package-result.dto.ts | 27 +++++++ .../resolvers/apps-catalog-proxy.resolver.ts | 58 +++++++++++++- .../apps-catalog-http.service.ts | 76 ++++++++++++++++++- 4 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-input.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-result.dto.ts diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-input.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-input.dto.ts new file mode 100644 index 00000000000..e99fc2bb196 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-input.dto.ts @@ -0,0 +1,37 @@ +import { Field, InputType } from '@nestjs/graphql'; + +/** + * Входные данные `Mutation.publishPackage` (story 9.3.b-pub). + * + * Эта мутация — первичная регистрация пакета в каталоге восхода. Она + * прокидывается на ca-admin `POST /v1/admin/package` (action + * `apps::regpkg` на blockchain), а ca-admin сам подписывает on-chain от + * имени chairman'а кооператива-оператора (voskhod). Поэтому здесь нет + * никаких ключей пайщика — только координаты пакета. + * + * Multipart upload install.js под Federation v2 более НЕ нужен: + * расширение публикуется как npm-package + docker-image в Nexus, а + * developer пушит их отдельной процедурой (`npm publish` + `docker + * push`) через CA-auth scoped JWT. Story 9.3.b-pub оставляет только + * on-chain регистрацию; релизы с manifest'ом — story 9.3.b-rel. + */ +@InputType() +export class PublishPackageInputDTO { + @Field({ + description: + 'Идентификатор пакета в формате @scope/name, например @voskhod/demoapp', + }) + packageId!: string; + + @Field({ + description: + 'Antelope-имя владельца пакета (1..12 символов из [a-z1-5.])', + }) + ownerUsername!: string; + + @Field(() => [String], { + description: + 'chain_id совместимых подсетей (каждый — 64 hex-символа), хотя бы одна', + }) + compatibleSubnets!: string[]; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-result.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-result.dto.ts new file mode 100644 index 00000000000..136ffca5927 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-package-result.dto.ts @@ -0,0 +1,27 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +/** + * Результат `Mutation.publishPackage` (story 9.3.b-pub). + * + * Discriminator `status` — `applied` если ca-admin вернул 200 (on-chain + * `apps::regpkg` улетел и подтверждён), `conflict` если пакет уже + * зарегистрирован (HTTP 409), `failed` для остальных ошибок (включая + * degraded-mode когда APPS_CATALOG_API_KEY не задан). + */ +@ObjectType() +export class PublishPackageResultDTO { + @Field({ + description: + 'Статус: applied (зарегистрирован on-chain), conflict (уже есть), failed (ошибка)', + }) + status!: 'applied' | 'conflict' | 'failed'; + + @Field({ + description: + 'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности', + }) + requestId!: string; + + @Field({ nullable: true, description: 'Человекочитаемое сообщение об ошибке' }) + error?: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts index 86644087d93..ededde0c494 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts @@ -1,8 +1,12 @@ -import { Args, Query, Resolver } from '@nestjs/graphql'; +import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; import { Logger, UseGuards } from '@nestjs/common'; import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard'; +import { RolesGuard } from '~/application/auth/guards/roles.guard'; +import { AuthRoles } from '~/application/auth/decorators/auth.decorator'; import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service'; import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto'; +import { PublishPackageInputDTO } from '../dto/publish-package-input.dto'; +import { PublishPackageResultDTO } from '../dto/publish-package-result.dto'; /** * Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина. @@ -55,4 +59,56 @@ export class AppsCatalogProxyResolver { private buildDescription(publisher: string): string { return `Удалённое расширение от ${publisher} — устанавливается без перезагрузки сервера.`; } + + /** + * Story 9.3.b-pub — стол разработчика публикует пакет в каталоге восхода. + * + * Защита: только chairman кооператива-оператора (`voskhod` на dev). + * Сама подпись on-chain `apps::regpkg` делает ca-admin от имени chairman'а + * восхода — controller только проксирует HTTP-запрос и возвращает + * discriminated outcome (`applied | conflict | failed`). + * + * Multipart upload install.js здесь НЕ делается. Под архитектуру E10 + * расширения публикуются как npm-package + docker-image в Nexus + * отдельной процедурой (`npm publish` / `docker push`) через scoped + * JWT от CA-auth. Эта мутация — только on-chain маркер «такой + * пакет существует». + */ + @Mutation(() => PublishPackageResultDTO, { + name: 'publishPackage', + description: + 'Регистрирует пакет on-chain (action apps::regpkg) через ca-admin. ' + + 'Подписывает chairman кооператива-оператора каталога. Доступно ' + + 'только chairman\'у (стол разработчика).', + }) + @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) + async publishPackage( + @Args('data', { type: () => PublishPackageInputDTO }) + data: PublishPackageInputDTO, + ): Promise { + const outcome = await this.client.registerPackage({ + packageId: data.packageId, + ownerUsername: data.ownerUsername, + compatibleSubnets: data.compatibleSubnets, + }); + if (outcome.status === 'applied') { + this.logger.log( + `publishPackage applied: ${data.packageId} (request ${outcome.requestId})`, + ); + return { status: 'applied', requestId: outcome.requestId }; + } + if (outcome.status === 'conflict') { + return { + status: 'conflict', + requestId: outcome.requestId, + error: outcome.error, + }; + } + return { + status: 'failed', + requestId: outcome.requestId, + error: outcome.error, + }; + } } diff --git a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts index 739998c81a9..00af15345fe 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; -import axios, { type AxiosInstance } from 'axios'; +import axios, { type AxiosInstance, AxiosError } from 'axios'; +import { v4 as uuidv4 } from 'uuid'; import config from '~/config/config'; interface PublicPackageWireFormat { @@ -16,6 +17,19 @@ export interface AppsCatalogPackage { lastActiveVersion: string | null; } +export interface RegisterPackageInput { + packageId: string; + ownerUsername: string; + compatibleSubnets: string[]; + /** Optional. Если не передан — генерируется uuidv4. */ + requestId?: string; +} + +export type RegisterPackageOutcome = + | { status: 'applied'; requestId: string } + | { status: 'conflict'; requestId: string; error: string } + | { status: 'failed'; requestId: string; error: string }; + /** * HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API * ключом из env (APPS_CATALOG_API_KEY). Используется только сервером — @@ -79,6 +93,66 @@ export class AppsCatalogHttpService { * @param scope владелец пакета (`voskhod` из `@voskhod/demo-app`). * @param name short-name пакета (`demo-app`). */ + /** + * Story 9.3.b-pub: первичная регистрация пакета в каталоге. + * + * Прокидывает входной DTO на ca-admin `POST /v1/admin/package`. ca-admin + * сам подписывает on-chain action `apps::regpkg` от имени chairman'а + * кооператива-оператора каталога (voskhod на dev) — поэтому здесь не + * требуется session-key пайщика. + * + * Discriminated outcome (vs. throw): + * - `applied` — HTTP 200, on-chain transaction подтверждён; + * - `conflict` — HTTP 409 (пакет уже зарегистрирован или дубликат + * `request_id`); resolver мапит на `status: 'conflict'`; + * - `failed` — любое прочее (network, 400, 401, degraded-mode без + * APPS_CATALOG_API_KEY); resolver мапит на `status: 'failed'`. + * + * Degraded mode (нет client'а) — возвращает `failed` с явным сообщением: + * mutation в degraded-стенде не делает silent no-op, чтобы UI не + * вводился в заблуждение «пакет опубликован». + */ + async registerPackage( + input: RegisterPackageInput, + ): Promise { + const requestId = input.requestId ?? uuidv4(); + if (!this.client) { + const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы'; + this.logger.warn( + `registerPackage refused (degraded mode): ${input.packageId}`, + ); + return { status: 'failed', requestId, error }; + } + try { + await this.client.post('/v1/admin/package', { + request_id: requestId, + package_id: input.packageId, + owner_username: input.ownerUsername, + compatible_subnets: input.compatibleSubnets, + }); + return { status: 'applied', requestId }; + } catch (err) { + const status = (err as AxiosError).response?.status; + const responseData = (err as AxiosError).response?.data; + const detail = + typeof responseData === 'object' && responseData + ? JSON.stringify(responseData) + : err instanceof Error + ? err.message + : String(err); + if (status === 409) { + this.logger.warn( + `registerPackage conflict для ${input.packageId}: ${detail}`, + ); + return { status: 'conflict', requestId, error: detail }; + } + this.logger.error( + `registerPackage failed для ${input.packageId}: ${detail}`, + ); + return { status: 'failed', requestId, error: detail }; + } + } + async fetchInstallScript(scope: string, name: string): Promise { if (!this.client) return null; try { From f698e39cb8f63702c301053bb8902bb038e7ac2f Mon Sep 17 00:00:00 2001 From: coopops Date: Wed, 3 Jun 2026 19:03:38 +0000 Subject: [PATCH 15/17] =?UTF-8?q?[9.3.b-rel]=20feat(controller):=20Mutatio?= =?UTF-8?q?n.publishRelease=20=E2=80=94=20on-chain=20setrelease=20=D1=87?= =?UTF-8?q?=D0=B5=D1=80=D0=B5=D0=B7=20ca-admin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 9.3.b-rel — мутация выкладки нового релиза ранее зарегистрированного пакета. Прокидывает manifest + версию + tarball_sha256 на ca-admin POST /v1/admin/releases, который сам валидирует manifest Zod-схемой (PackageManifestSchema) и подписывает on-chain apps::setrelease от имени chairman'а кооператива-оператора каталога. Backend (apps-catalog-proxy): - PublishReleaseInputDTO / PublishReleaseResultDTO — GraphQL Input/Object; manifest как GraphQLJSON (произвольная структура валидируется на ca-admin'е). - AppsCatalogHttpService.createRelease(): POST /v1/admin/releases; discriminated outcome — applied (с опциональным transaction_id) | invalidManifest (HTTP 422 INVALID_MANIFEST мапится явно, чтобы UI показывал валидационную ошибку отдельно от network/auth) | failed для прочих. - AppsCatalogProxyResolver.publishRelease(): @AuthRoles(['chairman']). Под архитектуру E10 manifest содержит ссылки на артефакты в Nexus: - coopenomics.backend.image — docker-image (subgraph расширения); - coopenomics.frontend.tarball — npm tarball (desktop-bundle). Сам manifest НЕ валидируется на стороне controller'а — это делает ca-admin (там Zod-схема и она же будет расширена для package.json полей в story 10.7). Frontend (форма Опубликовать релиз) — отдельной story 9.3.b-ui после codegen'а Zeus. Co-Authored-By: Claude Opus 4.7 --- .../dto/publish-release-input.dto.ts | 44 +++++++++++ .../dto/publish-release-result.dto.ts | 40 ++++++++++ .../resolvers/apps-catalog-proxy.resolver.ts | 54 +++++++++++++ .../apps-catalog-http.service.ts | 76 +++++++++++++++++++ 4 files changed, 214 insertions(+) create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-input.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-result.dto.ts diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-input.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-input.dto.ts new file mode 100644 index 00000000000..542b7c3e3b2 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-input.dto.ts @@ -0,0 +1,44 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { GraphQLJSON } from 'graphql-type-json'; + +/** + * Входные данные `Mutation.publishRelease` (story 9.3.b-rel). + * + * Эта мутация регистрирует новый релиз ранее зарегистрированного + * пакета (см. `publishPackage`/9.3.b-pub). Прокидывается на ca-admin + * `POST /v1/admin/releases` (action `apps::setrelease` on-chain), который + * сам подписывает от имени chairman'а кооператива-оператора. + * + * `manifest` под архитектуру E10 содержит ссылки на артефакты в Nexus: + * `coopenomics.backend.image` — docker-image (для subgraph'а + * расширения), `coopenomics.frontend.tarball` — npm tarball (для + * desktop-bundle). Сам manifest валидируется Zod-схемой + * `PackageManifestSchema` на стороне ca-admin (HTTP 422 → resolver + * мапит в `failed`). + */ +@InputType() +export class PublishReleaseInputDTO { + @Field({ + description: + 'Идентификатор пакета в формате @scope/name (должен быть уже зарегистрирован через publishPackage)', + }) + packageId!: string; + + @Field({ description: 'Версия релиза в формате semver, например 1.0.0' }) + version!: string; + + @Field(() => GraphQLJSON, { + description: + 'Package manifest (валидируется Zod-схемой на ca-admin). ' + + 'Содержит coopenomics.backend.image (docker) + coopenomics.frontend.tarball (npm), ' + + 'requires/provides, GraphQL-схему и pricing-параметры.', + }) + manifest!: Record; + + @Field({ + nullable: true, + description: + 'sha256 npm tarball\'а (HEX). Если не передан — ca-admin использует sentinel zero-hash.', + }) + tarballSha256?: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-result.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-result.dto.ts new file mode 100644 index 00000000000..c4d07d23c19 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/publish-release-result.dto.ts @@ -0,0 +1,40 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +/** + * Результат `Mutation.publishRelease` (story 9.3.b-rel). + * + * Discriminator `status`: + * - `applied` — ca-admin вернул 200, on-chain `apps::setrelease` улетел; + * - `invalidManifest` — HTTP 422 INVALID_MANIFEST (Zod-валидация + * manifest'а провалилась на стороне ca-admin); UI должен показать + * `error` как валидационное сообщение; + * - `failed` — прочие ошибки (network, 401, degraded-mode без + * APPS_CATALOG_API_KEY). + */ +@ObjectType() +export class PublishReleaseResultDTO { + @Field({ + description: + 'Статус: applied | invalidManifest | failed', + }) + status!: 'applied' | 'invalidManifest' | 'failed'; + + @Field({ + description: + 'Идентификатор запроса (UUIDv4), который ca-admin использовал для идемпотентности', + }) + requestId!: string; + + @Field({ + nullable: true, + description: + 'Идентификатор blockchain-транзакции (если ca-admin её вернул)', + }) + transactionId?: string; + + @Field({ + nullable: true, + description: 'Человекочитаемое сообщение об ошибке', + }) + error?: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts index ededde0c494..1863db64ec8 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts @@ -7,6 +7,8 @@ import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.s import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto'; import { PublishPackageInputDTO } from '../dto/publish-package-input.dto'; import { PublishPackageResultDTO } from '../dto/publish-package-result.dto'; +import { PublishReleaseInputDTO } from '../dto/publish-release-input.dto'; +import { PublishReleaseResultDTO } from '../dto/publish-release-result.dto'; /** * Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина. @@ -111,4 +113,56 @@ export class AppsCatalogProxyResolver { error: outcome.error, }; } + + /** + * Story 9.3.b-rel — стол разработчика выкладывает новый релиз пакета. + * + * Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`. + * ca-admin валидирует manifest Zod-схемой и подписывает on-chain + * `apps::setrelease` от имени chairman'а кооператива-оператора. Под + * архитектуру E10 manifest должен содержать ссылки на артефакты в + * Nexus (`coopenomics.backend.image` + `coopenomics.frontend.tarball`). + */ + @Mutation(() => PublishReleaseResultDTO, { + name: 'publishRelease', + description: + 'Создаёт новый релиз пакета (action apps::setrelease) через ca-admin. ' + + 'Подписывает chairman кооператива-оператора каталога. Доступно ' + + 'только chairman\'у (стол разработчика).', + }) + @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) + async publishRelease( + @Args('data', { type: () => PublishReleaseInputDTO }) + data: PublishReleaseInputDTO, + ): Promise { + const outcome = await this.client.createRelease({ + packageId: data.packageId, + version: data.version, + manifest: data.manifest, + tarballSha256: data.tarballSha256, + }); + if (outcome.status === 'applied') { + this.logger.log( + `publishRelease applied: ${data.packageId}@${data.version} (request ${outcome.requestId}, tx ${outcome.transactionId ?? '-'})`, + ); + return { + status: 'applied', + requestId: outcome.requestId, + transactionId: outcome.transactionId, + }; + } + if (outcome.status === 'invalidManifest') { + return { + status: 'invalidManifest', + requestId: outcome.requestId, + error: outcome.error, + }; + } + return { + status: 'failed', + requestId: outcome.requestId, + error: outcome.error, + }; + } } diff --git a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts index 00af15345fe..bc51edc1247 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts @@ -30,6 +30,19 @@ export type RegisterPackageOutcome = | { status: 'conflict'; requestId: string; error: string } | { status: 'failed'; requestId: string; error: string }; +export interface CreateReleaseInput { + packageId: string; + version: string; + manifest: Record; + tarballSha256?: string; + requestId?: string; +} + +export type CreateReleaseOutcome = + | { status: 'applied'; requestId: string; transactionId?: string } + | { status: 'invalidManifest'; requestId: string; error: string } + | { status: 'failed'; requestId: string; error: string }; + /** * HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API * ключом из env (APPS_CATALOG_API_KEY). Используется только сервером — @@ -153,6 +166,69 @@ export class AppsCatalogHttpService { } } + /** + * Story 9.3.b-rel: создание нового релиза пакета (action `apps::setrelease`). + * + * Прокидывает manifest + версию на ca-admin `POST /v1/admin/releases`. + * ca-admin сам валидирует manifest Zod-схемой и подписывает on-chain. + * + * Discriminated outcome: + * - `applied` — HTTP 200, on-chain прошёл; в payload может быть + * `transaction_id` (если адаптер не stub'овый); + * - `invalidManifest` — HTTP 422 INVALID_MANIFEST (manifest не прошёл + * Zod-валидацию на стороне ca-admin); `error` содержит детали + * валидации в стабильном формате; + * - `failed` — прочие ошибки (network, 401, 503, degraded-mode). + */ + async createRelease(input: CreateReleaseInput): Promise { + const requestId = input.requestId ?? uuidv4(); + if (!this.client) { + const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы'; + this.logger.warn( + `createRelease refused (degraded mode): ${input.packageId}@${input.version}`, + ); + return { status: 'failed', requestId, error }; + } + try { + const res = await this.client.post<{ + ok: boolean; + transaction_id?: string; + }>('/v1/admin/releases', { + request_id: requestId, + package_id: input.packageId, + version: input.version, + manifest: input.manifest, + ...(input.tarballSha256 + ? { tarball_sha256: input.tarballSha256 } + : {}), + }); + return { + status: 'applied', + requestId, + transactionId: res.data?.transaction_id, + }; + } catch (err) { + const status = (err as AxiosError).response?.status; + const responseData = (err as AxiosError).response?.data; + const detail = + typeof responseData === 'object' && responseData + ? JSON.stringify(responseData) + : err instanceof Error + ? err.message + : String(err); + if (status === 422) { + this.logger.warn( + `createRelease invalidManifest для ${input.packageId}@${input.version}: ${detail}`, + ); + return { status: 'invalidManifest', requestId, error: detail }; + } + this.logger.error( + `createRelease failed для ${input.packageId}@${input.version}: ${detail}`, + ); + return { status: 'failed', requestId, error: detail }; + } + } + async fetchInstallScript(scope: string, name: string): Promise { if (!this.client) return null; try { From 3f1d816ac037d421da4e5eea9fe1ec7287328a79 Mon Sep 17 00:00:00 2001 From: coopops Date: Thu, 4 Jun 2026 05:04:30 +0000 Subject: [PATCH 16/17] =?UTF-8?q?[E10-4]=20feat(gateway-orchestrator):=20i?= =?UTF-8?q?nstall-pipeline=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20DockerRunner?= =?UTF-8?q?/HealthProbe/OciToken=20=D0=BF=D0=BE=D1=80=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 10.4 — pipeline установки subgraph-расширения: 1. (опц.) OCI token у CA-auth → docker pull образа; 2. (опц.) docker compose up сервиса; 3. healthcheck poll по url subgraph'а до 200 OK; 4. upsert в subgraph_registry (Apollo Gateway за ≤10с подхватит); 5. на failure — composeDown rollback + discriminated outcome {applied|failed}. InstallController стал тонкой обёрткой над InstallOrchestratorService. Внешние зависимости (docker shell / fetch / CA-auth) изолированы за портами; тесты (7/7 green) гоняют сценарий через ин-мемори моки без реального docker daemon'а и сети. Что ОСТАЁТСЯ для отдельной ветки под этим зонтиком: - wiring approve→install REST из mono controller (mutation approvePackage); - desktop notification после успешного install'а; - E2E на реальном compose + chatcoop subgraph (Story 10.8). --- .../src/orchestrator/install.controller.ts | 40 --- .../src/orchestrator/orchestrator.module.ts | 10 - components/orchestrator/jest.config.js | 15 + .../package.json | 10 +- .../src/app.module.ts | 0 .../src/config/app-config.ts | 4 +- .../src/gateway/gateway.module.ts | 2 +- .../src/gateway/subgraph-registry.entity.ts | 0 .../src/gateway/subgraph-registry.service.ts | 0 .../src/main.ts | 4 +- .../orchestrator/composition.controller.ts | 0 .../src/orchestrator/docker-runner.impl.ts | 75 +++++ .../src/orchestrator/health-probe.impl.ts | 60 ++++ .../install-orchestrator.service.spec.ts | 215 ++++++++++++++ .../install-orchestrator.service.ts | 175 ++++++++++++ .../src/orchestrator/install.controller.ts | 42 +++ .../src/orchestrator/oci-token-client.impl.ts | 45 +++ .../src/orchestrator/orchestrator.module.ts | 31 ++ .../orchestrator/src/orchestrator/ports.ts | 60 ++++ .../tsconfig.json | 6 +- pnpm-lock.yaml | 265 +++++++++++------- 21 files changed, 897 insertions(+), 162 deletions(-) delete mode 100644 components/gateway-orchestrator/src/orchestrator/install.controller.ts delete mode 100644 components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts create mode 100644 components/orchestrator/jest.config.js rename components/{gateway-orchestrator => orchestrator}/package.json (77%) rename components/{gateway-orchestrator => orchestrator}/src/app.module.ts (100%) rename components/{gateway-orchestrator => orchestrator}/src/config/app-config.ts (93%) rename components/{gateway-orchestrator => orchestrator}/src/gateway/gateway.module.ts (98%) rename components/{gateway-orchestrator => orchestrator}/src/gateway/subgraph-registry.entity.ts (100%) rename components/{gateway-orchestrator => orchestrator}/src/gateway/subgraph-registry.service.ts (100%) rename components/{gateway-orchestrator => orchestrator}/src/main.ts (93%) rename components/{gateway-orchestrator => orchestrator}/src/orchestrator/composition.controller.ts (100%) create mode 100644 components/orchestrator/src/orchestrator/docker-runner.impl.ts create mode 100644 components/orchestrator/src/orchestrator/health-probe.impl.ts create mode 100644 components/orchestrator/src/orchestrator/install-orchestrator.service.spec.ts create mode 100644 components/orchestrator/src/orchestrator/install-orchestrator.service.ts create mode 100644 components/orchestrator/src/orchestrator/install.controller.ts create mode 100644 components/orchestrator/src/orchestrator/oci-token-client.impl.ts create mode 100644 components/orchestrator/src/orchestrator/orchestrator.module.ts create mode 100644 components/orchestrator/src/orchestrator/ports.ts rename components/{gateway-orchestrator => orchestrator}/tsconfig.json (70%) diff --git a/components/gateway-orchestrator/src/orchestrator/install.controller.ts b/components/gateway-orchestrator/src/orchestrator/install.controller.ts deleted file mode 100644 index d341147597e..00000000000 --- a/components/gateway-orchestrator/src/orchestrator/install.controller.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Body, Controller, Delete, Param, Post } from '@nestjs/common'; -import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; - -export interface InstallExtensionInput { - packageId: string; - version: string; - url: string; -} - -/** - * REST API для управления жизненным циклом расширений. - * - * **Story 10.3 (это PR):** только регистр — реальный docker compose pull/up - * и healthcheck приедут в Story 10.4 (Install pipeline integration). - * Сейчас endpoint просто пишет subgraph URL в registry; orchestrator-tests - * могут руками поднять контейнер и POST'нуть сюда. - * - * **Story 10.4:** controller станет тонкой обёрткой, реальную работу - * делает InstallOrchestratorService — pull image через CA-auth JWT, - * docker compose up -d, healthcheck опрос, register. - */ -@Controller('v1/internal/extensions') -export class InstallController { - constructor(private readonly registry: SubgraphRegistryService) {} - - @Post('install') - async install(@Body() input: InstallExtensionInput) { - if (!input.packageId || !input.version || !input.url) { - return { ok: false, error: 'packageId, version, url обязательны' }; - } - await this.registry.upsert(input.packageId, input.version, input.url); - return { ok: true, registered: input.packageId, pollWithinMs: 10000 }; - } - - @Delete('uninstall/:packageId') - async uninstall(@Param('packageId') packageId: string) { - await this.registry.deactivate(packageId); - return { ok: true, deactivated: packageId }; - } -} diff --git a/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts b/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts deleted file mode 100644 index e0edbe2d5dc..00000000000 --- a/components/gateway-orchestrator/src/orchestrator/orchestrator.module.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Module } from '@nestjs/common'; -import { GatewayModule } from '../gateway/gateway.module'; -import { CompositionController } from './composition.controller'; -import { InstallController } from './install.controller'; - -@Module({ - imports: [GatewayModule], - controllers: [CompositionController, InstallController], -}) -export class OrchestratorModule {} diff --git a/components/orchestrator/jest.config.js b/components/orchestrator/jest.config.js new file mode 100644 index 00000000000..40d4a49eeeb --- /dev/null +++ b/components/orchestrator/jest.config.js @@ -0,0 +1,15 @@ +/** + * @fileoverview Jest конфиг для orchestrator. + * + * Юнит-тесты лежат рядом с код-файлами (`*.spec.ts`). Все внешние + * зависимости (docker / http / postgres) подменяются ин-мемори + * фейками — testEnvironment 'node', без shell. + */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + rootDir: 'src', + testRegex: '.*\\.spec\\.ts$', + collectCoverageFrom: ['**/*.ts', '!**/*.spec.ts', '!main.ts'], + moduleFileExtensions: ['ts', 'js', 'json'], +}; diff --git a/components/gateway-orchestrator/package.json b/components/orchestrator/package.json similarity index 77% rename from components/gateway-orchestrator/package.json rename to components/orchestrator/package.json index cd67bfce392..7e805e5c934 100644 --- a/components/gateway-orchestrator/package.json +++ b/components/orchestrator/package.json @@ -1,13 +1,14 @@ { - "name": "@coopenomics/gateway-orchestrator", + "name": "@coopenomics/orchestrator", "version": "0.1.0", - "description": "Per-tenant сервис: Apollo Federation Gateway + orchestrator расширений (docker compose + on-chain auto-update). Epic 10 Story 10.3.", + "description": "Per-tenant сервис: Apollo Federation Gateway + orchestrator расширений (docker compose + on-chain auto-update). Epic 10.", "private": true, "main": "dist/main.js", "scripts": { "build": "tsc -p tsconfig.json", "start": "node dist/main.js", - "dev": "ts-node-dev --respawn src/main.ts" + "dev": "ts-node-dev --respawn src/main.ts", + "test": "jest --config jest.config.js" }, "dependencies": { "@apollo/gateway": "^2.9.0", @@ -24,7 +25,10 @@ "typeorm": "^0.3.20" }, "devDependencies": { + "@types/jest": "^29.5.0", "@types/node": "^20.0.0", + "jest": "^29.7.0", + "ts-jest": "^29.1.0", "ts-node-dev": "^2.0.0", "typescript": "^5.4.0" } diff --git a/components/gateway-orchestrator/src/app.module.ts b/components/orchestrator/src/app.module.ts similarity index 100% rename from components/gateway-orchestrator/src/app.module.ts rename to components/orchestrator/src/app.module.ts diff --git a/components/gateway-orchestrator/src/config/app-config.ts b/components/orchestrator/src/config/app-config.ts similarity index 93% rename from components/gateway-orchestrator/src/config/app-config.ts rename to components/orchestrator/src/config/app-config.ts index 757fa653d3f..1efb3daf51c 100644 --- a/components/gateway-orchestrator/src/config/app-config.ts +++ b/components/orchestrator/src/config/app-config.ts @@ -1,5 +1,5 @@ /** - * ENV-конфигурация gateway-orchestrator (per-tenant). + * ENV-конфигурация orchestrator (per-tenant). * * Все обязательные поля fail-fast валидируются на bootstrap. Если orchestrator * стартует и не находит coopback subgraph URL — он не сможет ничего @@ -16,7 +16,7 @@ export interface AppConfig { export class AppConfigError extends Error { constructor(msg: string) { - super(`[gateway-orchestrator] ${msg}`); + super(`[orchestrator] ${msg}`); this.name = 'AppConfigError'; } } diff --git a/components/gateway-orchestrator/src/gateway/gateway.module.ts b/components/orchestrator/src/gateway/gateway.module.ts similarity index 98% rename from components/gateway-orchestrator/src/gateway/gateway.module.ts rename to components/orchestrator/src/gateway/gateway.module.ts index 8fc8bb16094..9d0fde99c61 100644 --- a/components/gateway-orchestrator/src/gateway/gateway.module.ts +++ b/components/orchestrator/src/gateway/gateway.module.ts @@ -30,7 +30,7 @@ import { SubgraphRegistryService } from './subgraph-registry.service'; // и автоматически re-compose'ит если у subgraph'а изменилась схема. // // Но: добавление НОВОГО subgraph'а (POST /v1/internal/extensions/install) - // потребует рестарта контейнера gateway-orchestrator. На MVP это + // потребует рестарта контейнера orchestrator. На MVP это // ОК — gateway маленький, warmup ~5 сек, делается orchestrator-pipeline // при первом install'е приложения (Story 10.4). // diff --git a/components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts b/components/orchestrator/src/gateway/subgraph-registry.entity.ts similarity index 100% rename from components/gateway-orchestrator/src/gateway/subgraph-registry.entity.ts rename to components/orchestrator/src/gateway/subgraph-registry.entity.ts diff --git a/components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts b/components/orchestrator/src/gateway/subgraph-registry.service.ts similarity index 100% rename from components/gateway-orchestrator/src/gateway/subgraph-registry.service.ts rename to components/orchestrator/src/gateway/subgraph-registry.service.ts diff --git a/components/gateway-orchestrator/src/main.ts b/components/orchestrator/src/main.ts similarity index 93% rename from components/gateway-orchestrator/src/main.ts rename to components/orchestrator/src/main.ts index 9fd327b63a2..9f6e555e16c 100644 --- a/components/gateway-orchestrator/src/main.ts +++ b/components/orchestrator/src/main.ts @@ -42,13 +42,13 @@ async function bootstrap() { await app.listen(cfg.port); // eslint-disable-next-line no-console console.log( - `[gateway-orchestrator] up at :${cfg.port} ` + + `[orchestrator] up at :${cfg.port} ` + `(coopname=${cfg.coopname}, core=${cfg.coreSubgraphUrl})`, ); } bootstrap().catch((err) => { // eslint-disable-next-line no-console - console.error('[gateway-orchestrator] failed to bootstrap:', err); + console.error('[orchestrator] failed to bootstrap:', err); process.exit(1); }); diff --git a/components/gateway-orchestrator/src/orchestrator/composition.controller.ts b/components/orchestrator/src/orchestrator/composition.controller.ts similarity index 100% rename from components/gateway-orchestrator/src/orchestrator/composition.controller.ts rename to components/orchestrator/src/orchestrator/composition.controller.ts diff --git a/components/orchestrator/src/orchestrator/docker-runner.impl.ts b/components/orchestrator/src/orchestrator/docker-runner.impl.ts new file mode 100644 index 00000000000..ee13dcf1dce --- /dev/null +++ b/components/orchestrator/src/orchestrator/docker-runner.impl.ts @@ -0,0 +1,75 @@ +/** + * @fileoverview Shell-out импл `DockerRunnerPort`. Зовёт `docker` CLI + * через `child_process.execFile` (без shell-инъекции через `exec`). + * + * Зачем CLI, а не dockerode/socket: orchestrator деплоится в + * tenant-окружении, где доступ к docker.sock даётся compose-mount'ом; + * CLI работает с тем же сокетом и не тянет лишних зависимостей. + * + * Все вызовы получают аргументы массивом — нет конкатенации с + * пользовательским вводом, командной инъекции нет. + */ +import { Injectable, Logger } from '@nestjs/common'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { DockerRunnerPort } from './ports'; + +const execFileAsync = promisify(execFile); +const DOCKER_TIMEOUT_MS = 120_000; + +@Injectable() +export class ShellDockerRunner implements DockerRunnerPort { + private readonly logger = new Logger(ShellDockerRunner.name); + + async pullImage(opts: { imageRef: string; bearerToken: string }): Promise { + /* + * docker pull использует уже логиненный креденшал-helper; токен + * прокидывается через переменную окружения DOCKER_AUTH_TOKEN, + * которую читает per-registry credential helper (см. deploy/). + * В MWP здесь только pull — login лежит на инфраструктурном + * скрипте при bootstrap'е orchestrator'а. + */ + this.logger.log(`docker pull ${opts.imageRef}`); + await this.run('docker', ['pull', opts.imageRef], { + DOCKER_AUTH_TOKEN: opts.bearerToken, + }); + } + + async composeUp(opts: { composeFile: string; serviceName: string }): Promise { + this.logger.log(`docker compose -f ${opts.composeFile} up -d ${opts.serviceName}`); + await this.run('docker', [ + 'compose', + '-f', + opts.composeFile, + 'up', + '-d', + opts.serviceName, + ]); + } + + async composeDown(opts: { composeFile: string; serviceName: string }): Promise { + this.logger.log(`docker compose -f ${opts.composeFile} rm -fsv ${opts.serviceName}`); + await this.run('docker', [ + 'compose', + '-f', + opts.composeFile, + 'rm', + '-fsv', + opts.serviceName, + ]); + } + + private async run(cmd: string, args: string[], extraEnv: NodeJS.ProcessEnv = {}): Promise { + try { + const { stdout, stderr } = await execFileAsync(cmd, args, { + timeout: DOCKER_TIMEOUT_MS, + env: { ...process.env, ...extraEnv }, + }); + if (stdout) this.logger.debug(stdout.trim()); + if (stderr) this.logger.debug(stderr.trim()); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`docker ${args[0] ?? ''} failed: ${msg}`); + } + } +} diff --git a/components/orchestrator/src/orchestrator/health-probe.impl.ts b/components/orchestrator/src/orchestrator/health-probe.impl.ts new file mode 100644 index 00000000000..50316eff588 --- /dev/null +++ b/components/orchestrator/src/orchestrator/health-probe.impl.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview HTTP-импл `HealthProbePort`. Поллит target URL до 200 OK + * или таймаута. + * + * Без зависимостей: используем встроенный `fetch` (Node 20+). + * + * Между попытками — backoff 500ms. На каждой попытке timeout запроса + * 2 сек, чтоб не залипать на slow-DNS / hanging connect'е. + */ +import { Injectable } from '@nestjs/common'; +import { HealthOutcome, HealthProbePort } from './ports'; + +const POLL_INTERVAL_MS = 500; +const REQUEST_TIMEOUT_MS = 2_000; + +@Injectable() +export class HttpHealthProbe implements HealthProbePort { + async waitUntilHealthy(opts: { url: string; timeoutMs: number }): Promise { + const start = Date.now(); + let lastError: string | undefined; + let lastReason: 'timeout' | 'badStatus' | 'transportError' = 'timeout'; + + while (Date.now() - start < opts.timeoutMs) { + const probe = await this.singleProbe(opts.url); + if (probe.ok) { + return { ok: true, elapsedMs: Date.now() - start }; + } + lastReason = probe.reason; + lastError = probe.error; + await sleep(POLL_INTERVAL_MS); + } + return { ok: false, reason: lastReason, lastError }; + } + + private async singleProbe(url: string): Promise< + { ok: true } | { ok: false; reason: 'badStatus' | 'transportError'; error: string } + > { + try { + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(url, { method: 'GET', signal: ctrl.signal }); + if (resp.status >= 200 && resp.status < 300) { + return { ok: true }; + } + return { ok: false, reason: 'badStatus', error: `HTTP ${resp.status}` }; + } finally { + clearTimeout(timer); + } + } catch (e) { + return { + ok: false, + reason: 'transportError', + error: e instanceof Error ? e.message : String(e), + }; + } + } +} + +const sleep = (ms: number): Promise => new Promise((res) => setTimeout(res, ms)); diff --git a/components/orchestrator/src/orchestrator/install-orchestrator.service.spec.ts b/components/orchestrator/src/orchestrator/install-orchestrator.service.spec.ts new file mode 100644 index 00000000000..49a9284a368 --- /dev/null +++ b/components/orchestrator/src/orchestrator/install-orchestrator.service.spec.ts @@ -0,0 +1,215 @@ +/** + * @fileoverview Юнит-тесты install-pipeline'а. Все внешние зависимости + * (docker/health/OCI) подменены ин-мемори моками; SubgraphRegistryService + * подменяется fake'ом с in-memory map'ой. + * + * Что покрываем (Story 10.4): + * 1. happy path с imageRef: OCI token → docker pull → compose up → healthy → registry. + * 2. happy path без imageRef (core-subgraph-style): только healthcheck + registry. + * 3. healthcheck timeout → failed + rollback composeDown. + * 4. OCI token error без jwt → failed без shell-вызовов. + * 5. docker pull error → failed на этапе docker-pull. + * 6. registry write error → failed + rollback composeDown. + */ +import { + InstallOrchestratorService, + InstallExtensionInput, +} from './install-orchestrator.service'; +import { + DockerRunnerPort, + HealthOutcome, + HealthProbePort, + OciTokenClientPort, +} from './ports'; +import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; + +class FakeDockerRunner implements DockerRunnerPort { + pulls: Array<{ imageRef: string; bearerToken: string }> = []; + ups: Array<{ composeFile: string; serviceName: string }> = []; + downs: Array<{ composeFile: string; serviceName: string }> = []; + pullError?: Error; + upError?: Error; + async pullImage(opts: { imageRef: string; bearerToken: string }): Promise { + this.pulls.push(opts); + if (this.pullError) throw this.pullError; + } + async composeUp(opts: { composeFile: string; serviceName: string }): Promise { + this.ups.push(opts); + if (this.upError) throw this.upError; + } + async composeDown(opts: { composeFile: string; serviceName: string }): Promise { + this.downs.push(opts); + } +} + +class FakeHealthProbe implements HealthProbePort { + outcome: HealthOutcome = { ok: true, elapsedMs: 100 }; + calls: Array<{ url: string; timeoutMs: number }> = []; + async waitUntilHealthy(opts: { url: string; timeoutMs: number }): Promise { + this.calls.push(opts); + return this.outcome; + } +} + +class FakeOciTokenClient implements OciTokenClientPort { + calls: Array<{ packageId: string; jwt: string }> = []; + token = 'fake-bearer-token'; + error?: Error; + async issueToken(opts: { packageId: string; jwt: string }): Promise { + this.calls.push(opts); + if (this.error) throw this.error; + return this.token; + } +} + +class FakeRegistryService { + upserts: Array<{ packageId: string; version: string; url: string }> = []; + healthSets: Array<{ packageId: string; healthStatus: string }> = []; + upsertError?: Error; + async upsert(packageId: string, version: string, url: string): Promise { + this.upserts.push({ packageId, version, url }); + if (this.upsertError) throw this.upsertError; + } + async setHealthStatus(packageId: string, healthStatus: string): Promise { + this.healthSets.push({ packageId, healthStatus }); + } +} + +const buildHarness = (): { + service: InstallOrchestratorService; + docker: FakeDockerRunner; + health: FakeHealthProbe; + oci: FakeOciTokenClient; + registry: FakeRegistryService; +} => { + const docker = new FakeDockerRunner(); + const health = new FakeHealthProbe(); + const oci = new FakeOciTokenClient(); + const registry = new FakeRegistryService(); + const service = new InstallOrchestratorService( + docker, + health, + oci, + registry as unknown as SubgraphRegistryService, + ); + return { service, docker, health, oci, registry }; +}; + +const baseInput: InstallExtensionInput = { + packageId: '@coopenomics/chatcoop', + version: '1.0.0', + url: 'http://chatcoop:3000/graphql', +}; + +describe('InstallOrchestratorService', () => { + it('happy path c imageRef: OCI → pull → up → healthy → registry', async () => { + const h = buildHarness(); + const result = await h.service.install({ + ...baseInput, + imageRef: 'registry.local/coopenomics/chatcoop:1.0.0', + composeFile: '/etc/orchestrator/extensions.yaml', + composeService: 'chatcoop', + cooperativeJwt: 'jwt-of-voskhod', + }); + + expect(result.status).toBe('applied'); + if (result.status !== 'applied') return; // narrowing + expect(result.packageId).toBe(baseInput.packageId); + expect(result.healthAfterMs).toBe(100); + + expect(h.oci.calls).toEqual([{ packageId: baseInput.packageId, jwt: 'jwt-of-voskhod' }]); + expect(h.docker.pulls).toEqual([ + { imageRef: 'registry.local/coopenomics/chatcoop:1.0.0', bearerToken: 'fake-bearer-token' }, + ]); + expect(h.docker.ups).toEqual([ + { composeFile: '/etc/orchestrator/extensions.yaml', serviceName: 'chatcoop' }, + ]); + expect(h.health.calls).toEqual([ + { url: baseInput.url, timeoutMs: 60_000 }, + ]); + expect(h.registry.upserts).toEqual([ + { packageId: baseInput.packageId, version: baseInput.version, url: baseInput.url }, + ]); + expect(h.registry.healthSets).toEqual([ + { packageId: baseInput.packageId, healthStatus: 'ok' }, + ]); + expect(h.docker.downs).toEqual([]); + }); + + it('happy path без imageRef — только healthcheck и registry (core-style)', async () => { + const h = buildHarness(); + const result = await h.service.install(baseInput); + expect(result.status).toBe('applied'); + expect(h.oci.calls).toEqual([]); + expect(h.docker.pulls).toEqual([]); + expect(h.docker.ups).toEqual([]); + expect(h.health.calls.length).toBe(1); + expect(h.registry.upserts.length).toBe(1); + }); + + it('healthcheck timeout → failed + rollback composeDown', async () => { + const h = buildHarness(); + h.health.outcome = { ok: false, reason: 'timeout', lastError: 'no response' }; + const result = await h.service.install({ + ...baseInput, + composeFile: '/etc/orchestrator/extensions.yaml', + composeService: 'chatcoop', + }); + expect(result.status).toBe('failed'); + if (result.status !== 'failed') return; + expect(result.reason).toBe('healthcheck'); + expect(result.error).toContain('timeout'); + expect(h.docker.downs).toEqual([ + { composeFile: '/etc/orchestrator/extensions.yaml', serviceName: 'chatcoop' }, + ]); + expect(h.registry.upserts).toEqual([]); + }); + + it('imageRef без cooperativeJwt → failed: oci-token, без shell-вызовов', async () => { + const h = buildHarness(); + const result = await h.service.install({ ...baseInput, imageRef: 'reg/img:1.0.0' }); + expect(result.status).toBe('failed'); + if (result.status !== 'failed') return; + expect(result.reason).toBe('oci-token'); + expect(h.oci.calls).toEqual([]); + expect(h.docker.pulls).toEqual([]); + }); + + it('docker pull error → failed: docker-pull', async () => { + const h = buildHarness(); + h.docker.pullError = new Error('docker pull failed: unauthorized'); + const result = await h.service.install({ + ...baseInput, + imageRef: 'reg/img:1.0.0', + cooperativeJwt: 'jwt', + }); + expect(result.status).toBe('failed'); + if (result.status !== 'failed') return; + expect(result.reason).toBe('docker-pull'); + expect(result.error).toContain('docker pull failed'); + expect(h.health.calls).toEqual([]); + expect(h.registry.upserts).toEqual([]); + }); + + it('registry upsert error → failed: registry-write + rollback composeDown', async () => { + const h = buildHarness(); + h.registry.upsertError = new Error('postgres unavailable'); + const result = await h.service.install({ + ...baseInput, + composeFile: '/etc/orchestrator/extensions.yaml', + composeService: 'chatcoop', + }); + expect(result.status).toBe('failed'); + if (result.status !== 'failed') return; + expect(result.reason).toBe('registry-write'); + expect(h.docker.downs).toEqual([ + { composeFile: '/etc/orchestrator/extensions.yaml', serviceName: 'chatcoop' }, + ]); + }); + + it('healthcheckTimeoutMs из input пробрасывается в HealthProbe', async () => { + const h = buildHarness(); + await h.service.install({ ...baseInput, healthcheckTimeoutMs: 5_000 }); + expect(h.health.calls).toEqual([{ url: baseInput.url, timeoutMs: 5_000 }]); + }); +}); diff --git a/components/orchestrator/src/orchestrator/install-orchestrator.service.ts b/components/orchestrator/src/orchestrator/install-orchestrator.service.ts new file mode 100644 index 00000000000..07202fba141 --- /dev/null +++ b/components/orchestrator/src/orchestrator/install-orchestrator.service.ts @@ -0,0 +1,175 @@ +/** + * @fileoverview Сценарий установки subgraph-расширения (Story 10.4). + * + * Шаги pipeline'а: + * 1. (опционально) issue OCI token у CA-auth и docker pull образа; + * 2. (опционально) docker compose up сервиса в проекте orchestrator'а; + * 3. healthcheck по URL — ждём пока subgraph внутри контейнера + * прогреется и начнёт отвечать 200; + * 4. upsert записи в `subgraph_registry` (active=true, healthStatus='ok'); + * 5. Apollo Gateway за ≤10 сек подтянет новую запись polling'ом и + * добавит subgraph в supergraph — клиенты увидят его без рестарта. + * + * Discriminated outcome `applied | failed` — клиент должен switch'ить + * на `status`. На любом failure делаем rollback (compose down + deactivate + * в registry), чтобы не оставить «висящий» сервис, в который никто не + * маршрутизируется. + * + * Сервис намеренно не знает про docker socket / OCI / HTTP напрямую — + * это всё за портами {@link DockerRunnerPort}, {@link HealthProbePort}, + * {@link OciTokenClientPort}. Тесты гоняют сценарий через моки портов + * без реального docker daemon'а. + */ +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; +import { + DOCKER_RUNNER, + DockerRunnerPort, + HEALTH_PROBE, + HealthProbePort, + OCI_TOKEN_CLIENT, + OciTokenClientPort, +} from './ports'; + +export interface InstallExtensionInput { + /** `@scope/name` пакета в каталоге. */ + packageId: string; + /** Семантическая версия релиза. */ + version: string; + /** + * Внутренний URL subgraph'а внутри docker-сети + * (например `http://chatcoop:3000/graphql`). Используется и для + * healthcheck'а, и как `url` в registry. + */ + url: string; + /** + * OCI-ссылка образа `registry.host/scope/name:version`. Если задана — + * orchestrator делает docker pull. Если пусто — считается, что + * контейнер уже поднят сторонним образом (например core-subgraph). + */ + imageRef?: string; + /** + * Имя сервиса в docker-compose файле orchestrator'а. Если задано + * вместе с `composeFile` — orchestrator делает `compose up -d `. + */ + composeService?: string; + /** + * Путь к docker-compose файлу, описывающему extension-сервисы. + * Опциональный — если расширения деплоятся другим механизмом + * (k8s, swarm) — orchestrator только healthcheck'ает. + */ + composeFile?: string; + /** + * JWT кооператива для запроса OCI-токена у CA-auth. Нужен только + * если задан `imageRef`. + */ + cooperativeJwt?: string; + /** Healthcheck timeout. По умолчанию 60 сек — Nest-приложение успевает прогреться. */ + healthcheckTimeoutMs?: number; +} + +export type InstallOutcome = + | { status: 'applied'; packageId: string; healthAfterMs: number } + | { status: 'failed'; packageId: string; reason: InstallFailureReason; error: string }; + +export type InstallFailureReason = + | 'oci-token' + | 'docker-pull' + | 'compose-up' + | 'healthcheck' + | 'registry-write'; + +@Injectable() +export class InstallOrchestratorService { + private readonly logger = new Logger(InstallOrchestratorService.name); + + constructor( + @Inject(DOCKER_RUNNER) private readonly docker: DockerRunnerPort, + @Inject(HEALTH_PROBE) private readonly health: HealthProbePort, + @Inject(OCI_TOKEN_CLIENT) private readonly oci: OciTokenClientPort, + private readonly registry: SubgraphRegistryService, + ) {} + + async install(input: InstallExtensionInput): Promise { + const timeoutMs = input.healthcheckTimeoutMs ?? 60_000; + this.logger.log(`install start: ${input.packageId}@${input.version} → ${input.url}`); + + // 1. (опц.) docker pull через CA-auth OCI token. + if (input.imageRef !== undefined) { + if (input.cooperativeJwt === undefined) { + return this.failed(input.packageId, 'oci-token', 'cooperativeJwt обязателен при заданном imageRef'); + } + try { + const bearerToken = await this.oci.issueToken({ + packageId: input.packageId, + jwt: input.cooperativeJwt, + }); + await this.docker.pullImage({ imageRef: input.imageRef, bearerToken }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + const reason: InstallFailureReason = msg.includes('docker') ? 'docker-pull' : 'oci-token'; + return this.failed(input.packageId, reason, msg); + } + } + + // 2. (опц.) docker compose up сервиса. + if (input.composeService !== undefined && input.composeFile !== undefined) { + try { + await this.docker.composeUp({ + composeFile: input.composeFile, + serviceName: input.composeService, + }); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + return this.failed(input.packageId, 'compose-up', msg); + } + } + + // 3. healthcheck poll по URL subgraph'а. + const health = await this.health.waitUntilHealthy({ url: input.url, timeoutMs }); + if (!health.ok) { + await this.rollbackComposeIfAny(input); + return this.failed( + input.packageId, + 'healthcheck', + `${health.reason}${health.lastError ? `: ${health.lastError}` : ''}`, + ); + } + + // 4. upsert в subgraph_registry — Apollo Gateway за ≤10 сек подхватит. + try { + await this.registry.upsert(input.packageId, input.version, input.url); + await this.registry.setHealthStatus(input.packageId, 'ok'); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + await this.rollbackComposeIfAny(input); + return this.failed(input.packageId, 'registry-write', msg); + } + + this.logger.log(`install applied: ${input.packageId}@${input.version} (health ${health.elapsedMs}ms)`); + return { status: 'applied', packageId: input.packageId, healthAfterMs: health.elapsedMs }; + } + + private async rollbackComposeIfAny(input: InstallExtensionInput): Promise { + if (input.composeService === undefined || input.composeFile === undefined) return; + try { + await this.docker.composeDown({ + composeFile: input.composeFile, + serviceName: input.composeService, + }); + } catch (e) { + this.logger.error( + `rollback composeDown failed for ${input.packageId}: ${e instanceof Error ? e.message : String(e)}`, + ); + } + } + + private failed( + packageId: string, + reason: InstallFailureReason, + error: string, + ): InstallOutcome { + this.logger.error(`install failed (${reason}) ${packageId}: ${error}`); + return { status: 'failed', packageId, reason, error }; + } +} diff --git a/components/orchestrator/src/orchestrator/install.controller.ts b/components/orchestrator/src/orchestrator/install.controller.ts new file mode 100644 index 00000000000..8f63d14a8eb --- /dev/null +++ b/components/orchestrator/src/orchestrator/install.controller.ts @@ -0,0 +1,42 @@ +/** + * @fileoverview REST API установки/снятия subgraph-расширений. + * + * Story 10.4: тонкая обёртка над {@link InstallOrchestratorService}. + * Реальный pipeline (OCI pull → compose up → healthcheck → registry) + * живёт в сервисе. Сюда приходят запросы из mono controller'а + * (mutation approvePackage → orchestrator REST install) и из E2E. + * + * `install` возвращает discriminated outcome (`applied | failed`); + * клиент должен switch'ить на `status` поле. + */ +import { Body, Controller, Delete, Param, Post } from '@nestjs/common'; +import { + InstallOrchestratorService, + InstallExtensionInput, + InstallOutcome, +} from './install-orchestrator.service'; +import { SubgraphRegistryService } from '../gateway/subgraph-registry.service'; + +interface UninstallResult { + ok: boolean; + deactivated: string; +} + +@Controller('v1/internal/extensions') +export class InstallController { + constructor( + private readonly orchestrator: InstallOrchestratorService, + private readonly registry: SubgraphRegistryService, + ) {} + + @Post('install') + async install(@Body() input: InstallExtensionInput): Promise { + return this.orchestrator.install(input); + } + + @Delete('uninstall/:packageId') + async uninstall(@Param('packageId') packageId: string): Promise { + await this.registry.deactivate(packageId); + return { ok: true, deactivated: packageId }; + } +} diff --git a/components/orchestrator/src/orchestrator/oci-token-client.impl.ts b/components/orchestrator/src/orchestrator/oci-token-client.impl.ts new file mode 100644 index 00000000000..4f0ed3faf1c --- /dev/null +++ b/components/orchestrator/src/orchestrator/oci-token-client.impl.ts @@ -0,0 +1,45 @@ +/** + * @fileoverview HTTP-импл `OciTokenClientPort`. Идёт за bearer-токеном + * в CA-auth по протоколу Docker Registry v2 token endpoint (Story 10.6). + * + * Эндпоинт: `GET {CA_AUTH_BASE}/v2/token?service=registry&scope=repository::pull` + * Авторизация: `Authorization: Bearer ` — это JWT + * кооператива, выданный CA-auth при онбординге; CA-auth маппит + * coopname → discharge OCI token со scope'ом pull. + * + * Ответ: `{ token: "..." }` — короткоживущий (15 мин); docker pull + * подставляет его как Bearer на каждом запросе к registry. + */ +import { Injectable } from '@nestjs/common'; +import { OciTokenClientPort } from './ports'; + +const REQUEST_TIMEOUT_MS = 10_000; + +@Injectable() +export class CaAuthOciTokenClient implements OciTokenClientPort { + constructor(private readonly caAuthBase: string) {} + + async issueToken(opts: { packageId: string; jwt: string }): Promise { + const scope = `repository:${opts.packageId}:pull`; + const url = `${this.caAuthBase}/v2/token?service=registry&scope=${encodeURIComponent(scope)}`; + const ctrl = new AbortController(); + const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS); + try { + const resp = await fetch(url, { + method: 'GET', + headers: { Authorization: `Bearer ${opts.jwt}` }, + signal: ctrl.signal, + }); + if (!resp.ok) { + throw new Error(`CA-auth /v2/token → HTTP ${resp.status}`); + } + const body = (await resp.json()) as { token?: string }; + if (typeof body.token !== 'string' || body.token.length === 0) { + throw new Error('CA-auth /v2/token: пустой token в ответе'); + } + return body.token; + } finally { + clearTimeout(timer); + } + } +} diff --git a/components/orchestrator/src/orchestrator/orchestrator.module.ts b/components/orchestrator/src/orchestrator/orchestrator.module.ts new file mode 100644 index 00000000000..7954f0a2fda --- /dev/null +++ b/components/orchestrator/src/orchestrator/orchestrator.module.ts @@ -0,0 +1,31 @@ +/** + * @fileoverview Nest-модуль orchestrator'а: REST-эндпоинты, install-pipeline, + * shell-out импленты Docker/Health/OCI портов. + * + * Story 10.4: добавлены InstallOrchestratorService + порты + shell-out + * импленты. CA-auth base URL берётся из ENV (CA_AUTH_BASE_URL). + */ +import { Module } from '@nestjs/common'; +import { GatewayModule } from '../gateway/gateway.module'; +import { CompositionController } from './composition.controller'; +import { InstallController } from './install.controller'; +import { InstallOrchestratorService } from './install-orchestrator.service'; +import { DOCKER_RUNNER, HEALTH_PROBE, OCI_TOKEN_CLIENT } from './ports'; +import { ShellDockerRunner } from './docker-runner.impl'; +import { HttpHealthProbe } from './health-probe.impl'; +import { CaAuthOciTokenClient } from './oci-token-client.impl'; + +@Module({ + imports: [GatewayModule], + controllers: [CompositionController, InstallController], + providers: [ + InstallOrchestratorService, + { provide: DOCKER_RUNNER, useClass: ShellDockerRunner }, + { provide: HEALTH_PROBE, useClass: HttpHealthProbe }, + { + provide: OCI_TOKEN_CLIENT, + useFactory: () => new CaAuthOciTokenClient(process.env.CA_AUTH_BASE_URL ?? 'http://ca-auth:3000'), + }, + ], +}) +export class OrchestratorModule {} diff --git a/components/orchestrator/src/orchestrator/ports.ts b/components/orchestrator/src/orchestrator/ports.ts new file mode 100644 index 00000000000..86b3a6b5830 --- /dev/null +++ b/components/orchestrator/src/orchestrator/ports.ts @@ -0,0 +1,60 @@ +/** + * @fileoverview Порты для install-pipeline'а. Сервис {@link InstallOrchestratorService} + * зависит только от этих интерфейсов; реальные shell-out / HTTP-импленты + * лежат рядом в `*.impl.ts` и подключаются в `OrchestratorModule`. + * + * Зачем интерфейсы: docker socket / сеть наружу — environment-specific. + * Тесты подменяют порты на in-memory моки, не запуская docker. Story 10.4. + */ + +/** + * Порт обращения к docker daemon хоста. MWP-набор операций: + * pull image (с авторизацией через OCI token) и compose up. + * + * Реальный impl шеллит `docker pull` / `docker compose up -d`. Тестовый + * impl возвращает заранее заданные результаты — без shell. + */ +export interface DockerRunnerPort { + /** Подтянуть образ из приватного registry. */ + pullImage(opts: { imageRef: string; bearerToken: string }): Promise; + /** Поднять сервис compose-проекта (idempotent — повторный вызов no-op если up). */ + composeUp(opts: { composeFile: string; serviceName: string }): Promise; + /** Снять сервис compose-проекта — используется на rollback. */ + composeDown(opts: { composeFile: string; serviceName: string }): Promise; +} + +export const DOCKER_RUNNER = Symbol('DockerRunnerPort'); + +/** + * Порт healthcheck'а subgraph'а. Делает GET к указанному URL и считает + * сервис здоровым, если получает 200 OK с непустым body в течение + * `timeoutMs`. Реальный impl поллит с интервалом ≤500ms; тестовый + * отвечает заранее. + * + * Зачем именно poll, а не один запрос: docker compose up возвращается + * сразу же по запуску контейнера; Nest-приложение внутри стартует + * 1-3 секунды (DI, TypeORM connect, GraphQL schema introspection). + * Без поллинга мы получили бы false negative. + */ +export interface HealthProbePort { + waitUntilHealthy(opts: { url: string; timeoutMs: number }): Promise; +} + +export type HealthOutcome = + | { ok: true; elapsedMs: number } + | { ok: false; reason: 'timeout' | 'badStatus' | 'transportError'; lastError?: string }; + +export const HEALTH_PROBE = Symbol('HealthProbePort'); + +/** + * Порт получения OCI token у CA-auth для docker pull из приватного + * registry. Возвращает bearer-токен, валидный для конкретного scope + * `repository::pull`. + * + * @see ca-auth/src/modules/registry/web/oci-token.controller.ts (Story 10.6) + */ +export interface OciTokenClientPort { + issueToken(opts: { packageId: string; jwt: string }): Promise; +} + +export const OCI_TOKEN_CLIENT = Symbol('OciTokenClientPort'); diff --git a/components/gateway-orchestrator/tsconfig.json b/components/orchestrator/tsconfig.json similarity index 70% rename from components/gateway-orchestrator/tsconfig.json rename to components/orchestrator/tsconfig.json index 1b20d563127..8dc3d6f4dca 100644 --- a/components/gateway-orchestrator/tsconfig.json +++ b/components/orchestrator/tsconfig.json @@ -11,7 +11,9 @@ "emitDecoratorMetadata": true, "declaration": false, "outDir": "dist", - "rootDir": "src" + "rootDir": "src", + "types": ["node", "jest"] }, - "include": ["src/**/*"] + "include": ["src/**/*"], + "exclude": ["src/**/*.spec.ts", "dist", "node_modules"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4afb4876855..fc89d6282c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1303,9 +1303,18 @@ importers: specifier: ^0.3.20 version: 0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 '@types/node': specifier: ^20.0.0 version: 20.19.37 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.4)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) ts-node-dev: specifier: ^2.0.0 version: 2.0.0(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3) @@ -1393,6 +1402,64 @@ importers: specifier: ^4.0.18 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@20.19.37)(vite@8.0.3(@types/node@20.19.37)(esbuild@0.27.4)(jiti@2.6.1)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)) + components/orchestrator: + dependencies: + '@apollo/gateway': + specifier: ^2.9.0 + version: 2.14.1(encoding@0.1.13)(graphql@16.13.2) + '@nestjs/apollo': + specifier: ^12.2.1 + version: 12.2.2(@apollo/gateway@2.14.1(encoding@0.1.13)(graphql@16.13.2))(@apollo/server@4.13.0(encoding@0.1.13)(graphql@16.13.2))(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/graphql@12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2))(graphql@16.13.2) + '@nestjs/common': + specifier: ^10.4.0 + version: 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/graphql': + specifier: ^12.2.1 + version: 12.2.2(@apollo/subgraph@2.14.1(graphql@16.13.2))(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(class-transformer@0.5.1)(class-validator@0.14.4)(graphql@16.13.2)(reflect-metadata@0.2.2) + '@nestjs/platform-express': + specifier: ^10.4.0 + version: 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22) + '@nestjs/typeorm': + specifier: ^10.0.2 + version: 10.0.2(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3))) + graphql: + specifier: ^16.9.0 + version: 16.13.2 + pg: + specifier: ^8.13.0 + version: 8.20.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: ^0.3.20 + version: 0.3.20(ioredis@5.10.1)(pg@8.20.0)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) + devDependencies: + '@types/jest': + specifier: ^29.5.0 + version: 29.5.14 + '@types/node': + specifier: ^20.0.0 + version: 20.19.37 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)) + ts-jest: + specifier: ^29.1.0 + version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.0))(esbuild@0.27.4)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.37)(ts-node@10.9.2(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3)))(typescript@5.9.3) + ts-node-dev: + specifier: ^2.0.0 + version: 2.0.0(@swc/core@1.15.33(@swc/helpers@0.5.19))(@types/node@20.19.37)(typescript@5.9.3) + typescript: + specifier: ^5.4.0 + version: 5.9.3 + components/parser: dependencies: '@blockmatic/eosio-ship-reader': @@ -20664,7 +20731,7 @@ snapshots: '@babel/types': 7.29.0 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -20716,7 +20783,7 @@ snapshots: '@babel/core': 7.29.0 '@babel/helper-compilation-targets': 7.28.6 '@babel/helper-plugin-utils': 7.28.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) lodash.debounce: 4.0.8 resolve: 1.22.11 transitivePeerDependencies: @@ -21425,7 +21492,7 @@ snapshots: '@babel/parser': 7.29.2 '@babel/template': 7.28.6 '@babel/types': 7.29.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -21960,7 +22027,7 @@ snapshots: '@electron/get@2.0.3': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) env-paths: 2.2.1 fs-extra: 8.1.0 got: 11.8.6 @@ -22439,7 +22506,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -22463,7 +22530,7 @@ snapshots: '@eslint/eslintrc@0.4.3': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 7.3.1 globals: 13.24.0 ignore: 4.0.6 @@ -22477,7 +22544,7 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 @@ -22491,7 +22558,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -23103,7 +23170,7 @@ snapshots: '@types/js-yaml': 4.0.9 '@whatwg-node/fetch': 0.10.13 chalk: 4.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) dotenv: 16.6.1 graphql: 16.13.2 graphql-request: 6.1.0(encoding@0.1.13)(graphql@16.13.2) @@ -23476,7 +23543,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -23484,7 +23551,7 @@ snapshots: '@humanwhocodes/config-array@0.5.0': dependencies: '@humanwhocodes/object-schema': 1.2.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -26158,7 +26225,7 @@ snapshots: '@pm2/pm2-version-check@1.0.4': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -26972,7 +27039,7 @@ snapshots: '@swc-node/sourcemap-support': 0.6.1 '@swc/core': 1.15.33(@swc/helpers@0.5.19) colorette: 2.0.20 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) oxc-resolver: 11.19.1 pirates: 4.0.7 tslib: 2.8.1 @@ -27071,7 +27138,7 @@ snapshots: '@tokenizer/inflate@0.2.7': dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fflate: 0.8.2 token-types: 6.1.2 transitivePeerDependencies: @@ -27783,7 +27850,7 @@ snapshots: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/type-utils': 5.62.0(eslint@7.32.0)(typescript@5.9.3) '@typescript-eslint/utils': 5.62.0(eslint@7.32.0)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 graphemer: 1.4.0 ignore: 5.3.2 @@ -27862,7 +27929,7 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) optionalDependencies: typescript: 5.9.3 @@ -27875,7 +27942,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 typescript: 5.9.3 transitivePeerDependencies: @@ -27887,7 +27954,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -27899,7 +27966,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -27909,7 +27976,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -27942,7 +28009,7 @@ snapshots: dependencies: '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.9.3) '@typescript-eslint/utils': 5.62.0(eslint@7.32.0)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 tsutils: 3.21.0(typescript@5.9.3) optionalDependencies: @@ -27955,7 +28022,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@7.32.0)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -27967,7 +28034,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -27979,7 +28046,7 @@ snapshots: '@typescript-eslint/types': 8.57.2 '@typescript-eslint/typescript-estree': 8.57.2(typescript@5.9.3) '@typescript-eslint/utils': 8.57.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -27998,7 +28065,7 @@ snapshots: dependencies: '@typescript-eslint/types': 4.33.0 '@typescript-eslint/visitor-keys': 4.33.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.4 @@ -28012,7 +28079,7 @@ snapshots: dependencies: '@typescript-eslint/types': 5.62.0 '@typescript-eslint/visitor-keys': 5.62.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 semver: 7.7.4 @@ -28026,7 +28093,7 @@ snapshots: dependencies: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.9 @@ -28043,7 +28110,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.2(typescript@5.9.3) '@typescript-eslint/types': 8.57.2 '@typescript-eslint/visitor-keys': 8.57.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -28765,7 +28832,7 @@ snapshots: agent-base@6.0.2: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -29171,19 +29238,19 @@ snapshots: axios@0.21.4: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.15.11(debug@4.3.7) transitivePeerDependencies: - debug axios@0.26.1: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.15.11(debug@4.3.7) transitivePeerDependencies: - debug axios@1.13.6: dependencies: - follow-redirects: 1.15.11 + follow-redirects: 1.15.11(debug@4.3.7) form-data: 4.0.5 proxy-from-env: 1.1.0 transitivePeerDependencies: @@ -29480,7 +29547,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -31097,10 +31164,6 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.4.3: - dependencies: - ms: 2.1.3 - debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -31290,7 +31353,7 @@ snapshots: docker-modem@5.0.7: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) readable-stream: 3.6.2 split-ca: 1.0.1 ssh2: 1.17.0 @@ -31530,7 +31593,7 @@ snapshots: engine.io-client@6.6.4: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 xmlhttprequest-ssl: 2.1.2 @@ -31550,7 +31613,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-parser: 5.2.3 ws: 8.18.3 transitivePeerDependencies: @@ -32087,7 +32150,7 @@ snapshots: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.57.2 comment-parser: 1.4.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 @@ -32106,7 +32169,7 @@ snapshots: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.57.2 comment-parser: 1.4.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 @@ -32125,7 +32188,7 @@ snapshots: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.57.2 comment-parser: 1.4.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 @@ -32183,7 +32246,7 @@ snapshots: '@es-joy/jsdoccomment': 0.50.2 are-docs-informative: 0.0.2 comment-parser: 1.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 7.32.0 espree: 10.4.0 @@ -32199,7 +32262,7 @@ snapshots: '@es-joy/jsdoccomment': 0.50.2 are-docs-informative: 0.0.2 comment-parser: 1.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 8.57.1 espree: 10.4.0 @@ -32215,7 +32278,7 @@ snapshots: '@es-joy/jsdoccomment': 0.50.2 are-docs-informative: 0.0.2 comment-parser: 1.4.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint: 9.39.4(jiti@2.6.1) espree: 10.4.0 @@ -32442,7 +32505,7 @@ snapshots: eslint-plugin-toml@0.11.1(eslint@7.32.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 eslint-compat-utils: 0.5.1(eslint@7.32.0) lodash: 4.17.23 @@ -32452,7 +32515,7 @@ snapshots: eslint-plugin-toml@0.11.1(eslint@8.57.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 eslint-compat-utils: 0.5.1(eslint@8.57.1) lodash: 4.17.23 @@ -32462,7 +32525,7 @@ snapshots: eslint-plugin-toml@0.11.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) eslint-compat-utils: 0.5.1(eslint@9.39.4(jiti@2.6.1)) lodash: 4.17.23 @@ -32472,7 +32535,7 @@ snapshots: eslint-plugin-toml@0.12.0(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) eslint-compat-utils: 0.6.5(eslint@9.39.4(jiti@2.6.1)) lodash: 4.17.23 @@ -32629,7 +32692,7 @@ snapshots: eslint-plugin-yml@1.19.1(eslint@7.32.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) diff-sequences: 27.5.1 escape-string-regexp: 4.0.0 eslint: 7.32.0 @@ -32641,7 +32704,7 @@ snapshots: eslint-plugin-yml@1.19.1(eslint@8.57.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) diff-sequences: 27.5.1 escape-string-regexp: 4.0.0 eslint: 8.57.1 @@ -32653,7 +32716,7 @@ snapshots: eslint-plugin-yml@1.19.1(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) diff-sequences: 27.5.1 escape-string-regexp: 4.0.0 eslint: 9.39.4(jiti@2.6.1) @@ -32727,7 +32790,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 enquirer: 2.4.1 escape-string-regexp: 4.0.0 @@ -32777,7 +32840,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -32824,7 +32887,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -33081,7 +33144,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -33134,7 +33197,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -33359,7 +33422,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -33434,15 +33497,13 @@ snapshots: fn.name@1.1.0: {} - follow-redirects@1.15.11: {} - follow-redirects@1.15.11(debug@4.3.7): optionalDependencies: debug: 4.3.7 follow-redirects@1.15.11(debug@4.4.3): optionalDependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) for-each@0.3.5: dependencies: @@ -33692,7 +33753,7 @@ snapshots: dependencies: basic-ftp: 5.2.0 data-uri-to-buffer: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -34414,14 +34475,14 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color http-proxy-middleware@3.0.5: dependencies: '@types/http-proxy': 1.17.17 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-proxy: 1.18.1(debug@4.4.3) is-glob: 4.0.3 is-plain-object: 5.0.0 @@ -34466,14 +34527,14 @@ snapshots: https-proxy-agent@5.0.0: dependencies: agent-base: 6.0.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -34710,7 +34771,7 @@ snapshots: dependencies: '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) denque: 2.1.0 lodash.defaults: 4.2.0 lodash.isarguments: 3.1.0 @@ -35067,7 +35128,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -35182,7 +35243,7 @@ snapshots: jest-diff@29.7.0: dependencies: - chalk: 4.1.0 + chalk: 4.1.2 diff-sequences: 29.6.3 jest-get-type: 29.6.3 pretty-format: 29.7.0 @@ -35908,7 +35969,7 @@ snapshots: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -36736,7 +36797,7 @@ snapshots: micromark@2.11.4: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) parse-entities: 2.0.0 transitivePeerDependencies: - supports-color @@ -36744,7 +36805,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -37446,7 +37507,7 @@ snapshots: '@yarnpkg/parsers': 3.0.2 '@zkochan/js-yaml': 0.0.7 axios: 1.13.6 - chalk: 4.1.0 + chalk: 4.1.2 cli-cursor: 3.1.0 cli-spinners: 2.6.1 cliui: 8.0.1 @@ -37880,7 +37941,7 @@ snapshots: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -38320,7 +38381,7 @@ snapshots: pm2-axon-rpc@0.7.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -38328,7 +38389,7 @@ snapshots: dependencies: amp: 0.3.1 amp-message: 0.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) escape-string-regexp: 4.0.0 transitivePeerDependencies: - supports-color @@ -38345,7 +38406,7 @@ snapshots: pm2-sysmonit@1.2.8: dependencies: async: 3.2.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) pidusage: 2.0.21 systeminformation: 5.31.5 tx2: 1.0.5 @@ -38367,7 +38428,7 @@ snapshots: commander: 2.15.1 croner: 4.1.97 dayjs: 1.11.20 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) enquirer: 2.3.6 eventemitter2: 5.0.1 fclone: 1.0.11 @@ -38837,7 +38898,7 @@ snapshots: proxy-agent@6.3.1: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -39472,7 +39533,7 @@ snapshots: require-in-the-middle@5.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 resolve: 1.22.11 transitivePeerDependencies: @@ -39480,7 +39541,7 @@ snapshots: require-in-the-middle@8.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) module-details-from-path: 1.0.4 transitivePeerDependencies: - supports-color @@ -39689,7 +39750,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -39913,7 +39974,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -40171,7 +40232,7 @@ snapshots: simple-websocket@9.1.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) queue-microtask: 1.2.3 randombytes: 2.1.0 readable-stream: 3.6.2 @@ -40222,7 +40283,7 @@ snapshots: socket.io-adapter@2.5.6: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) ws: 8.18.3 transitivePeerDependencies: - bufferutil @@ -40243,7 +40304,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io-client: 6.6.4 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -40254,7 +40315,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -40263,7 +40324,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) engine.io: 6.6.6 socket.io-adapter: 2.5.6 socket.io-parser: 4.2.6 @@ -40275,7 +40336,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) socks: 2.8.7 transitivePeerDependencies: - supports-color @@ -40709,7 +40770,7 @@ snapshots: sumchecker@3.0.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -40717,7 +40778,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -40996,7 +41057,7 @@ snapshots: threads@1.7.0: dependencies: callsites: 3.1.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) is-observable: 2.1.0 observable-fns: 0.6.1 optionalDependencies: @@ -41297,7 +41358,7 @@ snapshots: tuf-js@2.2.1: dependencies: '@tufjs/models': 2.0.1 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) make-fetch-happen: 13.0.1 transitivePeerDependencies: - supports-color @@ -41431,7 +41492,7 @@ snapshots: chalk: 4.1.2 cli-highlight: 2.1.11 dayjs: 1.11.20 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) dotenv: 16.6.1 glob: 10.5.0 mkdirp: 2.1.6 @@ -41797,7 +41858,7 @@ snapshots: vite-node@1.6.1(@types/node@20.19.37)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) pathe: 1.1.2 picocolors: 1.1.1 vite: 5.4.21(@types/node@20.19.37)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) @@ -41815,7 +41876,7 @@ snapshots: vite-node@2.1.9(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1): dependencies: cac: 6.7.14 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.19.15)(lightningcss@1.32.0)(sass-embedded@1.98.0)(sass@1.98.0)(terser@5.46.1) @@ -41937,7 +41998,7 @@ snapshots: '@vitest/utils': 1.6.1 acorn-walk: 8.3.5 chai: 4.5.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) execa: 8.0.1 local-pkg: 0.5.1 magic-string: 0.30.21 @@ -41972,7 +42033,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -42110,7 +42171,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@7.32.0): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 7.32.0 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -42123,7 +42184,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@8.57.1): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 8.57.1 eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 @@ -42136,7 +42197,7 @@ snapshots: vue-eslint-parser@9.4.3(eslint@9.39.4(jiti@2.6.1)): dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@8.1.1) eslint: 9.39.4(jiti@2.6.1) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 From 66ad103e90b7defc6ae9bb499b3af8e504ce32e3 Mon Sep 17 00:00:00 2001 From: coopops Date: Thu, 4 Jun 2026 06:01:45 +0000 Subject: [PATCH 17/17] [E9-9] feat(controller): approveModeration + rejectModeration + appsCatalogPendingModerations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Story 9.9 «Стол восхода: approve pending packages» — backend-сторона. GraphQL contract: - Query.appsCatalogPendingModerations(status?: ModerationStatusEnum, limit?: Int) → [ModerationRequestDTO]. По умолчанию SUBMITTED. Chairman-only. - Mutation.approveModeration(data: ApproveModerationInputDTO!) → ApproveModerationResultDTO. Discriminated outcome: applied / pendingChain (423) / conflict (409) / requiresOverride (403) / failed. - Mutation.rejectModeration(data: RejectModerationInputDTO!) → RejectModerationResultDTO. Discriminated outcome: applied / conflict / failed. HTTP-service: 3 новых метода (listSubmittedModerations / approveModeration / rejectModeration) на ca-admin endpoints, добавленных параллельным apps-catalog PR #7. Mapping snake_case → camelCase; degraded mode на list — пустой массив, на mutations — failed с явной причиной (чтобы UI не вводился в заблуждение «approved»). ReleaseScope передаётся как InputType с union-style полями (type + subnets?/coopnames?); резолвер валидирует соответствие type → непустой массив перед отправкой на ca-admin. Зависимость: apps-catalog PR #7 (GET /v1/admin/moderation) должен быть смёрджен в feat/E9-demo-app-pilot до того, как этот endpoint можно использовать в e2e. Co-Authored-By: Claude Opus 4.7 --- .../dto/approve-moderation-input.dto.ts | 29 ++ .../dto/approve-moderation-result.dto.ts | 51 ++++ .../application/dto/moderation-request.dto.ts | 75 +++++ .../dto/reject-moderation-input.dto.ts | 18 ++ .../dto/reject-moderation-result.dto.ts | 34 +++ .../dto/release-scope-input.dto.ts | 51 ++++ .../resolvers/apps-catalog-proxy.resolver.ts | 210 +++++++++++++- .../apps-catalog-http.service.ts | 262 ++++++++++++++++++ 8 files changed, 728 insertions(+), 2 deletions(-) create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-input.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-result.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/moderation-request.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-input.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-result.dto.ts create mode 100644 components/controller/src/extensions/apps-catalog-proxy/application/dto/release-scope-input.dto.ts diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-input.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-input.dto.ts new file mode 100644 index 00000000000..61d16033370 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-input.dto.ts @@ -0,0 +1,29 @@ +import { Field, InputType } from '@nestjs/graphql'; +import { ReleaseScopeInputDTO } from './release-scope-input.dto'; + +/** + * Входные данные `Mutation.approveModeration` (story 9.9). + * + * Chairman восхода одобряет заявку на модерацию из стола восхода. + * Прокидывается на ca-admin `POST /v1/admin/moderation/:id/approve`, + * где ca-admin атомарно переводит заявку в APPROVED и активирует + * релиз. + */ +@InputType() +export class ApproveModerationInputDTO { + @Field({ description: 'UUID заявки на модерацию' }) + moderationId!: string; + + @Field(() => ReleaseScopeInputDTO, { + description: 'Область видимости релиза при активации', + }) + scope!: ReleaseScopeInputDTO; + + @Field({ + nullable: true, + description: + 'Явное согласие модератора на одобрение пакета с критической ' + + 'уязвимостью (requires_override=true). Без него — 403.', + }) + override?: boolean; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-result.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-result.dto.ts new file mode 100644 index 00000000000..08d5eced446 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/approve-moderation-result.dto.ts @@ -0,0 +1,51 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +/** + * Статусы результата `Mutation.approveModeration`. + * + * - `applied` — release ACTIVE, on-chain `apps::setrelease` прошёл; + * - `pendingChain` — moderation одобрена, но on-chain провалился + * (HTTP 423); recovery worker повторит позже; + * - `conflict` — заявка не в SUBMITTED (HTTP 409); + * - `requiresOverride` — нужен явный override:true (HTTP 403); + * - `failed` — прочие ошибки. + */ +export enum ApproveModerationStatus { + APPLIED = 'applied', + PENDING_CHAIN = 'pendingChain', + CONFLICT = 'conflict', + REQUIRES_OVERRIDE = 'requiresOverride', + FAILED = 'failed', +} + +registerEnumType(ApproveModerationStatus, { + name: 'ApproveModerationStatus', + description: 'Статус мутации approveModeration', +}); + +@ObjectType() +export class ApproveModerationResultDTO { + @Field(() => ApproveModerationStatus, { description: 'Discriminator' }) + status!: ApproveModerationStatus; + + @Field({ description: 'UUID запроса (для логов / идемпотентности)' }) + requestId!: string; + + @Field({ + nullable: true, + description: 'Идентификатор пакета (только при status=applied)', + }) + packageId?: string; + + @Field({ + nullable: true, + description: 'Версия активированного релиза (только при status=applied)', + }) + version?: string; + + @Field({ + nullable: true, + description: 'Человекочитаемое сообщение об ошибке', + }) + error?: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/moderation-request.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/moderation-request.dto.ts new file mode 100644 index 00000000000..3164ec650b0 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/moderation-request.dto.ts @@ -0,0 +1,75 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; +import GraphQLJSON from 'graphql-type-json'; + +/** + * Статусы заявки на модерацию (зеркало `ModerationStatus` из ca-admin). + */ +export enum ModerationStatusEnum { + SUBMITTED = 'SUBMITTED', + WITHDRAWN = 'WITHDRAWN', + APPROVED = 'APPROVED', + APPROVED_PENDING_CHAIN = 'APPROVED_PENDING_CHAIN', + REJECTED = 'REJECTED', +} + +registerEnumType(ModerationStatusEnum, { + name: 'ModerationStatusEnum', + description: 'Статус заявки на модерацию пакета', +}); + +export enum ReleaseTypeEnum { + FULL = 'full', + CANARY = 'canary', +} + +registerEnumType(ReleaseTypeEnum, { + name: 'ReleaseTypeEnum', + description: 'Тип релиза: full / canary', +}); + +/** + * Запись заявки на модерацию для стола восхода (story 9.9). + * + * Возвращается из `appsCatalogPendingModerations`. Поля `scope` — JSON + * (union all/subnets/cooperatives/empty), фронт сам разбирает по `.type`. + */ +@ObjectType() +export class ModerationRequestDTO { + @Field({ description: 'UUID заявки' }) + id!: string; + + @Field({ description: 'Идентификатор пакета (@scope/name)' }) + packageId!: string; + + @Field({ description: 'Версия пакета (SemVer)' }) + version!: string; + + @Field(() => GraphQLJSON, { + description: 'ReleaseScope: { type, subnets?, coopnames? }', + }) + scope!: unknown; + + @Field({ description: 'Краткое описание / release notes от разработчика' }) + brief!: string; + + @Field(() => ReleaseTypeEnum, { description: 'Тип релиза' }) + releaseType!: ReleaseTypeEnum; + + @Field(() => ModerationStatusEnum, { description: 'Текущий статус' }) + status!: ModerationStatusEnum; + + @Field({ description: 'Кто подал заявку (Antelope-имя)' }) + submittedBy!: string; + + @Field({ description: 'Когда подана (ISO 8601)' }) + submittedAt!: string; + + @Field({ description: 'Когда последний раз обновлена (ISO 8601)' }) + updatedAt!: string; + + @Field({ + description: + 'Требуется ли явный override:true при approve (критическая уязвимость в scan_report)', + }) + requiresOverride!: boolean; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-input.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-input.dto.ts new file mode 100644 index 00000000000..b47b4beb979 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-input.dto.ts @@ -0,0 +1,18 @@ +import { Field, InputType } from '@nestjs/graphql'; + +/** + * Входные данные `Mutation.rejectModeration` (story 9.9). + * + * Chairman восхода отклоняет заявку на модерацию с причиной. Причина + * пишется в `status_reason` и доставляется разработчику через outbox. + */ +@InputType() +export class RejectModerationInputDTO { + @Field({ description: 'UUID заявки на модерацию' }) + moderationId!: string; + + @Field({ + description: 'Причина отказа (3..2000 символов), увидит разработчик', + }) + reason!: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-result.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-result.dto.ts new file mode 100644 index 00000000000..ed421ad58b2 --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/reject-moderation-result.dto.ts @@ -0,0 +1,34 @@ +import { Field, ObjectType, registerEnumType } from '@nestjs/graphql'; + +/** + * Статусы результата `Mutation.rejectModeration`. + * + * - `applied` — REJECTED, статус-причина сохранена; + * - `conflict` — заявка не в SUBMITTED (HTTP 409); + * - `failed` — прочие ошибки. + */ +export enum RejectModerationStatus { + APPLIED = 'applied', + CONFLICT = 'conflict', + FAILED = 'failed', +} + +registerEnumType(RejectModerationStatus, { + name: 'RejectModerationStatus', + description: 'Статус мутации rejectModeration', +}); + +@ObjectType() +export class RejectModerationResultDTO { + @Field(() => RejectModerationStatus, { description: 'Discriminator' }) + status!: RejectModerationStatus; + + @Field({ description: 'UUID запроса' }) + requestId!: string; + + @Field({ + nullable: true, + description: 'Человекочитаемое сообщение об ошибке', + }) + error?: string; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/dto/release-scope-input.dto.ts b/components/controller/src/extensions/apps-catalog-proxy/application/dto/release-scope-input.dto.ts new file mode 100644 index 00000000000..ea162c2ec2b --- /dev/null +++ b/components/controller/src/extensions/apps-catalog-proxy/application/dto/release-scope-input.dto.ts @@ -0,0 +1,51 @@ +import { Field, InputType, registerEnumType } from '@nestjs/graphql'; + +/** + * Тип области видимости релиза. + * + * - `all` — для всех подсетей (любых кооперативов); + * - `subnets` — только перечисленные chain_id подсетей; + * - `cooperatives` — только перечисленные кооперативы; + * - `empty` — выключить релиз (полная отзывка видимости). + * + * См. epics.md Story 3.7 — одобрение заявки на модерацию. + */ +export enum ReleaseScopeType { + ALL = 'all', + SUBNETS = 'subnets', + COOPERATIVES = 'cooperatives', + EMPTY = 'empty', +} + +registerEnumType(ReleaseScopeType, { + name: 'ReleaseScopeType', + description: 'Тип области видимости релиза при одобрении заявки', +}); + +/** + * Область видимости релиза, передаётся в `approveModeration`. + * + * При `type=subnets` ожидается заполненный `subnets`. При + * `type=cooperatives` — заполненный `coopnames`. Для `all` / `empty` + * массивы игнорируются. + * + * Резолвер сам валидирует соответствие type → массив и нормализует + * перед отправкой на ca-admin. + */ +@InputType() +export class ReleaseScopeInputDTO { + @Field(() => ReleaseScopeType, { description: 'Тип scope' }) + type!: ReleaseScopeType; + + @Field(() => [String], { + nullable: true, + description: 'chain_id подсетей (для type=subnets)', + }) + subnets?: string[]; + + @Field(() => [String], { + nullable: true, + description: 'Antelope-имена кооперативов (для type=cooperatives)', + }) + coopnames?: string[]; +} diff --git a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts index 1863db64ec8..df2e766e53d 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/application/resolvers/apps-catalog-proxy.resolver.ts @@ -1,14 +1,64 @@ import { Args, Mutation, Query, Resolver } from '@nestjs/graphql'; -import { Logger, UseGuards } from '@nestjs/common'; +import { BadRequestException, Logger, UseGuards } from '@nestjs/common'; import { GqlJwtAuthGuard } from '~/application/auth/guards/graphql-jwt-auth.guard'; import { RolesGuard } from '~/application/auth/guards/roles.guard'; import { AuthRoles } from '~/application/auth/decorators/auth.decorator'; -import { AppsCatalogHttpService } from '../../infrastructure/apps-catalog-http.service'; +import { + AppsCatalogHttpService, + type ModerationStatus, + type ReleaseScopeInput, +} from '../../infrastructure/apps-catalog-http.service'; import { AppsCatalogRemotePackageDTO } from '../dto/apps-catalog-remote-package.dto'; +import { ApproveModerationInputDTO } from '../dto/approve-moderation-input.dto'; +import { + ApproveModerationResultDTO, + ApproveModerationStatus, +} from '../dto/approve-moderation-result.dto'; +import { + ModerationRequestDTO, + ModerationStatusEnum, + ReleaseTypeEnum, +} from '../dto/moderation-request.dto'; import { PublishPackageInputDTO } from '../dto/publish-package-input.dto'; import { PublishPackageResultDTO } from '../dto/publish-package-result.dto'; import { PublishReleaseInputDTO } from '../dto/publish-release-input.dto'; import { PublishReleaseResultDTO } from '../dto/publish-release-result.dto'; +import { + ReleaseScopeInputDTO, + ReleaseScopeType, +} from '../dto/release-scope-input.dto'; +import { RejectModerationInputDTO } from '../dto/reject-moderation-input.dto'; +import { + RejectModerationResultDTO, + RejectModerationStatus, +} from '../dto/reject-moderation-result.dto'; + +const toReleaseScope = (dto: ReleaseScopeInputDTO): ReleaseScopeInput => { + switch (dto.type) { + case ReleaseScopeType.ALL: + return { type: 'all' }; + case ReleaseScopeType.EMPTY: + return { type: 'empty' }; + case ReleaseScopeType.SUBNETS: { + const subnets = dto.subnets ?? []; + if (subnets.length === 0) { + throw new BadRequestException( + 'scope.type=subnets требует непустой subnets', + ); + } + return { type: 'subnets', subnets }; + } + case ReleaseScopeType.COOPERATIVES: { + const coopnames = dto.coopnames ?? []; + if (coopnames.length === 0) { + throw new BadRequestException( + 'scope.type=cooperatives требует непустой coopnames', + ); + } + return { type: 'cooperatives', coopnames }; + } + } +}; /** * Story 9.5.b — публичный каталог remote-пакетов на desktop'е магазина. @@ -165,4 +215,160 @@ export class AppsCatalogProxyResolver { error: outcome.error, }; } + + /** + * Story 9.9 — стол восхода: список заявок на модерацию. + * + * Защита: chairman кооператива-оператора (voskhod). Возвращает заявки + * в указанном статусе (по умолчанию SUBMITTED), отсортированные + * `updatedAt` ASC (старшие — первыми). + * + * На degraded-mode (без APPS_CATALOG_API_KEY) — пустой массив, чтобы + * UI стола восхода нормально показал «pending пусто». + */ + @Query(() => [ModerationRequestDTO], { + name: 'appsCatalogPendingModerations', + description: + 'Заявки на модерацию пакетов в каталоге восхода. По умолчанию ' + + 'SUBMITTED (ждут approve/reject). Только chairman.', + }) + @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) + async appsCatalogPendingModerations( + @Args('status', { + type: () => ModerationStatusEnum, + nullable: true, + defaultValue: ModerationStatusEnum.SUBMITTED, + }) + status: ModerationStatusEnum, + @Args('limit', { type: () => Number, nullable: true }) + limit?: number, + ): Promise { + const items = await this.client.listSubmittedModerations( + status as unknown as ModerationStatus, + limit, + ); + return items.map((r) => ({ + id: r.id, + packageId: r.packageId, + version: r.version, + scope: r.scope, + brief: r.brief, + releaseType: r.releaseType as ReleaseTypeEnum, + status: r.status as unknown as ModerationStatusEnum, + submittedBy: r.submittedBy, + submittedAt: r.submittedAt, + updatedAt: r.updatedAt, + requiresOverride: r.requiresOverride, + })); + } + + /** + * Story 9.9-approve — chairman восхода одобряет заявку на модерацию. + * + * Защита: только chairman кооператива-оператора (voskhod). ca-admin + * атомарно переводит moderation в APPROVED + активирует release + + * выкладывает outbox-event `release.activated` (он же триггер для + * on-chain `apps::setrelease` и orchestrator'а install pipeline). + * + * Discriminated outcome — клиент должен switch на `status`. + */ + @Mutation(() => ApproveModerationResultDTO, { + name: 'approveModeration', + description: + 'Одобряет заявку на модерацию в каталоге восхода. Только chairman.', + }) + @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) + async approveModeration( + @Args('data', { type: () => ApproveModerationInputDTO }) + data: ApproveModerationInputDTO, + ): Promise { + const outcome = await this.client.approveModeration({ + moderationId: data.moderationId, + scope: toReleaseScope(data.scope), + override: data.override, + }); + if (outcome.status === 'applied') { + this.logger.log( + `approveModeration applied: ${outcome.packageId}@${outcome.version} (request ${outcome.requestId})`, + ); + return { + status: ApproveModerationStatus.APPLIED, + requestId: outcome.requestId, + packageId: outcome.packageId, + version: outcome.version, + }; + } + if (outcome.status === 'pendingChain') { + return { + status: ApproveModerationStatus.PENDING_CHAIN, + requestId: outcome.requestId, + error: outcome.error, + }; + } + if (outcome.status === 'conflict') { + return { + status: ApproveModerationStatus.CONFLICT, + requestId: outcome.requestId, + error: outcome.error, + }; + } + if (outcome.status === 'requiresOverride') { + return { + status: ApproveModerationStatus.REQUIRES_OVERRIDE, + requestId: outcome.requestId, + error: outcome.error, + }; + } + return { + status: ApproveModerationStatus.FAILED, + requestId: outcome.requestId, + error: outcome.error, + }; + } + + /** + * Story 9.9-reject — chairman восхода отклоняет заявку. + * + * Защита: только chairman кооператива-оператора (voskhod). Причина + * пишется в `status_reason` и доставляется разработчику через outbox. + */ + @Mutation(() => RejectModerationResultDTO, { + name: 'rejectModeration', + description: + 'Отклоняет заявку на модерацию с причиной. Только chairman.', + }) + @UseGuards(GqlJwtAuthGuard, RolesGuard) + @AuthRoles(['chairman']) + async rejectModeration( + @Args('data', { type: () => RejectModerationInputDTO }) + data: RejectModerationInputDTO, + ): Promise { + const outcome = await this.client.rejectModeration({ + moderationId: data.moderationId, + reason: data.reason, + }); + if (outcome.status === 'applied') { + this.logger.log( + `rejectModeration applied: ${data.moderationId} (request ${outcome.requestId})`, + ); + return { + status: RejectModerationStatus.APPLIED, + requestId: outcome.requestId, + }; + } + if (outcome.status === 'conflict') { + return { + status: RejectModerationStatus.CONFLICT, + requestId: outcome.requestId, + error: outcome.error, + }; + } + return { + status: RejectModerationStatus.FAILED, + requestId: outcome.requestId, + error: outcome.error, + }; + } } diff --git a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts index bc51edc1247..5983acf9260 100644 --- a/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts +++ b/components/controller/src/extensions/apps-catalog-proxy/infrastructure/apps-catalog-http.service.ts @@ -43,6 +43,93 @@ export type CreateReleaseOutcome = | { status: 'invalidManifest'; requestId: string; error: string } | { status: 'failed'; requestId: string; error: string }; +export type ModerationStatus = + | 'SUBMITTED' + | 'WITHDRAWN' + | 'APPROVED' + | 'APPROVED_PENDING_CHAIN' + | 'REJECTED'; + +export interface ModerationRequestRow { + id: string; + packageId: string; + version: string; + scope: unknown; + brief: string; + releaseType: 'full' | 'canary'; + status: ModerationStatus; + submittedBy: string; + submittedAt: string; + updatedAt: string; + requiresOverride: boolean; +} + +interface ModerationRequestWireFormat { + id: string; + package_id: string; + version: string; + scope: unknown; + brief: string; + release_type: 'full' | 'canary'; + status: ModerationStatus; + submitted_by: string; + submitted_at: string; + updated_at: string; + requires_override: boolean; +} + +export type ReleaseScopeInput = + | { type: 'all' } + | { type: 'empty' } + | { type: 'subnets'; subnets: string[] } + | { type: 'cooperatives'; coopnames: string[] }; + +export interface ApproveModerationInput { + moderationId: string; + scope: ReleaseScopeInput; + override?: boolean; + requestId?: string; +} + +export type ApproveModerationOutcome = + | { + status: 'applied'; + requestId: string; + packageId: string; + version: string; + } + | { + status: 'pendingChain'; + requestId: string; + error: string; + } + | { + status: 'conflict'; + requestId: string; + error: string; + } + | { + status: 'requiresOverride'; + requestId: string; + error: string; + } + | { + status: 'failed'; + requestId: string; + error: string; + }; + +export interface RejectModerationInput { + moderationId: string; + reason: string; + requestId?: string; +} + +export type RejectModerationOutcome = + | { status: 'applied'; requestId: string } + | { status: 'conflict'; requestId: string; error: string } + | { status: 'failed'; requestId: string; error: string }; + /** * HTTP-клиент к ca-admin (apps-catalog) для Story 9.5.b. Защищён admin-API * ключом из env (APPS_CATALOG_API_KEY). Используется только сервером — @@ -229,6 +316,181 @@ export class AppsCatalogHttpService { } } + /** + * Story 9.9: список заявок на модерацию по статусу. + * + * Дёргает ca-admin `GET /v1/admin/moderation?status=...&limit=...`. + * Используется столом восхода (chairman voskhod) для просмотра pending + * заявок и принятия решения (approve/reject). + * + * Degraded mode (нет ca-admin client'а) → пустой массив, чтобы UI + * нормально показал «pending пусто» вместо ошибки. + */ + async listSubmittedModerations( + status: ModerationStatus = 'SUBMITTED', + limit?: number, + ): Promise { + if (!this.client) return []; + try { + const params: Record = { status }; + if (limit !== undefined) params.limit = limit; + const res = await this.client.get<{ + items: ModerationRequestWireFormat[]; + }>('/v1/admin/moderation', { params }); + return res.data.items.map((r) => ({ + id: r.id, + packageId: r.package_id, + version: r.version, + scope: r.scope, + brief: r.brief, + releaseType: r.release_type, + status: r.status, + submittedBy: r.submitted_by, + submittedAt: r.submitted_at, + updatedAt: r.updated_at, + requiresOverride: r.requires_override, + })); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.logger.error(`listSubmittedModerations failed: ${msg}`); + return []; + } + } + + /** + * Story 9.9: chairman восхода одобряет заявку на модерацию. + * + * Дёргает ca-admin `POST /v1/admin/moderation/:id/approve`. ca-admin + * атомарно переводит moderation в APPROVED, активирует release и + * выкладывает outbox-event `release.activated` (он же триггер для + * on-chain `apps::setrelease` и orchestrator'а install pipeline). + * + * Discriminated outcome: + * - `applied` — HTTP 200, release ACTIVE, package_id/version в payload; + * - `pendingChain` — HTTP 423 APPROVED_PENDING_CHAIN: moderation + * одобрена, но on-chain провалился; recovery worker повторит; + * - `conflict` — HTTP 409: заявка не в SUBMITTED (уже approved / + * withdrawn); + * - `requiresOverride` — HTTP 403: scan_report критичен, нужен + * явный `override: true`; + * - `failed` — прочие ошибки. + */ + async approveModeration( + input: ApproveModerationInput, + ): Promise { + const requestId = input.requestId ?? uuidv4(); + if (!this.client) { + const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы'; + this.logger.warn( + `approveModeration refused (degraded mode): ${input.moderationId}`, + ); + return { status: 'failed', requestId, error }; + } + try { + const res = await this.client.post<{ + ok: boolean; + package_id: string; + version: string; + request_id: string; + }>(`/v1/admin/moderation/${encodeURIComponent(input.moderationId)}/approve`, { + request_id: requestId, + scope: input.scope, + ...(input.override !== undefined ? { override: input.override } : {}), + }); + return { + status: 'applied', + requestId: res.data.request_id ?? requestId, + packageId: res.data.package_id, + version: res.data.version, + }; + } catch (err) { + const httpStatus = (err as AxiosError).response?.status; + const responseData = (err as AxiosError).response?.data; + const detail = + typeof responseData === 'object' && responseData + ? JSON.stringify(responseData) + : err instanceof Error + ? err.message + : String(err); + if (httpStatus === 423) { + this.logger.warn( + `approveModeration pendingChain ${input.moderationId}: ${detail}`, + ); + return { status: 'pendingChain', requestId, error: detail }; + } + if (httpStatus === 409) { + this.logger.warn( + `approveModeration conflict ${input.moderationId}: ${detail}`, + ); + return { status: 'conflict', requestId, error: detail }; + } + if (httpStatus === 403) { + this.logger.warn( + `approveModeration requiresOverride ${input.moderationId}: ${detail}`, + ); + return { status: 'requiresOverride', requestId, error: detail }; + } + this.logger.error( + `approveModeration failed ${input.moderationId}: ${detail}`, + ); + return { status: 'failed', requestId, error: detail }; + } + } + + /** + * Story 9.9: chairman восхода отклоняет заявку на модерацию. + * + * Дёргает ca-admin `POST /v1/admin/moderation/:id/reject` с `reason`. + * ca-admin переводит moderation в REJECTED (compare-and-set из + * SUBMITTED) и пишет аудит-запись. + * + * Discriminated outcome: + * - `applied` — HTTP 200, заявка REJECTED; + * - `conflict` — HTTP 409: заявка уже не в SUBMITTED; + * - `failed` — прочие ошибки. + */ + async rejectModeration( + input: RejectModerationInput, + ): Promise { + const requestId = input.requestId ?? uuidv4(); + if (!this.client) { + const error = 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы'; + this.logger.warn( + `rejectModeration refused (degraded mode): ${input.moderationId}`, + ); + return { status: 'failed', requestId, error }; + } + try { + await this.client.post( + `/v1/admin/moderation/${encodeURIComponent(input.moderationId)}/reject`, + { + request_id: requestId, + reason: input.reason, + }, + ); + return { status: 'applied', requestId }; + } catch (err) { + const httpStatus = (err as AxiosError).response?.status; + const responseData = (err as AxiosError).response?.data; + const detail = + typeof responseData === 'object' && responseData + ? JSON.stringify(responseData) + : err instanceof Error + ? err.message + : String(err); + if (httpStatus === 409) { + this.logger.warn( + `rejectModeration conflict ${input.moderationId}: ${detail}`, + ); + return { status: 'conflict', requestId, error: detail }; + } + this.logger.error( + `rejectModeration failed ${input.moderationId}: ${detail}`, + ); + return { status: 'failed', requestId, error: detail }; + } + } + async fetchInstallScript(scope: string, name: string): Promise { if (!this.client) return null; try {