[E12-2][@ant] feat(orchestrator): volume-кэш фронт-частей расширений — JWT-gated доставка вместо публичного каталога
install.js активного релиза качается с ca-admin при установке/обновлении
пакета (watcher-события подписки/релиза), проверяется по sha256
(заголовок ca-admin + независимая сверка с coopenomics.frontend.installSha256
из манифеста в ca-auth registry, fail-closed) и складывается в volume.
Endpoints /v1/internal/extensions/frontend{,/:scope/:name/install.js} —
источник для coopback-прокси (E12-3): desktop получает только фронты
реально установленных у кооператива пакетов.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @fileoverview Юнит-тесты CaAdminInstallSource (E12-2) — транспорт
|
||||
* подменяется fetch-стабом, сети нет.
|
||||
*/
|
||||
import { CaAdminInstallSource, NoopInstallSource } from './ca-admin-install-source.impl';
|
||||
|
||||
interface RecordedRequest {
|
||||
url: string;
|
||||
init: RequestInit;
|
||||
}
|
||||
|
||||
class StubbedSource extends CaAdminInstallSource {
|
||||
requests: RecordedRequest[] = [];
|
||||
constructor(private readonly handler: () => Promise<Response>) {
|
||||
super({ baseUrl: 'http://ca-admin:3000', apiKey: 'admin-key' });
|
||||
}
|
||||
protected override fetch(url: string, init: RequestInit): Promise<Response> {
|
||||
this.requests.push({ url, init });
|
||||
return this.handler();
|
||||
}
|
||||
}
|
||||
|
||||
describe('CaAdminInstallSource', () => {
|
||||
it('200 → ok с телом и заголовками sha256/version; путь и Bearer корректны', async () => {
|
||||
const source = new StubbedSource(async () =>
|
||||
new Response('module.exports = 1;', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'X-Install-Script-Sha256': 'a'.repeat(64),
|
||||
'X-Package-Version': '1.2.0',
|
||||
},
|
||||
}),
|
||||
);
|
||||
const outcome = await source.fetchInstallScript('voskhod', 'demo-app');
|
||||
expect(outcome.status).toBe('ok');
|
||||
if (outcome.status === 'ok') {
|
||||
expect(outcome.content.toString()).toBe('module.exports = 1;');
|
||||
expect(outcome.declaredSha256).toBe('a'.repeat(64));
|
||||
expect(outcome.version).toBe('1.2.0');
|
||||
}
|
||||
expect(source.requests[0]?.url).toBe(
|
||||
'http://ca-admin:3000/v1/public/packages/voskhod/demo-app/install.js',
|
||||
);
|
||||
expect(
|
||||
(source.requests[0]?.init.headers as Record<string, string>).Authorization,
|
||||
).toBe('Bearer admin-key');
|
||||
});
|
||||
|
||||
it('200 без заголовков → ok с null-полями (кэш примет по содержимому)', async () => {
|
||||
const source = new StubbedSource(async () => new Response('x', { status: 200 }));
|
||||
const outcome = await source.fetchInstallScript('voskhod', 'demo-app');
|
||||
expect(outcome.status).toBe('ok');
|
||||
if (outcome.status === 'ok') {
|
||||
expect(outcome.declaredSha256).toBeNull();
|
||||
expect(outcome.version).toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it('404 → notFound; 503 → unavailable; сеть → unavailable', async () => {
|
||||
const nf = new StubbedSource(async () => new Response('no', { status: 404 }));
|
||||
expect((await nf.fetchInstallScript('voskhod', 'gone')).status).toBe('notFound');
|
||||
|
||||
const down = new StubbedSource(async () => new Response('oops', { status: 503 }));
|
||||
expect((await down.fetchInstallScript('voskhod', 'demo-app')).status).toBe('unavailable');
|
||||
|
||||
const net = new StubbedSource(async () => {
|
||||
throw new Error('ECONNREFUSED');
|
||||
});
|
||||
expect((await net.fetchInstallScript('voskhod', 'demo-app')).status).toBe('unavailable');
|
||||
});
|
||||
|
||||
it('NoopInstallSource → unavailable с понятной причиной', async () => {
|
||||
const outcome = await new NoopInstallSource().fetchInstallScript();
|
||||
expect(outcome.status).toBe('unavailable');
|
||||
if (outcome.status === 'unavailable') {
|
||||
expect(outcome.reason).toMatch(/APPS_CATALOG_URL/);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* @fileoverview Реальный импл {@link FrontendInstallSourcePort} — E12-2.
|
||||
*
|
||||
* Бьёт в ca-admin `GET /v1/public/packages/:scope/:name/install.js`
|
||||
* (E12-1: источник — npm-tarball активного релиза) с admin API key
|
||||
* контура — те же env-имена, что у coopback-прокси
|
||||
* (`APPS_CATALOG_URL` / `APPS_CATALOG_API_KEY`).
|
||||
*
|
||||
* Маппинг ответов:
|
||||
* - 200 → ok + заголовки X-Install-Script-Sha256 / X-Package-Version;
|
||||
* - 404 → notFound (нет активного релиза или пакет backend-only);
|
||||
* - прочее/сеть/timeout → unavailable (кэш не трогаем).
|
||||
*/
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { FrontendInstallFetchOutcome, FrontendInstallSourcePort } from './ports';
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
export interface CaAdminInstallSourceConfig {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
export class CaAdminInstallSource implements FrontendInstallSourcePort {
|
||||
private readonly logger = new Logger(CaAdminInstallSource.name);
|
||||
|
||||
constructor(private readonly cfg: CaAdminInstallSourceConfig) {}
|
||||
|
||||
async fetchInstallScript(
|
||||
scope: string,
|
||||
name: string,
|
||||
): Promise<FrontendInstallFetchOutcome> {
|
||||
const path = `/v1/public/packages/${encodeURIComponent(scope)}/${encodeURIComponent(name)}/install.js`;
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await this.fetch(`${this.cfg.baseUrl}${path}`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${this.cfg.apiKey}` },
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
if (resp.status === 404) {
|
||||
return { status: 'notFound', reason: `ca-admin ${path} → 404` };
|
||||
}
|
||||
if (!resp.ok) {
|
||||
return { status: 'unavailable', reason: `ca-admin ${path} → HTTP ${resp.status}` };
|
||||
}
|
||||
const content = Buffer.from(await resp.arrayBuffer());
|
||||
return {
|
||||
status: 'ok',
|
||||
content,
|
||||
declaredSha256: resp.headers.get('x-install-script-sha256'),
|
||||
version: resp.headers.get('x-package-version'),
|
||||
};
|
||||
} catch (e) {
|
||||
const reason = e instanceof Error ? e.message : String(e);
|
||||
this.logger.warn(`install.js fetch @${scope}/${name} failed: ${reason}`);
|
||||
return { status: 'unavailable', reason };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal protected — тесты подменяют транспорт без сети. */
|
||||
protected fetch(url: string, init: RequestInit): Promise<Response> {
|
||||
return fetch(url, init);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Noop-источник для degraded mode (env каталога не задан): всегда
|
||||
* `unavailable`, чтобы кэш жил на том, что уже на диске, и стенд без
|
||||
* каталога стартовал без ошибок.
|
||||
*/
|
||||
export class NoopInstallSource implements FrontendInstallSourcePort {
|
||||
async fetchInstallScript(): Promise<FrontendInstallFetchOutcome> {
|
||||
return {
|
||||
status: 'unavailable',
|
||||
reason: 'APPS_CATALOG_URL/APPS_CATALOG_API_KEY не заданы — источник install.js отключён',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview REST-доступ coopback'а к кэшу фронт-частей (E12-2).
|
||||
*
|
||||
* Endpoints (внутренние, docker-сеть контура — как `/v1/internal/composition`;
|
||||
* наружу orchestrator опубликован только на 127.0.0.1 хоста):
|
||||
*
|
||||
* - `GET /v1/internal/extensions/frontend` — список фактически
|
||||
* установленных фронтов ({@link CachedFrontendMeta}[]);
|
||||
* - `GET /v1/internal/extensions/frontend/:scope/:name/install.js` —
|
||||
* отдача install.js из кэша + `X-Install-Script-Sha256` /
|
||||
* `X-Package-Version` для сверки на desktop перед eval.
|
||||
*
|
||||
* JWT-gate пайщика — на стороне coopback (E12-3): он проксирует эти
|
||||
* endpoints через свой HttpJwtAuthGuard и не выставляет orchestrator
|
||||
* наружу.
|
||||
*/
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Header,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Param,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { FrontendCacheService } from './frontend-cache.service';
|
||||
import type { CachedFrontendMeta } from './ports';
|
||||
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
/**
|
||||
* Минимум express-Response, который тут нужен. Пакет @types/express в
|
||||
* orchestrator не подключён (другие контроллеры отдают plain JSON), ради
|
||||
* двух заголовков его не тянем.
|
||||
*/
|
||||
interface HeaderSettableResponse {
|
||||
setHeader(name: string, value: string): void;
|
||||
status(code: number): unknown;
|
||||
}
|
||||
|
||||
@Controller('v1/internal/extensions/frontend')
|
||||
export class FrontendCacheController {
|
||||
constructor(private readonly cache: FrontendCacheService) {}
|
||||
|
||||
@Get()
|
||||
async list(): Promise<{ items: CachedFrontendMeta[] }> {
|
||||
return { items: await this.cache.list() };
|
||||
}
|
||||
|
||||
@Get(':scope/:name/install.js')
|
||||
@Header('Content-Type', 'application/javascript; charset=utf-8')
|
||||
@Header('Cache-Control', 'no-store')
|
||||
async installScript(
|
||||
@Param('scope') scope: string,
|
||||
@Param('name') name: string,
|
||||
@Res({ passthrough: true }) res: HeaderSettableResponse,
|
||||
): Promise<string> {
|
||||
if (!NAME_REGEX.test(scope) || !NAME_REGEX.test(name)) {
|
||||
throw new HttpException('Invalid package coordinates', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const cached = await this.cache.read(scope, name);
|
||||
if (cached === null) {
|
||||
throw new HttpException('frontend not installed', HttpStatus.NOT_FOUND);
|
||||
}
|
||||
res.setHeader('X-Install-Script-Sha256', cached.meta.sha256);
|
||||
res.setHeader('X-Package-Version', cached.meta.version);
|
||||
res.status(HttpStatus.OK);
|
||||
return cached.content.toString('utf8');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview Nest-модуль кэша фронт-частей расширений (E12-2).
|
||||
*
|
||||
* Конфигурация через ENV (все опциональны — degraded mode без каталога):
|
||||
*
|
||||
* - `APPS_CATALOG_URL` + `APPS_CATALOG_API_KEY` — ca-admin контура
|
||||
* (те же имена, что у coopback-прокси); без них источник Noop —
|
||||
* кэш живёт на том, что уже на диске;
|
||||
* - `FRONTEND_CACHE_DIR` — каталог volume-кэша
|
||||
* (default `/var/orchestrator/frontend-cache`);
|
||||
* - `COOPERATIVE_WIF` + `CA_AUTH_BASE_URL` — включают независимую
|
||||
* sha256-сверку с манифестом в ca-auth registry (тот же signed-request
|
||||
* канал, что у auto-install watcher'а); без них принимаем по
|
||||
* заголовку ca-admin.
|
||||
*
|
||||
* Свой экземпляр {@link CaAuthReleaseMetadata} (а не общий с WatcherModule):
|
||||
* Nest резолвит провайдеры в scope модуля-владельца, а WatcherModule
|
||||
* импортирует этот модуль — обратная зависимость дала бы цикл. Класс
|
||||
* stateless, второй экземпляр ничего не стоит.
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { loadAppConfig } from '../config/app-config';
|
||||
import { CaAuthReleaseMetadata } from '../watcher/ca-auth-release-metadata.impl';
|
||||
import { CaAdminInstallSource, NoopInstallSource } from './ca-admin-install-source.impl';
|
||||
import { FrontendCacheController } from './frontend-cache.controller';
|
||||
import { FRONTEND_CACHE_DIR, FrontendCacheService } from './frontend-cache.service';
|
||||
import {
|
||||
FRONTEND_INSTALL_SOURCE,
|
||||
FRONTEND_MANIFEST_VERIFIER,
|
||||
FrontendInstallSourcePort,
|
||||
FrontendManifestVerifierPort,
|
||||
} from './ports';
|
||||
|
||||
@Module({
|
||||
controllers: [FrontendCacheController],
|
||||
providers: [
|
||||
FrontendCacheService,
|
||||
{
|
||||
provide: FRONTEND_CACHE_DIR,
|
||||
useFactory: (): string =>
|
||||
process.env.FRONTEND_CACHE_DIR ?? '/var/orchestrator/frontend-cache',
|
||||
},
|
||||
{
|
||||
provide: FRONTEND_INSTALL_SOURCE,
|
||||
useFactory: (): FrontendInstallSourcePort => {
|
||||
const baseUrl = process.env.APPS_CATALOG_URL;
|
||||
const apiKey = process.env.APPS_CATALOG_API_KEY;
|
||||
if (!baseUrl || !apiKey) return new NoopInstallSource();
|
||||
return new CaAdminInstallSource({ baseUrl, apiKey });
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: FRONTEND_MANIFEST_VERIFIER,
|
||||
useFactory: (): FrontendManifestVerifierPort | null => {
|
||||
const wif = process.env.COOPERATIVE_WIF;
|
||||
const caAuthBaseUrl = process.env.CA_AUTH_BASE_URL;
|
||||
if (!wif || !caAuthBaseUrl) return null;
|
||||
const cfg = loadAppConfig();
|
||||
return new CaAuthReleaseMetadata({
|
||||
caAuthBaseUrl,
|
||||
coopname: cfg.coopname,
|
||||
cooperativeWif: wif,
|
||||
jwtSecret: cfg.jwtSecret,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [FrontendCacheService],
|
||||
})
|
||||
export class FrontendCacheModule {}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @fileoverview Юнит-тесты FrontendCacheService (E12-2) — на настоящей ФС
|
||||
* (mkdtemp), source/verifier — фейки.
|
||||
*
|
||||
* Покрытие:
|
||||
* - ok → запись install.js+meta.json, list/read/isCached видят;
|
||||
* - sha256 содержимого ≠ X-Install-Script-Sha256 → rejected, кэш пуст;
|
||||
* - manifest-декларация ≠ sha256 → rejected; старый кэш не затёрт;
|
||||
* - verifier бросил (сеть) → fail-closed rejected, старый кэш жив;
|
||||
* - verifier вернул null (sha не декларирован) → cached;
|
||||
* - notFound от источника → evict существующей записи;
|
||||
* - unavailable → skipped, существующая запись жива;
|
||||
* - evict идемпотентен; read невалидных координат → null;
|
||||
* - packageId без @scope/name → skipped.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { FrontendCacheService } from './frontend-cache.service';
|
||||
import type {
|
||||
FrontendInstallFetchOutcome,
|
||||
FrontendInstallSourcePort,
|
||||
FrontendManifestVerifierPort,
|
||||
} from './ports';
|
||||
|
||||
const PKG = '@voskhod/demo-app';
|
||||
const CODE = Buffer.from('module.exports = { install: () => [] };\n');
|
||||
const SHA = createHash('sha256').update(CODE).digest('hex');
|
||||
|
||||
class FakeSource implements FrontendInstallSourcePort {
|
||||
outcome: FrontendInstallFetchOutcome = {
|
||||
status: 'ok',
|
||||
content: CODE,
|
||||
declaredSha256: SHA,
|
||||
version: '1.2.0',
|
||||
};
|
||||
calls: Array<{ scope: string; name: string }> = [];
|
||||
async fetchInstallScript(scope: string, name: string): Promise<FrontendInstallFetchOutcome> {
|
||||
this.calls.push({ scope, name });
|
||||
return this.outcome;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeVerifier implements FrontendManifestVerifierPort {
|
||||
sha: string | null = null;
|
||||
error: Error | null = null;
|
||||
calls: Array<{ packageId: string; version: string }> = [];
|
||||
async fetchInstallSha256(packageId: string, version: string): Promise<string | null> {
|
||||
this.calls.push({ packageId, version });
|
||||
if (this.error !== null) throw this.error;
|
||||
return this.sha;
|
||||
}
|
||||
}
|
||||
|
||||
describe('FrontendCacheService', () => {
|
||||
let dir: string;
|
||||
let source: FakeSource;
|
||||
let verifier: FakeVerifier;
|
||||
let service: FrontendCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(path.join(tmpdir(), 'fe-cache-'));
|
||||
source = new FakeSource();
|
||||
verifier = new FakeVerifier();
|
||||
service = new FrontendCacheService(dir, source, verifier);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('ok → кэширует, list/read/isCached видят запись с версией и sha', async () => {
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('cached');
|
||||
expect(source.calls).toEqual([{ scope: 'voskhod', name: 'demo-app' }]);
|
||||
|
||||
expect(await service.isCached(PKG)).toBe(true);
|
||||
const list = await service.list();
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0]).toMatchObject({
|
||||
packageId: PKG,
|
||||
scope: 'voskhod',
|
||||
name: 'demo-app',
|
||||
version: '1.2.0',
|
||||
sha256: SHA,
|
||||
});
|
||||
|
||||
const read = await service.read('voskhod', 'demo-app');
|
||||
expect(read?.content.equals(CODE)).toBe(true);
|
||||
expect(read?.meta.sha256).toBe(SHA);
|
||||
});
|
||||
|
||||
it('sha256 ≠ заголовку ca-admin → rejected, в кэш ничего не пишется', async () => {
|
||||
source.outcome = {
|
||||
status: 'ok',
|
||||
content: CODE,
|
||||
declaredSha256: 'f'.repeat(64),
|
||||
version: '1.2.0',
|
||||
};
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('rejected');
|
||||
expect(await service.isCached(PKG)).toBe(false);
|
||||
});
|
||||
|
||||
it('manifest-декларация ≠ sha256 → rejected; старая версия в кэше живёт', async () => {
|
||||
await service.syncPackage(PKG); // прогрев валидной версией
|
||||
|
||||
verifier.sha = 'a'.repeat(64);
|
||||
source.outcome = {
|
||||
status: 'ok',
|
||||
content: Buffer.from('tampered'),
|
||||
declaredSha256: createHash('sha256').update('tampered').digest('hex'),
|
||||
version: '1.3.0',
|
||||
};
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('rejected');
|
||||
|
||||
const read = await service.read('voskhod', 'demo-app');
|
||||
expect(read?.meta.version).toBe('1.2.0');
|
||||
expect(read?.content.equals(CODE)).toBe(true);
|
||||
});
|
||||
|
||||
it('verifier бросил → fail-closed rejected, старый кэш жив', async () => {
|
||||
await service.syncPackage(PKG);
|
||||
verifier.error = new Error('ECONNREFUSED ca-auth');
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('rejected');
|
||||
expect((await service.read('voskhod', 'demo-app'))?.meta.version).toBe('1.2.0');
|
||||
});
|
||||
|
||||
it('verifier вернул null (sha не декларирован) → cached', async () => {
|
||||
verifier.sha = null;
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('cached');
|
||||
expect(verifier.calls).toEqual([{ packageId: PKG, version: '1.2.0' }]);
|
||||
});
|
||||
|
||||
it('без verifier (degraded) → принимаем по заголовку', async () => {
|
||||
const noVerifier = new FrontendCacheService(dir, source, null);
|
||||
expect((await noVerifier.syncPackage(PKG)).status).toBe('cached');
|
||||
});
|
||||
|
||||
it('notFound от источника → существующая запись evict-ится', async () => {
|
||||
await service.syncPackage(PKG);
|
||||
source.outcome = { status: 'notFound', reason: '404' };
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('evicted');
|
||||
expect(await service.isCached(PKG)).toBe(false);
|
||||
expect(await service.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('unavailable → skipped, существующая запись остаётся', async () => {
|
||||
await service.syncPackage(PKG);
|
||||
source.outcome = { status: 'unavailable', reason: 'каталог лёг' };
|
||||
const outcome = await service.syncPackage(PKG);
|
||||
expect(outcome.status).toBe('skipped');
|
||||
expect(await service.isCached(PKG)).toBe(true);
|
||||
});
|
||||
|
||||
it('evict идемпотентен; read мусорных координат → null', async () => {
|
||||
await service.evict(PKG); // ничего нет — не падает
|
||||
await service.syncPackage(PKG);
|
||||
await service.evict(PKG);
|
||||
await service.evict(PKG);
|
||||
expect(await service.isCached(PKG)).toBe(false);
|
||||
expect(await service.read('..', 'demo-app')).toBeNull();
|
||||
expect(await service.read('voskhod', '../../etc')).toBeNull();
|
||||
});
|
||||
|
||||
it('packageId без @scope/name → skipped, источник не дёргается', async () => {
|
||||
const outcome = await service.syncPackage('plain-name');
|
||||
expect(outcome.status).toBe('skipped');
|
||||
expect(source.calls).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* @fileoverview Volume-кэш фронт-частей расширений (E12-2).
|
||||
*
|
||||
* Watcher зовёт {@link FrontendCacheService.syncPackage} при установке /
|
||||
* обновлении / отзыве пакета; coopback читает кэш через REST
|
||||
* (`/v1/internal/extensions/frontend`, см. FrontendCacheController).
|
||||
* Так desktop получает только фронты пакетов, реально установленных у
|
||||
* кооператива (JWT-gated доставка), а не публичный каталог целиком.
|
||||
*
|
||||
* Раскладка на диске (FRONTEND_CACHE_DIR, volume — переживает рестарт):
|
||||
*
|
||||
* <dir>/<scope>__<name>/install.js
|
||||
* <dir>/<scope>__<name>/meta.json ({@link CachedFrontendMeta})
|
||||
*
|
||||
* Цепочка проверки целостности перед записью в кэш:
|
||||
* 1. sha256 содержимого == `X-Install-Script-Sha256` ca-admin
|
||||
* (транспорт не побился) — расхождение → reject;
|
||||
* 2. sha256 == `coopenomics.frontend.installSha256` из npm-манифеста
|
||||
* версии в ca-auth registry (независимый канал по подписке) —
|
||||
* декларация есть и не совпала → reject; verifier недоступен →
|
||||
* fail-closed reject (непроверенный код не кэшируем); пакет не
|
||||
* декларирует sha — принимаем по п.1.
|
||||
*
|
||||
* Семантика syncPackage по ответу источника:
|
||||
* - ok → атомарная запись (tmp + rename);
|
||||
* - notFound (релиз отозван / фронт-части больше нет) → evict;
|
||||
* - unavailable → кэш не трогаем, живём на старой версии.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdir, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
|
||||
import {
|
||||
CachedFrontendMeta,
|
||||
FRONTEND_INSTALL_SOURCE,
|
||||
FRONTEND_MANIFEST_VERIFIER,
|
||||
FrontendInstallSourcePort,
|
||||
FrontendManifestVerifierPort,
|
||||
} from './ports';
|
||||
|
||||
const NAME_REGEX = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
|
||||
export const FRONTEND_CACHE_DIR = Symbol('FRONTEND_CACHE_DIR');
|
||||
|
||||
export type FrontendSyncOutcome =
|
||||
| { status: 'cached'; packageId: string; version: string; sha256: string }
|
||||
| { status: 'evicted'; packageId: string; reason: string }
|
||||
| { status: 'skipped'; packageId: string; reason: string }
|
||||
| { status: 'rejected'; packageId: string; reason: string };
|
||||
|
||||
@Injectable()
|
||||
export class FrontendCacheService {
|
||||
private readonly logger = new Logger(FrontendCacheService.name);
|
||||
|
||||
constructor(
|
||||
@Inject(FRONTEND_CACHE_DIR) private readonly cacheDir: string,
|
||||
@Inject(FRONTEND_INSTALL_SOURCE) private readonly source: FrontendInstallSourcePort,
|
||||
@Optional()
|
||||
@Inject(FRONTEND_MANIFEST_VERIFIER)
|
||||
private readonly verifier: FrontendManifestVerifierPort | null,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Привести кэш фронт-части пакета к состоянию активного релиза.
|
||||
* Идемпотентен — повторный вызов с тем же релизом перезапишет тем же
|
||||
* содержимым. Никогда не throw'ит: все исходы — discriminated outcome.
|
||||
*/
|
||||
async syncPackage(packageId: string): Promise<FrontendSyncOutcome> {
|
||||
const coords = splitPackageId(packageId);
|
||||
if (coords === null) {
|
||||
return { status: 'skipped', packageId, reason: `packageId без @scope/name-формы: ${packageId}` };
|
||||
}
|
||||
|
||||
const fetched = await this.source.fetchInstallScript(coords.scope, coords.name);
|
||||
if (fetched.status === 'unavailable') {
|
||||
this.logger.warn(`syncPackage ${packageId}: источник недоступен (${fetched.reason}) — кэш не тронут`);
|
||||
return { status: 'skipped', packageId, reason: fetched.reason };
|
||||
}
|
||||
if (fetched.status === 'notFound') {
|
||||
await this.evict(packageId);
|
||||
return { status: 'evicted', packageId, reason: fetched.reason };
|
||||
}
|
||||
|
||||
const sha256 = createHash('sha256').update(fetched.content).digest('hex');
|
||||
if (fetched.declaredSha256 !== null && fetched.declaredSha256 !== sha256) {
|
||||
return this.rejected(
|
||||
packageId,
|
||||
`sha256 содержимого ${sha256} ≠ X-Install-Script-Sha256 ${fetched.declaredSha256}`,
|
||||
);
|
||||
}
|
||||
const version = fetched.version ?? 'unknown';
|
||||
|
||||
if (this.verifier !== null && fetched.version !== null) {
|
||||
let manifestSha: string | null;
|
||||
try {
|
||||
manifestSha = await this.verifier.fetchInstallSha256(packageId, fetched.version);
|
||||
} catch (e) {
|
||||
// fail-closed: непроверенный install.js в кэш не пишем; старая
|
||||
// проверенная версия остаётся отдаваться.
|
||||
return this.rejected(
|
||||
packageId,
|
||||
`manifest-верификатор недоступен: ${e instanceof Error ? e.message : String(e)}`,
|
||||
);
|
||||
}
|
||||
if (manifestSha !== null && manifestSha !== sha256) {
|
||||
return this.rejected(
|
||||
packageId,
|
||||
`sha256 ${sha256} ≠ coopenomics.frontend.installSha256 ${manifestSha} (манифест ${version})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const meta: CachedFrontendMeta = {
|
||||
packageId,
|
||||
scope: coords.scope,
|
||||
name: coords.name,
|
||||
version,
|
||||
sha256,
|
||||
cachedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.writeAtomic(this.entryDir(coords.scope, coords.name), fetched.content, meta);
|
||||
this.logger.log(`frontend cached: ${packageId}@${version} sha256=${sha256.slice(0, 12)}…`);
|
||||
return { status: 'cached', packageId, version, sha256 };
|
||||
}
|
||||
|
||||
/** Убрать фронт-часть из кэша (отзыв релиза / истечение подписки). Идемпотентен. */
|
||||
async evict(packageId: string): Promise<void> {
|
||||
const coords = splitPackageId(packageId);
|
||||
if (coords === null) return;
|
||||
await rm(this.entryDir(coords.scope, coords.name), { recursive: true, force: true });
|
||||
this.logger.log(`frontend evicted: ${packageId}`);
|
||||
}
|
||||
|
||||
/** Есть ли фронт-часть пакета в кэше (по meta.json на диске). */
|
||||
async isCached(packageId: string): Promise<boolean> {
|
||||
const coords = splitPackageId(packageId);
|
||||
if (coords === null) return false;
|
||||
return (await this.readMeta(coords.scope, coords.name)) !== null;
|
||||
}
|
||||
|
||||
/** Список фактически установленных фронтов — скан каталога кэша. */
|
||||
async list(): Promise<CachedFrontendMeta[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await readdir(this.cacheDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const items: CachedFrontendMeta[] = [];
|
||||
for (const entry of entries.sort()) {
|
||||
const sep = entry.indexOf('__');
|
||||
if (sep <= 0) continue;
|
||||
const meta = await this.readMeta(entry.slice(0, sep), entry.slice(sep + 2));
|
||||
if (meta !== null) items.push(meta);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Прочитать install.js + метаданные из кэша; null если не закэширован. */
|
||||
async read(
|
||||
scope: string,
|
||||
name: string,
|
||||
): Promise<{ content: Buffer; meta: CachedFrontendMeta } | null> {
|
||||
const meta = await this.readMeta(scope, name);
|
||||
if (meta === null) return null;
|
||||
try {
|
||||
const content = await readFile(path.join(this.entryDir(scope, name), 'install.js'));
|
||||
return { content, meta };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async readMeta(scope: string, name: string): Promise<CachedFrontendMeta | null> {
|
||||
if (!NAME_REGEX.test(scope) || !NAME_REGEX.test(name)) return null;
|
||||
try {
|
||||
const raw = await readFile(path.join(this.entryDir(scope, name), 'meta.json'), 'utf8');
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
return isCachedFrontendMeta(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* tmp-каталог + rename: читатель никогда не видит полузаписанную пару
|
||||
* install.js/meta.json (rename на одной ФС атомарен).
|
||||
*/
|
||||
private async writeAtomic(dir: string, content: Buffer, meta: CachedFrontendMeta): Promise<void> {
|
||||
const tmp = `${dir}.tmp-${process.pid}`;
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
await mkdir(tmp, { recursive: true });
|
||||
await writeFile(path.join(tmp, 'install.js'), content);
|
||||
await writeFile(path.join(tmp, 'meta.json'), JSON.stringify(meta, null, 2));
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
await rename(tmp, dir);
|
||||
}
|
||||
|
||||
private entryDir(scope: string, name: string): string {
|
||||
return path.join(this.cacheDir, `${scope}__${name}`);
|
||||
}
|
||||
|
||||
private rejected(packageId: string, reason: string): FrontendSyncOutcome {
|
||||
this.logger.error(`syncPackage ${packageId} rejected: ${reason}`);
|
||||
return { status: 'rejected', packageId, reason };
|
||||
}
|
||||
}
|
||||
|
||||
/** `@scope/name` → {scope, name}; null для неподдерживаемых форм. */
|
||||
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] };
|
||||
}
|
||||
|
||||
function isCachedFrontendMeta(value: unknown): value is CachedFrontendMeta {
|
||||
if (typeof value !== 'object' || value === null) return false;
|
||||
const v = value as Record<string, unknown>;
|
||||
return (
|
||||
typeof v.packageId === 'string' &&
|
||||
typeof v.scope === 'string' &&
|
||||
typeof v.name === 'string' &&
|
||||
typeof v.version === 'string' &&
|
||||
typeof v.sha256 === 'string' &&
|
||||
typeof v.cachedAt === 'string'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview Порты кэша фронт-частей расширений (E12-2).
|
||||
*
|
||||
* Откуда берётся install.js: ca-admin отдаёт его из npm-tarball'а
|
||||
* АКТИВНОГО релиза (`GET /v1/public/packages/:scope/:name/install.js`,
|
||||
* E12-1) с заголовками `X-Install-Script-Sha256` / `X-Package-Version`.
|
||||
* Orchestrator скачивает скрипт при установке/обновлении пакета,
|
||||
* сверяет sha256 и кладёт в volume-кэш — desktop затем получает фронт
|
||||
* через coopback из этого кэша, а не напрямую из каталога.
|
||||
*
|
||||
* Зачем два порта:
|
||||
* - {@link FrontendInstallSourcePort} — транспорт до ca-admin
|
||||
* (admin API key контура); тесты подменяют fetch-стабом;
|
||||
* - {@link FrontendManifestVerifierPort} — независимая сверка с
|
||||
* декларацией `coopenomics.frontend.installSha256` из npm-манифеста
|
||||
* в ca-auth registry (per-package JWT по подписке). Это другой
|
||||
* контур и другой auth-канал, чем admin-key путь до ca-admin —
|
||||
* подмена install.js на стороне ca-admin не пройдёт сверку.
|
||||
*/
|
||||
|
||||
/** Результат скачивания install.js из каталога. */
|
||||
export type FrontendInstallFetchOutcome =
|
||||
| {
|
||||
status: 'ok';
|
||||
/** Сырые байты install.js. */
|
||||
content: Buffer;
|
||||
/** sha256 из заголовка `X-Install-Script-Sha256` (или null, если ca-admin его не прислал). */
|
||||
declaredSha256: string | null;
|
||||
/** Версия активного релиза из `X-Package-Version` (или null). */
|
||||
version: string | null;
|
||||
}
|
||||
/** Нет активного релиза или у пакета нет фронт-части (HTTP 404). */
|
||||
| { status: 'notFound'; reason: string }
|
||||
/** Каталог недоступен/5xx/сеть — кэш не трогаем, живём на старом. */
|
||||
| { status: 'unavailable'; reason: string };
|
||||
|
||||
/** Порт скачивания install.js активного релиза из ca-admin. */
|
||||
export interface FrontendInstallSourcePort {
|
||||
fetchInstallScript(scope: string, name: string): Promise<FrontendInstallFetchOutcome>;
|
||||
}
|
||||
|
||||
export const FRONTEND_INSTALL_SOURCE = Symbol('FrontendInstallSourcePort');
|
||||
|
||||
/**
|
||||
* Порт независимой сверки sha256 install.js с декларацией пакета.
|
||||
*
|
||||
* Возвращает `installSha256` из `coopenomics.frontend` манифеста данной
|
||||
* версии в ca-auth registry, либо `null`, если пакет её не декларирует
|
||||
* (поле опционально в manifest-схеме каталога). Ошибка транспорта —
|
||||
* throw: вызывающий обязан fail-closed (не кэшировать непроверенное).
|
||||
*/
|
||||
export interface FrontendManifestVerifierPort {
|
||||
fetchInstallSha256(packageId: string, version: string): Promise<string | null>;
|
||||
}
|
||||
|
||||
export const FRONTEND_MANIFEST_VERIFIER = Symbol('FrontendManifestVerifierPort');
|
||||
|
||||
/** Метаданные закэшированной фронт-части (содержимое meta.json). */
|
||||
export interface CachedFrontendMeta {
|
||||
/** `@scope/name`. */
|
||||
packageId: string;
|
||||
scope: string;
|
||||
name: string;
|
||||
/** Версия релиза, из которого взят install.js. */
|
||||
version: string;
|
||||
/** sha256 содержимого install.js (hex). */
|
||||
sha256: string;
|
||||
/** ISO-время записи в кэш. */
|
||||
cachedAt: string;
|
||||
}
|
||||
@@ -19,6 +19,10 @@ const PACKUMENT = {
|
||||
subgraphPort: 3001,
|
||||
healthcheck: '/_health',
|
||||
},
|
||||
frontend: {
|
||||
tarball: '@voskhod/chatcoop@1.2.0',
|
||||
installSha256: 'c'.repeat(64),
|
||||
},
|
||||
},
|
||||
},
|
||||
'0.9.0': {},
|
||||
@@ -120,4 +124,25 @@ describe('CaAuthReleaseMetadata', () => {
|
||||
expect(spec).toBeNull();
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
describe('fetchInstallSha256 (E12-2, FrontendManifestVerifierPort)', () => {
|
||||
it('возвращает coopenomics.frontend.installSha256 версии', async () => {
|
||||
const sha = await build().fetchInstallSha256('@voskhod/chatcoop', '1.2.0');
|
||||
expect(sha).toBe('c'.repeat(64));
|
||||
});
|
||||
|
||||
it('версия без декларации / неизвестная версия / мусорный id → null', async () => {
|
||||
const metadata = build();
|
||||
expect(await metadata.fetchInstallSha256('@voskhod/chatcoop', '0.9.0')).toBeNull();
|
||||
expect(await metadata.fetchInstallSha256('@voskhod/chatcoop', '9.9.9')).toBeNull();
|
||||
expect(await metadata.fetchInstallSha256('demoapp', '1.0.0')).toBeNull();
|
||||
});
|
||||
|
||||
it('транспортная ошибка → throw (fail-closed на стороне кэша)', async () => {
|
||||
global.fetch = (async () => new Response('down', { status: 503 })) as typeof fetch;
|
||||
await expect(
|
||||
build().fetchInstallSha256('@voskhod/chatcoop', '1.2.0'),
|
||||
).rejects.toThrow(/503/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* возвращаем `null` (desktop подхватит install.js своим поллингом).
|
||||
*/
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { FrontendManifestVerifierPort } from '../frontend-cache/ports';
|
||||
import type { ReleaseInstallSpec, ReleaseMetadataPort } from './ports';
|
||||
import { SignedRequestSigner } from './signed-request.client';
|
||||
|
||||
@@ -40,6 +41,10 @@ interface NpmVersionManifest {
|
||||
healthcheck?: string;
|
||||
env?: string[];
|
||||
};
|
||||
frontend?: {
|
||||
tarball?: string;
|
||||
installSha256?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -48,7 +53,9 @@ interface NpmPackumentLike {
|
||||
versions?: Record<string, NpmVersionManifest>;
|
||||
}
|
||||
|
||||
export class CaAuthReleaseMetadata implements ReleaseMetadataPort {
|
||||
export class CaAuthReleaseMetadata
|
||||
implements ReleaseMetadataPort, FrontendManifestVerifierPort
|
||||
{
|
||||
private readonly logger = new Logger(CaAuthReleaseMetadata.name);
|
||||
private readonly signer: SignedRequestSigner;
|
||||
|
||||
@@ -107,6 +114,21 @@ export class CaAuthReleaseMetadata implements ReleaseMetadataPort {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Декларированный sha256 install.js версии (E12-2,
|
||||
* {@link FrontendManifestVerifierPort}): `coopenomics.frontend.installSha256`
|
||||
* из npm-манифеста в ca-auth registry. `null` — пакет/версия не
|
||||
* декларирует sha (поле опционально). Транспортная ошибка — throw:
|
||||
* кэш фронт-частей обязан fail-closed.
|
||||
*/
|
||||
async fetchInstallSha256(packageId: string, version: string): Promise<string | null> {
|
||||
const coords = splitPackageId(packageId);
|
||||
if (coords === null) return null;
|
||||
const jwt = await this.issuePackageJwt(coords);
|
||||
const packument = await this.fetchPackument(coords, jwt);
|
||||
return packument.versions?.[version]?.coopenomics?.frontend?.installSha256 ?? null;
|
||||
}
|
||||
|
||||
private async issuePackageJwt(coords: { scope: string; name: string }): Promise<string> {
|
||||
const path = `/v1/package/@${coords.scope}/${coords.name}/token`;
|
||||
const headers = this.signer.sign('POST', path, '');
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
* - release-withdrawn → registry.deactivate;
|
||||
* - subscription-expired для нашего coop → registry.deactivate;
|
||||
* - subscription-expired для чужого coop → ignore.
|
||||
*
|
||||
* E12-2 (кэш фронт-частей):
|
||||
* - subscription-activated нашего coop → frontendCache.syncPackage,
|
||||
* в том числе для frontend-only пакета (metadata=null);
|
||||
* - subscription-expired нашего coop → frontendCache.evict;
|
||||
* - release-published: syncPackage только для уже закэшированных;
|
||||
* - release-withdrawn: syncPackage для закэшированных (сам разрулит
|
||||
* «другой активный релиз vs evict»).
|
||||
*/
|
||||
import {
|
||||
AppsContractEvent,
|
||||
@@ -29,6 +37,23 @@ import {
|
||||
InstallOutcome,
|
||||
} from '../orchestrator/install-orchestrator.service';
|
||||
import { SubgraphRegistryService } from '../gateway/subgraph-registry.service';
|
||||
import { FrontendCacheService } from '../frontend-cache/frontend-cache.service';
|
||||
|
||||
class FakeFrontendCache {
|
||||
cachedIds = new Set<string>();
|
||||
synced: string[] = [];
|
||||
evicted: string[] = [];
|
||||
async syncPackage(packageId: string): Promise<unknown> {
|
||||
this.synced.push(packageId);
|
||||
return { status: 'cached', packageId, version: '1.0.0', sha256: 'x' };
|
||||
}
|
||||
async evict(packageId: string): Promise<void> {
|
||||
this.evicted.push(packageId);
|
||||
}
|
||||
async isCached(packageId: string): Promise<boolean> {
|
||||
return this.cachedIds.has(packageId);
|
||||
}
|
||||
}
|
||||
|
||||
class FakeEventStream implements AppsContractEventStreamPort {
|
||||
handler?: (e: AppsContractEvent) => Promise<void>;
|
||||
@@ -74,19 +99,22 @@ const buildHarness = (
|
||||
metadata: FakeMetadata;
|
||||
orchestrator: FakeOrchestrator;
|
||||
registry: FakeRegistryService;
|
||||
frontendCache: FakeFrontendCache;
|
||||
} => {
|
||||
const stream = new FakeEventStream();
|
||||
const metadata = new FakeMetadata();
|
||||
const orchestrator = new FakeOrchestrator();
|
||||
const registry = new FakeRegistryService();
|
||||
const frontendCache = new FakeFrontendCache();
|
||||
const watcher = new OnChainWatcherService(
|
||||
stream,
|
||||
metadata,
|
||||
{ coopname: 'voskhod', ...cfg },
|
||||
orchestrator as unknown as InstallOrchestratorService,
|
||||
registry as unknown as SubgraphRegistryService,
|
||||
frontendCache as unknown as FrontendCacheService,
|
||||
);
|
||||
return { watcher, stream, metadata, orchestrator, registry };
|
||||
return { watcher, stream, metadata, orchestrator, registry, frontendCache };
|
||||
};
|
||||
|
||||
const PACKAGE_ID = '@coopenomics/chatcoop';
|
||||
@@ -256,4 +284,80 @@ describe('OnChainWatcherService', () => {
|
||||
expect(h.registry.deactivated).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('frontend-cache (E12-2)', () => {
|
||||
it('subscription-activated → syncPackage даже для frontend-only пакета (metadata=null)', async () => {
|
||||
const h = buildHarness();
|
||||
// metadata пуст: fetchInstallSpec вернёт null — backend не ставится,
|
||||
// но фронт-часть всё равно кэшируется.
|
||||
await h.watcher.handle({
|
||||
kind: 'subscription-activated',
|
||||
coopname: 'voskhod',
|
||||
packageId: PACKAGE_ID,
|
||||
expiresAtUnix: 1_800_000_000,
|
||||
blockNum: 500,
|
||||
});
|
||||
expect(h.frontendCache.synced).toEqual([PACKAGE_ID]);
|
||||
expect(h.orchestrator.calls).toEqual([]);
|
||||
});
|
||||
|
||||
it('subscription-activated чужого coop → фронт не трогаем', async () => {
|
||||
const h = buildHarness();
|
||||
await h.watcher.handle({
|
||||
kind: 'subscription-activated',
|
||||
coopname: 'alpha',
|
||||
packageId: PACKAGE_ID,
|
||||
expiresAtUnix: 1_800_000_000,
|
||||
blockNum: 501,
|
||||
});
|
||||
expect(h.frontendCache.synced).toEqual([]);
|
||||
});
|
||||
|
||||
it('subscription-expired нашего coop → evict', async () => {
|
||||
const h = buildHarness();
|
||||
await h.watcher.handle({
|
||||
kind: 'subscription-expired',
|
||||
coopname: 'voskhod',
|
||||
packageId: PACKAGE_ID,
|
||||
blockNum: 502,
|
||||
});
|
||||
expect(h.frontendCache.evicted).toEqual([PACKAGE_ID]);
|
||||
});
|
||||
|
||||
it('release-published: syncPackage только если фронт уже в кэше', async () => {
|
||||
const h = buildHarness();
|
||||
h.metadata.setSpec(PACKAGE_ID, VERSION, SPEC);
|
||||
await h.watcher.handle({
|
||||
kind: 'release-published',
|
||||
packageId: PACKAGE_ID,
|
||||
version: VERSION,
|
||||
scopeType: 'all',
|
||||
blockNum: 503,
|
||||
});
|
||||
expect(h.frontendCache.synced).toEqual([]);
|
||||
|
||||
h.frontendCache.cachedIds.add(PACKAGE_ID);
|
||||
await h.watcher.handle({
|
||||
kind: 'release-published',
|
||||
packageId: PACKAGE_ID,
|
||||
version: '1.1.0',
|
||||
scopeType: 'all',
|
||||
blockNum: 504,
|
||||
});
|
||||
expect(h.frontendCache.synced).toEqual([PACKAGE_ID]);
|
||||
});
|
||||
|
||||
it('release-withdrawn: syncPackage для закэшированного (сам решит refresh vs evict)', async () => {
|
||||
const h = buildHarness();
|
||||
h.frontendCache.cachedIds.add(PACKAGE_ID);
|
||||
await h.watcher.handle({
|
||||
kind: 'release-withdrawn',
|
||||
packageId: PACKAGE_ID,
|
||||
version: VERSION,
|
||||
blockNum: 505,
|
||||
});
|
||||
expect(h.frontendCache.synced).toEqual([PACKAGE_ID]);
|
||||
expect(h.registry.deactivated).toEqual([PACKAGE_ID]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
} from './ports';
|
||||
import { InstallOrchestratorService } from '../orchestrator/install-orchestrator.service';
|
||||
import { SubgraphRegistryService } from '../gateway/subgraph-registry.service';
|
||||
import { FrontendCacheService } from '../frontend-cache/frontend-cache.service';
|
||||
|
||||
export interface OnChainWatcherConfig {
|
||||
/** Имя нашего кооператива — нужно для фильтрации scope=cooperatives. */
|
||||
@@ -59,6 +60,7 @@ export class OnChainWatcherService implements OnApplicationBootstrap, OnApplicat
|
||||
@Inject('ON_CHAIN_WATCHER_CONFIG') private readonly cfg: OnChainWatcherConfig,
|
||||
private readonly orchestrator: InstallOrchestratorService,
|
||||
private readonly registry: SubgraphRegistryService,
|
||||
private readonly frontendCache: FrontendCacheService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
@@ -95,6 +97,12 @@ export class OnChainWatcherService implements OnApplicationBootstrap, OnApplicat
|
||||
);
|
||||
return;
|
||||
}
|
||||
// E12-2: обновить фронт-часть у уже установленных пакетов. Кэш как
|
||||
// маркер подписки: «не закэширован, но подписан» доезжает на
|
||||
// subscription-activated initial-снапшота при следующем старте.
|
||||
if (await this.frontendCache.isCached(e.packageId)) {
|
||||
await this.frontendCache.syncPackage(e.packageId);
|
||||
}
|
||||
const spec = await this.metadata.fetchInstallSpec({
|
||||
packageId: e.packageId,
|
||||
version: e.version,
|
||||
@@ -112,6 +120,10 @@ export class OnChainWatcherService implements OnApplicationBootstrap, OnApplicat
|
||||
e: Extract<AppsContractEvent, { kind: 'subscription-activated' }>,
|
||||
): Promise<void> {
|
||||
if (e.coopname !== this.cfg.coopname) return;
|
||||
// E12-2: фронт-часть кэшируется независимо от backend-спеки — для
|
||||
// frontend-only пакетов fetchInstallSpec вернёт null, а install.js
|
||||
// активного релиза всё равно должен попасть в кэш.
|
||||
await this.frontendCache.syncPackage(e.packageId);
|
||||
// На subscription-activated нам не приходит конкретная версия —
|
||||
// нужно сходить в apps-catalog за active-релизом. fetchInstallSpec
|
||||
// с пустой version интерпретируется реальным impl'ом как «дай active».
|
||||
@@ -135,6 +147,12 @@ export class OnChainWatcherService implements OnApplicationBootstrap, OnApplicat
|
||||
e: Extract<AppsContractEvent, { kind: 'release-withdrawn' }>,
|
||||
): Promise<void> {
|
||||
await this.registry.deactivate(e.packageId);
|
||||
// E12-2: syncPackage сам разрулит исход — есть другой активный релиз
|
||||
// → фронт обновится на него; активных не осталось → ca-admin отдаст
|
||||
// 404 и кэш-запись будет убрана.
|
||||
if (await this.frontendCache.isCached(e.packageId)) {
|
||||
await this.frontendCache.syncPackage(e.packageId);
|
||||
}
|
||||
this.logger.log(`release-withdrawn ${e.packageId}@${e.version} → deactivated`);
|
||||
}
|
||||
|
||||
@@ -143,6 +161,9 @@ export class OnChainWatcherService implements OnApplicationBootstrap, OnApplicat
|
||||
): Promise<void> {
|
||||
if (e.coopname !== this.cfg.coopname) return;
|
||||
await this.registry.deactivate(e.packageId);
|
||||
// E12-2: подписка кончилась — фронт убирается из кэша, desktop
|
||||
// перестаёт получать install.js этого пакета.
|
||||
await this.frontendCache.evict(e.packageId);
|
||||
this.logger.log(`subscription-expired ${e.packageId} → deactivated`);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* healthcheck → registry → supergraph recompose» без ручных вызовов.
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FrontendCacheModule } from '../frontend-cache/frontend-cache.module';
|
||||
import { GatewayModule } from '../gateway/gateway.module';
|
||||
import { OrchestratorModule } from '../orchestrator/orchestrator.module';
|
||||
import { OnChainWatcherService, OnChainWatcherConfig } from './on-chain-watcher.service';
|
||||
@@ -44,7 +45,7 @@ class NoopReleaseMetadata implements ReleaseMetadataPort {
|
||||
}
|
||||
|
||||
@Module({
|
||||
imports: [GatewayModule, OrchestratorModule],
|
||||
imports: [GatewayModule, OrchestratorModule, FrontendCacheModule],
|
||||
providers: [
|
||||
OnChainWatcherService,
|
||||
{
|
||||
|
||||
@@ -136,9 +136,17 @@ services:
|
||||
WATCHER_POLL_INTERVAL_MS: ${WATCHER_POLL_INTERVAL_MS:-15000}
|
||||
COOPERATIVE_WIF: ${COOPERATIVE_WIF:-}
|
||||
EXTENSIONS_DOCKER_NETWORK: ${EXTENSIONS_DOCKER_NETWORK:-}
|
||||
# E12-2: кэш фронт-частей. install.js активного релиза качается из
|
||||
# ca-admin (admin API key контура — те же env-имена, что у coopback),
|
||||
# сверяется по sha256 и складывается в volume; coopback раздаёт его
|
||||
# desktop'у через JWT-gated proxy.
|
||||
APPS_CATALOG_URL: ${APPS_CATALOG_URL:-}
|
||||
APPS_CATALOG_API_KEY: ${APPS_CATALOG_API_KEY:-}
|
||||
FRONTEND_CACHE_DIR: /var/orchestrator/frontend-cache
|
||||
volumes:
|
||||
- ./:/app
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- orchestrator_frontend_cache:/var/orchestrator/frontend-cache
|
||||
ports:
|
||||
- "127.0.0.1:${ORCHESTRATOR_HOST_PORT:-4000}:4000"
|
||||
working_dir: /app
|
||||
@@ -166,3 +174,4 @@ volumes:
|
||||
mongo_data:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
orchestrator_frontend_cache:
|
||||
|
||||
Reference in New Issue
Block a user