Merge pull request '[apps-catalog umbrella] mono — Federation MWP (E9 demo pilot + E10 runtime federation + 9.3.b publish)' (#64) from apps-catalog into dev

This commit was merged in pull request #64.
This commit is contained in:
2026-06-04 11:26:47 +00:00
81 changed files with 5404 additions and 125 deletions
+7
View File
@@ -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
+1
View File
@@ -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:*",
@@ -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,
},
};
@@ -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<string> {
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;
}
}
@@ -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;
}
@@ -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;
}
@@ -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(() => String, {
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;
}
@@ -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;
}
@@ -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[];
}
@@ -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;
}
@@ -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<string, unknown>;
@Field({
nullable: true,
description:
'sha256 npm tarball\'а (HEX). Если не передан — ca-admin использует sentinel zero-hash.',
})
tarballSha256?: string;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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[];
}
@@ -0,0 +1,374 @@
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
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,
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'е магазина.
*
* Прокси-резолвер: 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<AppsCatalogRemotePackageDTO[]> {
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} — устанавливается без перезагрузки сервера.`;
}
/**
* 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<PublishPackageResultDTO> {
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,
};
}
/**
* 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<PublishReleaseResultDTO> {
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,
};
}
/**
* 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<ModerationRequestDTO[]> {
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<ApproveModerationResultDTO> {
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<RejectModerationResultDTO> {
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,
};
}
}
@@ -0,0 +1,19 @@
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 + 9.4.b — модуль-прокси для публичного каталога apps-catalog.
*
* 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],
})
export class AppsCatalogProxyModule {}
@@ -0,0 +1,510 @@
import { Injectable, Logger } from '@nestjs/common';
import axios, { type AxiosInstance, AxiosError } from 'axios';
import { v4 as uuidv4 } from 'uuid';
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;
}
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 };
export interface CreateReleaseInput {
packageId: string;
version: string;
manifest: Record<string, unknown>;
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 };
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). Используется только сервером —
* ключ в браузер не утекает.
*
* 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<AppsCatalogPackage[]> {
if (!this.client) return [];
try {
const res = await this.client.get<PublicPackageWireFormat[]>(
'/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 [];
}
}
/**
* 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`).
*/
/**
* 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<RegisterPackageOutcome> {
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 };
}
}
/**
* 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<CreateReleaseOutcome> {
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 };
}
}
/**
* 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<ModerationRequestRow[]> {
if (!this.client) return [];
try {
const params: Record<string, string | number> = { 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<ApproveModerationOutcome> {
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<RejectModerationOutcome> {
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<string | null> {
if (!this.client) return null;
try {
const res = await this.client.get<string>(
`/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;
}
}
}
@@ -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,
],
};
}
@@ -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<ApolloDriverConfig>({
driver: ApolloDriver,
GraphQLModule.forRoot<ApolloFederationDriverConfig>({
driver: ApolloFederationDriver,
introspection: true,
autoSchemaFile: 'schema.gql',
autoSchemaFile: {
path: 'schema.gql',
federation: 2,
},
sortSchema: true,
debug: config.env !== 'production',
// context: ({ req }) => req,
@@ -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<IWorkspaceConfig[]> {
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'],
},
},
],
},
],
}];
}
@@ -0,0 +1 @@
export { default as DeveloperMyPackagesPage } from './DeveloperMyPackagesPage.vue';
@@ -0,0 +1,23 @@
<template>
<q-page padding>
<div class="q-pa-md">
<div class="text-h5 q-mb-md">Мои пакеты</div>
<q-banner class="bg-info text-white q-mb-md" rounded>
Стол разработчика, MVP-заглушка (Epic 9 story 9.3).
Здесь будет таблица пакетов вашего кооператива из apps-catalog.
</q-banner>
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle1">@voskhod/demoapp · 0.1.0</div>
<div class="text-caption text-grey">remote · 1000 RUB/мес</div>
</q-card-section>
</q-card>
</div>
</q-page>
</template>
<script setup lang="ts">
// Заглушка для V1 пилота Epic 9. Реальный запрос:
// GET /v1/admin/packages?owner=<chairman.coopname>
// будет добавлен в story 9.3.b вместе с резолверами controller'а.
</script>
@@ -0,0 +1 @@
export { default as DeveloperPricingPage } from './DeveloperPricingPage.vue';
@@ -0,0 +1,29 @@
<template>
<q-page padding>
<div class="q-pa-md">
<div class="text-h5 q-mb-md">Pricing</div>
<q-banner class="bg-info text-white q-mb-md" rounded>
Заглушка (Epic 9 story 9.3). Здесь будут карточки пакетов
с текущей ставкой (asset RUB/час), кнопкой «Изменить» (POST
/v1/admin/pricing) и графиком списаний за последние N тиков.
</q-banner>
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle2">Текущие ставки</div>
<q-list separator class="q-mt-md">
<q-item>
<q-item-section>
<q-item-label>@voskhod/demoapp · default</q-item-label>
<q-item-label caption>1.3889 RUB/h 1000 RUB/мес</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-card-section>
</q-card>
</div>
</q-page>
</template>
<script setup lang="ts">
// Заглушка для V1.
</script>
@@ -0,0 +1 @@
export { default as DeveloperPublishReleasePage } from './DeveloperPublishReleasePage.vue';
@@ -0,0 +1,26 @@
<template>
<q-page padding>
<div class="q-pa-md">
<div class="text-h5 q-mb-md">Опубликовать релиз</div>
<q-banner class="bg-info text-white q-mb-md" rounded>
Заглушка (Epic 9 story 9.3). Здесь будет форма выбора tarball'а
и POST на ca-admin /v1/admin/releases с подписью chairman'а.
</q-banner>
<q-card flat bordered>
<q-card-section>
<div class="text-subtitle2">Шаги публикации</div>
<ol class="text-body2 q-mt-md">
<li>Выбрать пакет из списка «Мои пакеты».</li>
<li>Загрузить tarball на Nexus (через ca-auth presigned PUT).</li>
<li>Отправить manifest + sha256 POST /v1/admin/releases.</li>
<li>Подписать setrelease через chairman key (двух-сиг).</li>
</ol>
</q-card-section>
</q-card>
</div>
</q-page>
</template>
<script setup lang="ts">
// Заглушка для V1.
</script>
@@ -0,0 +1 @@
export { default as DeveloperSubscribersPage } from './DeveloperSubscribersPage.vue';
@@ -0,0 +1,16 @@
<template>
<q-page padding>
<div class="q-pa-md">
<div class="text-h5 q-mb-md">Подписчики</div>
<q-banner class="bg-info text-white q-mb-md" rounded>
Заглушка (Epic 9 story 9.3). Список кооперативов-подписчиков
с разбивкой по пакетам, статусу подписки (active/trial/expired),
биллинг-периоду и сумме списания.
</q-banner>
</div>
</q-page>
</template>
<script setup lang="ts">
// Заглушка для V1.
</script>
@@ -0,0 +1,4 @@
export { DeveloperMyPackagesPage } from './DeveloperMyPackagesPage';
export { DeveloperPublishReleasePage } from './DeveloperPublishReleasePage';
export { DeveloperSubscribersPage } from './DeveloperSubscribersPage';
export { DeveloperPricingPage } from './DeveloperPricingPage';
@@ -27,7 +27,24 @@ async function loadExtensionLogs(data?: Queries.Extensions.GetExtensionLogs.IInp
return output;
}
async function loadAppsCatalogRemotePackages(
page = 1,
pageSize = 50,
): Promise<Queries.Extensions.AppsCatalogRemotePackages.IOutput[typeof Queries.Extensions.AppsCatalogRemotePackages.name]> {
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
}
@@ -1,38 +1,325 @@
<template lang="pug">
.catalog-grid
ExtensionCard(
v-for='extension in filteredExtensions',
:key='extension.name',
:extension='extension'
)
.epic9-hint
q-banner.bg-info.text-white.q-mb-md(rounded)
template(#avatar)
q-icon(name='fa-solid fa-rocket')
| Epic 9 магазин приложений. Bundle-расширения ниже нативные,
| оплачивать не нужно. Удалённые пакеты подгружаются из публичного
| каталога apps-catalog через проксирующий резолвер controller'а
| (Story 9.5.b).
section.catalog-section
h3.catalog-section__title Нативные расширения
.catalog-grid
ExtensionCard(
v-for='extension in filteredExtensions',
:key='extension.name',
:extension='extension'
)
section.catalog-section.catalog-section--remote
.catalog-section__head
h3.catalog-section__title Удалённые пакеты
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'
)
.remote-card__head
q-avatar(size='48px', color='deep-orange-1', text-color='deep-orange-10')
q-icon(name='fa-solid fa-cube' size='22px')
.remote-card__heading
h3.remote-card__title {{ pkg.title }}
span.badge.badge--info
q-icon(name='fa-solid fa-cloud-arrow-down' size='11px')
| Удалённый
p.remote-card__desc {{ pkg.description }}
.remote-card__meta
span.remote-card__publisher
q-icon(name='fa-solid fa-building' size='12px')
| {{ pkg.publisher }}
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,
color='primary',
label='Подписаться',
icon='fa-solid fa-plus',
size='sm',
@click='openSubscribe(pkg)'
)
q-dialog(v-model='subscribeDialog')
q-card.subscribe-dialog
q-card-section
.text-h6 Подписка на {{ pendingPkg?.title || 'пакет' }}
q-card-section.q-pt-none
p Стоимость: {{ pendingPkg ? formatPrice(pendingPkg.rubPerMonth) : '' }} ₽/мес.
| Период оплаты определяется globals.min_payment_period_seconds.
p.text-caption.q-mb-none
| Реальная подпись и трансфер AXON будут включены после 9.6.b
| (auto-extend self-subscription) — сейчас отображается информация
| из публичного каталога apps-catalog.
q-card-actions(align='right')
q-btn(flat, label='Закрыть', v-close-popup)
</template>
<script lang="ts" setup>
import { useExtensionStore } from 'src/entities/Extension/model';
import { useSystemStore } from 'src/entities/System/model';
import { onMounted, computed } from 'vue';
import { api as extensionApi } from 'src/entities/Extension/api';
import { onMounted, computed, ref } from 'vue';
import { ExtensionCard } from 'src/widgets/ExtensionCard';
import { Queries } from '@coopenomics/sdk';
type RemotePackage = Queries.Extensions.AppsCatalogRemotePackages.IOutput[
typeof Queries.Extensions.AppsCatalogRemotePackages.name
][number];
const extStore = useExtensionStore();
const systemStore = useSystemStore();
const filteredExtensions = computed(() => {
// Временно: скрываем расширение 'capital' для всех кооперативов кроме 'voskhod'
// TODO: убрать фильтрацию потом, когда станет доступно для всех
return extStore.extensions.filter(
(extension) => extension.name !== 'capital' || systemStore.info.coopname === 'voskhod',
);
});
const remoteExtensions = ref<RemotePackage[]>([]);
const remoteLoading = ref(false);
const remoteError = ref<string | null>(null);
const packagesWord = computed(() => {
const n = remoteExtensions.value.length;
const lastTwo = n % 100;
const last = n % 10;
if (lastTwo >= 11 && lastTwo <= 14) return 'пакетов';
if (last === 1) return 'пакет';
if (last >= 2 && last <= 4) return 'пакета';
return 'пакетов';
});
function formatPrice(rub: number): string {
return new Intl.NumberFormat('ru-RU').format(rub);
}
const subscribeDialog = ref(false);
const pendingPkg = ref<RemotePackage | null>(null);
function openSubscribe(pkg: RemotePackage) {
pendingPkg.value = pkg;
subscribeDialog.value = true;
}
async function loadRemote() {
remoteLoading.value = true;
remoteError.value = null;
try {
const list = await extensionApi.loadAppsCatalogRemotePackages(1, 50);
remoteExtensions.value = list;
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
remoteError.value = msg || 'Каталог apps-catalog недоступен';
remoteExtensions.value = [];
} finally {
remoteLoading.value = false;
}
}
onMounted(async () => {
extStore.loadExtensions();
await loadRemote();
});
</script>
<style scoped lang="scss">
.catalog-section {
margin-bottom: var(--p-6, 24px);
}
.catalog-section__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--p-3, 12px);
margin-bottom: var(--p-3, 12px);
}
.catalog-section__title {
margin: 0 0 var(--p-3, 12px);
font-size: var(--p-fs-h3);
font-weight: 600;
color: var(--p-ink);
}
.catalog-section__hint {
font-size: var(--p-fs-body-sm);
color: var(--p-ink-2);
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
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;
gap: var(--p-3, 12px);
padding: var(--p-5, 20px);
background: var(--p-surface, #fff);
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 {
display: flex;
align-items: flex-start;
gap: var(--p-3, 12px);
}
.remote-card__heading {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--p-1, 4px);
}
.remote-card__title {
margin: 0;
font-size: var(--p-fs-h3);
font-weight: 600;
color: var(--p-ink);
line-height: 1.3;
}
.remote-card__desc {
margin: 0;
font-size: var(--p-fs-body-sm);
line-height: 1.5;
color: var(--p-ink-2);
}
.remote-card__meta {
display: flex;
align-items: center;
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__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);
}
.remote-card__foot {
display: flex;
justify-content: flex-end;
}
.subscribe-dialog {
min-width: 360px;
}
</style>
@@ -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<string, () => Promise<IWorkspaceConfig[]
powerup: powerupInstall,
soviet: sovietInstall,
reports: reportsInstall,
developer: developerInstall,
};
/**
@@ -2,6 +2,7 @@ import type { Router } from 'vue-router';
import { useDesktopStore } from 'src/entities/Desktop/model';
import type { IWorkspaceConfig } from 'src/shared/lib/types/workspace';
import { extensionsRegistry, getAvailableExtensions } from './extensions-registry';
import { installRemoteExtensions } from './remote-loader';
export async function useInitExtensionsProcess(router: Router) {
const store = useDesktopStore();
@@ -44,7 +45,32 @@ export async function useInitExtensionsProcess(router: Router) {
}
}
// Epic 9 story 9.4 — remote-loader pass. Тянем пакеты с активной
// подпиской из apps-catalog. На V1 — заглушка, возвращает [];
// реальный fetch + eval — 9.4.b.
try {
const coopname = (store as any).currentCoopname ?? '';
const remoteConfigs: IWorkspaceConfig[] = await installRemoteExtensions(
coopname,
router,
);
for (const config of remoteConfigs) {
if (config?.workspace && config?.routes?.length) {
store.setRoutes(config.workspace, config.routes as any);
const baseRoute = router.getRoutes().find((r) => 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);
}
}
// Функция для динамической загрузки маршрутов конкретного расширения
@@ -0,0 +1,198 @@
// Epic 9 stories 9.4 (skeleton) + 9.4.b (real fetch+eval).
//
// На boot'е desktop'а, после регистрации bundle-extensions, идём в coopback
// за списком пакетов с активной подпиской у текущего кооператива и пробуем
// dynamic-eval'ом распаковать install.js из каждого пакета, чтобы получить
// `IWorkspaceConfig[]` и добавить роуты в router.
//
// 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<IWorkspaceConfig[]> }`.
// - Try/catch per item: падение одного пакета не валит остальные.
//
// Изоляция: 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;
scope: string;
name: string;
publisher: string;
version: string | null;
title: string;
description: string;
}
interface RemoteInstallModule {
install?: () => Promise<IWorkspaceConfig[]> | IWorkspaceConfig[];
default?: () => Promise<IWorkspaceConfig[]> | IWorkspaceConfig[];
}
/**
* Распилить `@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,
): Promise<RemoteExtensionDescriptor[]> {
// 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 [];
}
}
/**
* Скачать install.js пакета и eval'нуть его в plain CJS-обёртке.
* Возвращает массив workspace'ов, который install() пакета вернул.
*
* На MVP sha256 не валидируется; manifest / release-row появятся в 9.4.c.
*/
export async function loadRemoteExtension(
desc: RemoteExtensionDescriptor,
): Promise<IWorkspaceConfig[]> {
const { tokens } = useGlobalStore();
const url = `${env.BACKEND_URL}/v1/apps-catalog/install/${encodeURIComponent(
desc.scope,
)}/${encodeURIComponent(desc.name)}`;
const response = await axios.get<string>(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;
}
/**
* Один pass remote-loader'а: получить список → загрузить каждый → отдать
* слитый массив workspace'ов вызывателю, который сам впишет их в store/router.
*
* Падение одного пакета не должно валить остальные (try/catch per item).
*/
export async function installRemoteExtensions(
coopname: string,
router: Router,
): Promise<IWorkspaceConfig[]> {
// 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}:`,
err,
);
}
}
return all;
}
@@ -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"
}
}
@@ -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 {}
@@ -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',
};
}
}
@@ -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);
});
@@ -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/**/*"]
}
+89
View File
@@ -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<string[]> {
return this.chat.listThreads(cooperator.username);
}
}
```
После build'а:
- `docker build` через `templates/Dockerfile`
- `docker push registry.coopenomics.world/<scope>/<name>:<version>`
- `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) | |
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@coopenomics/extension-sdk",
"version": "0.1.0",
"description": "SDK для разработки backend-расширений Apollo Federation v2 subgraph'ов экосистемы кооперативов",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist",
"templates",
"README.md"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"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"
}
}
@@ -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],
};
}
}
@@ -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;
}
}
@@ -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<ExtensionJwtPayload> {
return payload;
}
}
@@ -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,
};
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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<ApolloFederationDriverConfig>({
driver: ApolloFederationDriver,
path,
introspection,
playground,
autoSchemaFile: {
path: schemaFile,
federation: 2,
},
sortSchema: true,
}),
],
exports: [GraphQLModule],
};
}
}
@@ -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),
};
}
}
+44
View File
@@ -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';
@@ -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"]
@@ -0,0 +1,30 @@
# Snippet, который orchestrator добавляет в общий docker-compose.yaml DC.
#
# Имя service'а = `ext-<packageId-без-scope>` (например `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
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"declaration": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src/**/*"]
}
+15
View File
@@ -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'],
};
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@coopenomics/orchestrator",
"version": "0.1.0",
"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",
"test": "jest --config jest.config.js"
},
"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/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"
}
}
+25
View File
@@ -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 {}
@@ -0,0 +1,53 @@
/**
* ENV-конфигурация 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(`[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),
};
}
@@ -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<ApolloGatewayDriverConfig>({
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)
// потребует рестарта контейнера 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<string, string> } })?.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 {}
@@ -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;
}
@@ -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<SubgraphRegistryEntity>,
) {}
/**
* Список active subgraph'ов в формате который ждёт Apollo Gateway's
* IntrospectAndCompose. На bootstrap'е orchestrator'а должна быть как
* минимум одна запись (core), иначе gateway не скомпонует ничего.
*/
async listForCompose(): Promise<SubgraphDescriptor[]> {
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<void> {
await this.repo.upsert(
{
packageId,
version,
url,
active: true,
healthStatus: 'unknown',
},
['packageId'],
);
}
async deactivate(packageId: string): Promise<void> {
await this.repo.update({ packageId }, { active: false });
}
async setHealthStatus(packageId: string, healthStatus: string): Promise<void> {
await this.repo.update({ packageId }, { healthStatus });
}
}
+54
View File
@@ -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<typeof loadAppConfig>) {
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(
`[orchestrator] up at :${cfg.port} ` +
`(coopname=${cfg.coopname}, core=${cfg.coreSubgraphUrl})`,
);
}
bootstrap().catch((err) => {
// eslint-disable-next-line no-console
console.error('[orchestrator] failed to bootstrap:', err);
process.exit(1);
});
@@ -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,
};
}
}
@@ -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<void> {
/*
* 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<void> {
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<void> {
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<void> {
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}`);
}
}
}
@@ -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<HealthOutcome> {
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<void> => new Promise((res) => setTimeout(res, ms));
@@ -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<void> {
this.pulls.push(opts);
if (this.pullError) throw this.pullError;
}
async composeUp(opts: { composeFile: string; serviceName: string }): Promise<void> {
this.ups.push(opts);
if (this.upError) throw this.upError;
}
async composeDown(opts: { composeFile: string; serviceName: string }): Promise<void> {
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<HealthOutcome> {
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<string> {
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<void> {
this.upserts.push({ packageId, version, url });
if (this.upsertError) throw this.upsertError;
}
async setHealthStatus(packageId: string, healthStatus: string): Promise<void> {
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 }]);
});
});
@@ -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 <svc>`.
*/
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<InstallOutcome> {
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<void> {
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 };
}
}
@@ -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<InstallOutcome> {
return this.orchestrator.install(input);
}
@Delete('uninstall/:packageId')
async uninstall(@Param('packageId') packageId: string): Promise<UninstallResult> {
await this.registry.deactivate(packageId);
return { ok: true, deactivated: packageId };
}
}
@@ -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:<packageId>:pull`
* Авторизация: `Authorization: Bearer <cooperativeJwt>` — это 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<string> {
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);
}
}
}
@@ -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 {}
@@ -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<void>;
/** Поднять сервис compose-проекта (idempotent — повторный вызов no-op если up). */
composeUp(opts: { composeFile: string; serviceName: string }): Promise<void>;
/** Снять сервис compose-проекта — используется на rollback. */
composeDown(opts: { composeFile: string; serviceName: string }): Promise<void>;
}
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<HealthOutcome>;
}
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:<packageId>: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<string>;
}
export const OCI_TOKEN_CLIENT = Symbol('OciTokenClientPort');
+19
View File
@@ -0,0 +1,19 @@
{
"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",
"types": ["node", "jest"]
},
"include": ["src/**/*"],
"exclude": ["src/**/*.spec.ts", "dist", "node_modules"]
}
@@ -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<GraphQLTypes['Query'], typeof query>
@@ -1,3 +1,6 @@
/** Публичный каталог remote-пакетов apps-catalog (Story 9.5.b) */
export * as AppsCatalogRemotePackages from './appsCatalogRemotePackages'
/** Получить логи расширений с фильтрацией и пагинацией */
export * as GetExtensionLogs from './getExtensionLogs'
@@ -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<ValueTypes['AppsCatalogRemotePackageDTO']> = rawAppsCatalogRemotePackageSelector
export type appsCatalogRemotePackageModel = ModelTypes['AppsCatalogRemotePackageDTO']
export const appsCatalogRemotePackageSelector = Selector('AppsCatalogRemotePackageDTO')(rawAppsCatalogRemotePackageSelector)
export { rawAppsCatalogRemotePackageSelector }
@@ -1 +1,2 @@
export * from './extensionSelector'
export * from './appsCatalogRemotePackageSelector'
export * from './extensionSelector'
+13
View File
@@ -1337,6 +1337,9 @@ export const AllTypesProps: Record<string,any> = {
agreements:{
filter:"AgreementFilter",
options:"PaginationInput"
},
appsCatalogRemotePackages:{
},
buildInitialReportEdits:{
reportType:"ReportType"
@@ -2040,6 +2043,15 @@ export const ReturnTypes: Record<string,any> = {
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<string,any> = {
Query:{
agreementTemplates:"AgreementTemplate",
agreements:"PaginatedAgreementsPaginationResult",
appsCatalogRemotePackages:"AppsCatalogRemotePackageDTO",
buildInitialReportEdits:"BuildInitialReportEdits",
candidates:"PaginatedCandidatesPaginationResult",
capitalCandidates:"PaginatedCapitalCandidatesPaginationResult",
+75
View File
@@ -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<ValueTypes["AppsCatalogRemotePackageDTO"], "...on AppsCatalogRemotePackageDTO">
}>;
["AssetContributionActGenerateDocumentInput"]: {
/** Идентификатор акта */
act_id: string | Variable<any, string>,
@@ -7769,6 +7787,7 @@ walmoveWallets?: [{ input: ValueTypes["WalmoveInput"] | Variable<any, string>},V
["Query"]: AliasType<{
agreementTemplates?: [{ coopname: string | Variable<any, string>},ValueTypes["AgreementTemplate"]],
agreements?: [{ filter?: ValueTypes["AgreementFilter"] | undefined | null | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedAgreementsPaginationResult"]],
appsCatalogRemotePackages?: [{ page: number | Variable<any, string>, pageSize: number | Variable<any, string>},ValueTypes["AppsCatalogRemotePackageDTO"]],
buildInitialReportEdits?: [{ period?: number | undefined | null | Variable<any, string>, reportType: ValueTypes["ReportType"] | Variable<any, string>, year: number | Variable<any, string>},ValueTypes["BuildInitialReportEdits"]],
candidates?: [{ filter?: ValueTypes["CandidateFilterInput"] | undefined | null | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},ValueTypes["PaginatedCandidatesPaginationResult"]],
capitalCandidates?: [{ filter?: ValueTypes["CandidateFilterInput"] | undefined | null | Variable<any, string>, options?: ValueTypes["PaginationInput"] | undefined | null | Variable<any, string>},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<string>,
/** Краткое описание (в 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<ModelTypes["AgreementTemplate"]>,
/** Получение списка соглашений с фильтрацией и пагинацией */
agreements: ModelTypes["PaginatedAgreementsPaginationResult"],
/** Список remote-пакетов из публичного каталога apps-catalog. Защищён JWT (видят только авторизованные пайщики). Источник — ca-admin /v1/public/packages; controller проксирует. */
appsCatalogRemotePackages: Array<ModelTypes["AppsCatalogRemotePackageDTO"]>,
/** Построить предзаполненные edits для формы: дефолты (ledger2 + реквизиты + корректировки), с наложением dirty-полей существующего черновика (если он есть).
Требуемые роли: chairman. */
@@ -27110,6 +27165,24 @@ export type GraphQLTypes = {
};
/** Статус одобрения в системе CHAIRMAN */
["ApprovalStatus"]: ApprovalStatus;
["AppsCatalogRemotePackageDTO"]: {
__typename: "AppsCatalogRemotePackageDTO",
/** Совместимые subnet (chain_id блокчейна ЦК) */
compatibleSubnets: Array<string>,
/** Краткое описание (в 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<GraphQLTypes["AppsCatalogRemotePackageDTO"], "...on AppsCatalogRemotePackageDTO">
};
["AssetContributionActGenerateDocumentInput"]: {
/** Идентификатор акта */
act_id: string,
@@ -33622,6 +33695,8 @@ export type GraphQLTypes = {
agreementTemplates: Array<GraphQLTypes["AgreementTemplate"]>,
/** Получение списка соглашений с фильтрацией и пагинацией */
agreements: GraphQLTypes["PaginatedAgreementsPaginationResult"],
/** Список remote-пакетов из публичного каталога apps-catalog. Защищён JWT (видят только авторизованные пайщики). Источник — ca-admin /v1/public/packages; controller проксирует. */
appsCatalogRemotePackages: Array<GraphQLTypes["AppsCatalogRemotePackageDTO"]>,
/** Построить предзаполненные edits для формы: дефолты (ledger2 + реквизиты + корректировки), с наложением dirty-полей существующего черновика (если он есть).
Требуемые роли: chairman. */
@@ -0,0 +1,280 @@
# 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<string, () => Promise<IWorkspaceConfig[]>>`.
- 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<IWorkspaceConfig[]>` (как сейчас, 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/<package>@<version>/`.
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/<name>` (org scope = монорепозиторий). Если кооператив хочет форкнуть — он публикует под своим scope `@voskhod/<name>` с другим 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. Решение V1: не мигрируем, делаем демо-пилот
**Утверждено 2026-06-02.** Native extensions трогать не будем. Вместо bundle→remote миграции собираем минимальное демо-приложение, ставим его сами как внешний разработчик и проводим полный цикл с оплатой. Это снимает риски с native и подтверждает контур end-to-end.
### Решения по содержанию пилота
| Тема | Решение | Замечание |
|---|---|---|
| Что делает 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'а).
+1026 -109
View File
File diff suppressed because it is too large Load Diff