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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
+59
@@ -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;
|
||||
}
|
||||
}
|
||||
+29
@@ -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;
|
||||
}
|
||||
+51
@@ -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;
|
||||
}
|
||||
+43
@@ -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;
|
||||
}
|
||||
+75
@@ -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;
|
||||
}
|
||||
+37
@@ -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[];
|
||||
}
|
||||
+27
@@ -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;
|
||||
}
|
||||
+44
@@ -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;
|
||||
}
|
||||
+40
@@ -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;
|
||||
}
|
||||
+18
@@ -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;
|
||||
}
|
||||
+34
@@ -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;
|
||||
}
|
||||
+51
@@ -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[];
|
||||
}
|
||||
+374
@@ -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 {}
|
||||
+510
@@ -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
|
||||
}
|
||||
|
||||
+296
-9
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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) | |
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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/**/*"]
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
@@ -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'
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'а).
|
||||
Generated
+1026
-109
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user