[E12-2][@ant] feat(orchestrator): volume-кэш фронт-частей расширений — JWT-gated доставка вместо публичного каталога
Typecheck / desktop (pull_request) Has been cancelled
Typecheck / controller (pull_request) Has been cancelled

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:
coopops
2026-06-11 07:16:05 +00:00
parent 27220c8572
commit 071525f2a2
13 changed files with 959 additions and 3 deletions
@@ -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,
{
+9
View File
@@ -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: