Compare commits

...

7 Commits

Author SHA1 Message Date
coopops 34d6af7a98 Merge remote-tracking branch 'origin/dev' into file-storage
# Conflicts:
#	components/controller/package.json
#	pnpm-lock.yaml
2026-05-14 18:12:21 +00:00
coopops 84eb846044 [E59-6][@ant] docs(file-storage): краткий README для разработчиков расширений — пример @UseBucket/@InjectBucket/forFeature, операции, ошибки, env, как запускать тесты 2026-04-23 10:01:17 +00:00
coopops a9150b76ee [E59-6][@ant] feat(controller,compose): MinIO в dev docker-compose, env-валидация и FileStorageInfrastructureModule.forRoot в app.module — контроллер на старте идемпотентно создаёт бакет coop-<coopname>; integration-тесты проходят против MinIO из dev compose 2026-04-23 09:37:46 +00:00
coopops 0846ad173f [E59-5][@ant] test(controller): integration suite против реального MinIO — 8 сценариев на полный цикл put/head/getReadUrl/GET/delete + ошибки лимитов/MIME/metadata + HMAC-роут 200/403/404; docker-compose рядом с тестами, npm run test:integration:file-storage с автодетектом доступности MinIO 2026-04-23 09:20:56 +00:00
coopops 3c05ba157e [E59-4][@ant] feat(controller): HTTP-ручка GET /api/storage/:bucket/:key с HMAC-валидацией подписи и стримом из MinIO; fetchObjectForReadProxy на адаптере, controller registered в FileStorageInfrastructureModule.forRoot; 9 e2e-тестов через supertest на 200/403/404/502 2026-04-23 09:10:42 +00:00
coopops 85e81f8ed5 [E59-3][@ant] feat(controller): MinIO-адаптер InterFileStoragePort, реестр бакетов, @UseBucket/@InjectBucket декораторы и FileStorageInfrastructureModule с forRoot/forFeature; 36 unit-тестов на адаптер, реестр, декоратор и HMAC-подписание 2026-04-23 09:05:51 +00:00
coopops 979e5bd478 [E59-2][@ant] feat(inter): InterFileStorage порт — типы, токен INTER_FILE_STORAGE и типизированные ошибки для универсального файлового хранилища контура кооператива 2026-04-23 08:29:36 +00:00
28 changed files with 2801 additions and 1 deletions
+6
View File
@@ -27,6 +27,12 @@ PG_HOST_PORT=5532
COOPBACK_HOST_PORT=2998
DESKTOP_HOST_PORT=2999
OPENSEARCH_HOST_PORT=9200
MINIO_HOST_PORT=9000
MINIO_CONSOLE_HOST_PORT=9001
# MinIO root credentials (только для локального dev; на prod подменяются плейбуком).
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=minioadmin
# URL для скриптов и кода, запускаемого с хоста (boot, networks.sh,
# preactivate.sh, health.ts). Должны соответствовать host-портам выше.
+13
View File
@@ -84,3 +84,16 @@ OPENAI_API_KEY=
OPENAI_BASE_URL=
WHISPER_MODEL=whisper-1
WHISPER_LANGUAGE=ru
# FILE STORAGE (MinIO в dev compose; на prod подменяется плейбуком).
# Endpoint S3-совместимого бэкенда. В dev compose service name = minio.
# Для запуска контроллера на хосте (вне docker) — http://127.0.0.1:9000.
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin
# Имя физического бакета. По умолчанию `coop-${COOPNAME}`, можно перекрыть.
MINIO_BUCKET=
# HMAC-секрет для подписи URL отдачи. Если пусто — берётся SERVER_SECRET.
FILE_STORAGE_SIGNING_SECRET=
# База публичного URL контроллера для read-URL. Если пусто — берётся BACKEND_URL.
FILE_STORAGE_PUBLIC_BASE_URL=
+3
View File
@@ -8,6 +8,9 @@ module.exports = {
NODE_ENV: 'test',
},
restoreMocks: true,
// Интеграционные тесты против внешних сервисов (MinIO и т.п.) — не часть штатного `jest`-прогона.
// Запускаются явно через `npm run test:integration:file-storage`.
testPathIgnorePatterns: ['/node_modules/', '\\.integration\\.spec\\.ts$'],
coveragePathIgnorePatterns: ['node_modules', 'src/config', 'src/app.ts', 'tests'],
coverageReporters: ['text', 'lcov', 'clover', 'html'],
moduleNameMapper: {
+3
View File
@@ -22,6 +22,7 @@
"dev": "nodemon --watch src --watch migrations --ext ts,js,env --exec 'ts-node -r tsconfig-paths/register' src/index.ts",
"test": "jest -i --testPathPattern=tests/unit",
"test:integration": "echo 'Integration tests run via @coopenomics/boot' && exit 0",
"test:integration:file-storage": "cross-env NODE_OPTIONS=--experimental-vm-modules jest --testRegex 'tests/file-storage/.*\\.integration\\.spec\\.ts$' --testPathIgnorePatterns='/node_modules/' --testTimeout=60000 -i",
"test:watch": "jest -i --watchAll",
"coverage": "jest -i --coverage",
"coverage:coveralls": "jest -i --coverage --coverageReporters=text-lcov | coveralls",
@@ -71,6 +72,7 @@
],
"dependencies": {
"@a2seven/yoo-checkout": "^1.1.4",
"@aws-sdk/client-s3": "^3.1035.0",
"@coopenomics/factory": "workspace:*",
"@coopenomics/inter": "workspace:*",
"@coopenomics/notifications": "workspace:*",
@@ -193,6 +195,7 @@
"@graphql-codegen/typescript": "^4.1.1",
"@graphql-codegen/typescript-apollo-client-helpers": "^3.0.0",
"@graphql-codegen/typescript-operations": "^4.3.1",
"@nestjs/testing": "^11.1.19",
"@redocly/cli": "^1.18.0",
"@types/express": "^4.17.21",
"@types/node": "^20.11.26",
+9
View File
@@ -16,6 +16,7 @@ import { EventsInfrastructureModule } from './infrastructure/events/events.modul
import { FreeDecisionInfrastructureModule } from './infrastructure/free-decision/free-decision-infrastructure.module';
import { DecisionTrackingInfrastructureModule } from './infrastructure/decision-tracking/decision-tracking-infrastructure.module';
import { SearchInfrastructureModule } from './infrastructure/search/search-infrastructure.module';
import { FileStorageInfrastructureModule } from './infrastructure/file-storage';
// Domain modules
import { AccountDomainModule } from './domain/account/account-domain.module';
@@ -102,6 +103,14 @@ import { MutationLoggingInterceptor } from './application/common/interceptors/mu
EventsInfrastructureModule,
FreeDecisionInfrastructureModule,
DecisionTrackingInfrastructureModule,
FileStorageInfrastructureModule.forRoot({
endpoint: config.file_storage.endpoint,
accessKey: config.file_storage.access_key,
secretKey: config.file_storage.secret_key,
bucket: config.file_storage.bucket,
signingSecret: config.file_storage.signing_secret,
publicBaseUrl: config.file_storage.public_base_url,
}),
// Domain modules
AuthDomainModule,
RegistrationDomainModule,
@@ -170,6 +170,26 @@ const envVarsSchema = z.object({
OPENAI_BASE_URL: z.string().optional().describe('Базовый URL для Whisper API (через chatcoop-proxy nginx)'),
WHISPER_MODEL: z.string().default('whisper-1').describe('Модель Whisper для STT'),
WHISPER_LANGUAGE: z.string().default('ru').describe('Язык для Whisper STT'),
// Файловое хранилище (MinIO в dev/контуре кооператива; S3 в проде по плану E59-N)
MINIO_ENDPOINT: z
.string()
.default('http://minio:9000')
.describe('Endpoint S3-совместимого бэкенда; в compose — service name'),
MINIO_ACCESS_KEY: z.string().default('minioadmin').describe('Access-key для MinIO/S3'),
MINIO_SECRET_KEY: z.string().default('minioadmin').describe('Secret-key для MinIO/S3'),
MINIO_BUCKET: z
.string()
.optional()
.describe('Имя физического бакета; по умолчанию `coop-${COOPNAME}`'),
FILE_STORAGE_SIGNING_SECRET: z
.string()
.optional()
.describe('HMAC-секрет для подписи read-URL; пусто — берётся SERVER_SECRET'),
FILE_STORAGE_PUBLIC_BASE_URL: z
.string()
.optional()
.describe('База публичного URL контроллера для read-URL; пусто — берётся BACKEND_URL'),
});
const envInput = isSchemaGeneration ? { ...SCHEMA_GEN_ENV_DEFAULTS, ...process.env } : process.env;
@@ -293,4 +313,12 @@ export default {
whisper_model: envVars.data.WHISPER_MODEL,
whisper_language: envVars.data.WHISPER_LANGUAGE,
},
file_storage: {
endpoint: envVars.data.MINIO_ENDPOINT,
access_key: envVars.data.MINIO_ACCESS_KEY,
secret_key: envVars.data.MINIO_SECRET_KEY,
bucket: envVars.data.MINIO_BUCKET || `coop-${envVars.data.COOPNAME}`,
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,
},
};
@@ -0,0 +1,169 @@
# FileStorage
Универсальное файловое хранилище контура кооператива (MinIO в v1, готовность к нативному S3).
Подробности и архитектура — req **«SPEC: файловое хранилище v1»** в blago.
## Когда использовать
Расширению нужно положить и потом отдать пайщику бинарный файл — изображение, PDF, в перспективе
любой другой blob. Не катить свой S3-клиент и не плодить ad-hoc эндпоинты.
## Минимальный пример
### 1. Сервис расширения декларирует свой бакет
```ts
// extensions/stol-zakazov/order-images.service.ts
import { Injectable } from '@nestjs/common';
import {
InjectBucket,
UseBucket,
type InterFileStorageBucket,
} from '~/infrastructure/file-storage';
import type { FileUpload } from 'graphql-upload';
const MB = 1024 * 1024;
@UseBucket({
name: 'stol-zakazov:images',
maxBytes: 10 * MB,
allowedMime: ['image/jpeg', 'image/png', 'image/webp'],
metadataSchema: { ownerId: 'required', orderId: 'required' },
defaultUrlTtlSeconds: 600,
})
@Injectable()
export class OrderImagesService {
constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
async attach(orderId: string, ownerId: string, upload: FileUpload): Promise<string> {
const key = `orders/${orderId}/main.${ext(upload.mimetype)}`;
await this.bucket.put(key, upload.createReadStream(), {
contentType: upload.mimetype,
metadata: { ownerId, orderId },
});
return key;
}
async displayUrl(key: string): Promise<string> {
return this.bucket.getReadUrl(key);
}
async remove(key: string): Promise<void> {
await this.bucket.delete(key); // идемпотентно
}
}
```
**Конвенция:** один бакет — один сервис. Нужен второй бакет — сделай второй сервис.
### 2. Модуль расширения регистрирует сервис в `forFeature`
```ts
// extensions/stol-zakazov/stol-zakazov.module.ts
import { Module } from '@nestjs/common';
import { FileStorageInfrastructureModule } from '~/infrastructure/file-storage';
import { OrderImagesService } from './order-images.service';
@Module({
imports: [FileStorageInfrastructureModule.forFeature([OrderImagesService])],
providers: [OrderImagesService],
exports: [OrderImagesService],
})
export class StolZakazovModule {}
```
`forFeature` читает `@UseBucket` метадату каждого класса и провайдит ему конкретный
`InterFileStorageBucket` (с уже подмешанным префиксом). Корневой `forRoot(...)` уже подключён
в `app.module.ts`.
### 3. GraphQL — обычная мутация с Upload и поле URL в ответе
```graphql
type Mutation {
createOrderItem(input: CreateOrderItemInput!): OrderItem!
}
input CreateOrderItemInput {
title: String!
image: Upload!
}
type OrderItem {
id: ID!
title: String!
imageUrl: String! # резолвер: bucket.getReadUrl(key) после ACL-проверки
}
```
UI ходит обычным `<img src={imageUrl}>`. Никакого нового REST-слоя для GraphQL-клиента —
служебная ручка `/api/storage/...` живёт сама и видит только подписанные URL.
## Операции
| Метод | Что делает | Гарантии |
|---|---|---|
| `put(key, body, opts)` | Загружает объект | Перезапись по тому же ключу, валидация `maxBytes`/`allowedMime`/`metadataSchema` |
| `getReadUrl(key, opts?)` | Короткоживущий URL чтения | HMAC-signed URL контроллера; TTL по умолчанию из `defaultUrlTtlSeconds` спеки |
| `delete(key)` | Удалить | Идемпотентно (нет объекта = успех) |
| `head(key)` | Метаданные без скачивания | Бросает `InterFileStorageObjectNotFoundError` если нет |
`body` принимает `Uint8Array` или `ReadableStream<Uint8Array>` (graphql-upload `createReadStream()`
также подходит — duck-typed, проверяется наличие `getReader()` или конвертируется адаптером).
## Ошибки
Всё — наследники `InterFileStorageError`. Доменный код мапит на свои коды:
- `InterFileStorageObjectNotFoundError` — нет объекта (`head`)
- `InterFileStorageObjectTooLargeError` — превышен `maxBytes`
- `InterFileStorageMimeRejectedError``contentType` не в `allowedMime`
- `InterFileStorageMetadataValidationError` — нет required-метаданного
- `InterFileStorageBackendUnavailableError` — недоступен MinIO/S3
- `InterFileStorageBucketNotConfiguredError` — невалидная спека бакета
## Конфигурация (env)
| Переменная | Default | Заметка |
|---|---|---|
| `MINIO_ENDPOINT` | `http://minio:9000` | Compose service name; для запуска контроллера на хосте — `http://127.0.0.1:9000` |
| `MINIO_ACCESS_KEY` | `minioadmin` | Только dev; на prod подменяется плейбуком |
| `MINIO_SECRET_KEY` | `minioadmin` | То же |
| `MINIO_BUCKET` | `coop-${COOPNAME}` | Физический бакет, идемпотентно создаётся на bootstrap |
| `FILE_STORAGE_SIGNING_SECRET` | `SERVER_SECRET` | HMAC-секрет; на prod — отдельный |
| `FILE_STORAGE_PUBLIC_BASE_URL` | `BACKEND_URL` | База URL для read-ссылок |
## Тесты
```bash
# Юнит (моки)
cd components/controller && npx jest src/infrastructure/file-storage/ -i
# Интеграционные против реального MinIO
docker compose up -d minio # из mono-ai-3 root
cd components/controller
npm run test:integration:file-storage
```
Тесты пропускаются автоматически (с диагностическим warning), если MinIO недоступен на
`FILE_STORAGE_TEST_MINIO_ENDPOINT` (default `http://localhost:9000`).
## Что в коробке (файлы модуля)
- `file-storage.config.ts``FileStorageInfrastructureOptions` + токен `FILE_STORAGE_OPTIONS`
- `bucket-registry.ts` — singleton, накапливает `BucketSpec` от декораторов
- `use-bucket.decorator.ts``@UseBucket`/`@InjectBucket` + `bucketTokenFor`
- `minio-file-storage.adapter.ts` — реализация `InterFileStoragePort` поверх `@aws-sdk/client-s3`,
валидация спеки, `OnApplicationBootstrap` с `ensureBucketExists`
- `signing.ts``signReadUrl` / `verifyReadUrl` (HMAC-SHA256, constant-time)
- `file-storage-http.controller.ts``GET /api/storage/:bucket/:key` с проверкой подписи
- `file-storage-infrastructure.module.ts``FileStorageModule.forRoot(...) / forFeature(consumers)`
## Не входит в v1
- `getWriteUrl()` (presigned PUT)
- `list(prefix)`, `copy(src, dst)`
- Lifecycle/retention на уровне порта
- Привязка URL к userId (отдельный режим, если появится особо приватный контент)
- Антивирусное сканирование
См. §11 спеки.
@@ -0,0 +1,42 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
describe('BucketRegistry', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
const spec = (name: string): InterFileStorageBucketSpec => ({
name,
maxBytes: 1024,
allowedMime: ['image/jpeg'],
});
it('add + get возвращает зарегистрированную спеку', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(BucketRegistry.get(A)?.name).toBe('ext-a:images');
});
it('повторная регистрация того же класса под тем же именем — no-op (идемпотентно)', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:images'))).not.toThrow();
});
it('повторная регистрация того же класса под другим именем — выбрасывает', () => {
class A {}
BucketRegistry.add(A, spec('ext-a:images'));
expect(() => BucketRegistry.add(A, spec('ext-a:other'))).toThrow(/уже зарегистрирован/);
});
it('list возвращает все зарегистрированные пары', () => {
class A {}
class B {}
BucketRegistry.add(A, spec('ext-a:images'));
BucketRegistry.add(B, spec('ext-b:receipts'));
const list = BucketRegistry.list();
expect(list).toHaveLength(2);
expect(list.map((r) => r.spec.name).sort()).toEqual(['ext-a:images', 'ext-b:receipts']);
});
});
@@ -0,0 +1,39 @@
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
/**
* Глобальный реестр бакетов, наполняется декоратором `@UseBucket` на этапе загрузки модулей.
* Используется `FileStorageInfrastructureModule.forFeature(...)` для wire-up per-class providers
* и `MinioFileStorageAdapter.onApplicationBootstrap` для логирования / диагностики.
*/
export interface RegisteredBucket {
readonly cls: { readonly name: string };
readonly spec: InterFileStorageBucketSpec;
}
const REGISTERED = new Map<{ name: string }, InterFileStorageBucketSpec>();
export const BucketRegistry = {
add(cls: { name: string }, spec: InterFileStorageBucketSpec): void {
const existing = REGISTERED.get(cls);
if (existing && existing.name !== spec.name) {
throw new Error(
`BucketRegistry: класс ${cls.name} уже зарегистрирован под бакетом '${existing.name}', ` +
`повторная регистрация под '${spec.name}' запрещена`,
);
}
REGISTERED.set(cls, spec);
},
get(cls: { name: string }): InterFileStorageBucketSpec | undefined {
return REGISTERED.get(cls);
},
list(): readonly RegisteredBucket[] {
return Array.from(REGISTERED.entries()).map(([cls, spec]) => ({ cls, spec }));
},
/** Только для тестов: очистка реестра между сценариями. */
_resetForTests(): void {
REGISTERED.clear();
},
};
@@ -0,0 +1,197 @@
import 'reflect-metadata';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { FileStorageHttpController } from './file-storage-http.controller';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { signReadUrl } from './signing';
const OPTS: FileStorageInfrastructureOptions = {
endpoint: 'http://minio:9000',
accessKey: 'AKIA-test',
secretKey: 'sk-test',
bucket: 'coop-test',
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
publicBaseUrl: 'https://test.example.org',
};
interface AdapterStub {
fetchObjectForReadProxy: jest.Mock;
}
async function makeApp(stub: AdapterStub): Promise<INestApplication> {
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: OPTS },
{ provide: MinioFileStorageAdapter, useValue: stub },
],
}).compile();
const app = moduleRef.createNestApplication();
await app.init();
return app;
}
function makeStream(bytes: Buffer): Readable {
return Readable.from([bytes]);
}
function makeStub(): AdapterStub {
return { fetchObjectForReadProxy: jest.fn() };
}
function urlFor(bucket: string, key: string, ttlSeconds: number): { path: string; exp: number; sig: string } {
const exp = Math.floor(Date.now() / 1000) + ttlSeconds;
const sig = signReadUrl({ bucket, key, expUnix: exp, secret: OPTS.signingSecret });
const encodedKey = key.split('/').map(encodeURIComponent).join('/');
return { path: `/api/storage/${encodeURIComponent(bucket)}/${encodedKey}`, exp, sig };
}
describe('FileStorageHttpController', () => {
let app: INestApplication;
let stub: AdapterStub;
beforeEach(() => {
stub = makeStub();
});
afterEach(async () => {
if (app) await app.close();
});
it('200 + байты при валидной подписи', async () => {
app = await makeApp(stub);
const payload = Buffer.from('hello world');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'image/jpeg',
lastModified: new Date(),
});
const u = urlFor('orders-images', 'orders/42/main.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect(res.headers['content-length']).toBe(String(payload.length));
expect(res.headers['cache-control']).toMatch(/^private, max-age=\d+$/);
expect((res.body as Buffer).equals(payload)).toBe(true);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith('orders-images/orders/42/main.jpg');
});
it('403 при неверной подписи', async () => {
app = await makeApp(stub);
const u = urlFor('orders-images', 'a.jpg', 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: 'a'.repeat(64) });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при истёкшем exp', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) - 1;
const sig = signReadUrl({ bucket: 'orders-images', key: 'a.jpg', expUnix: exp, secret: OPTS.signingSecret });
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
expect(stub.fetchObjectForReadProxy).not.toHaveBeenCalled();
});
it('403 при отсутствии exp/sig', async () => {
app = await makeApp(stub);
const res = await request(app.getHttpServer()).get('/api/storage/orders-images/a.jpg');
expect(res.status).toBe(403);
});
it('403 при подмене bucket в URL (HMAC связан с bucket)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/expenses-receipts/a.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('403 при подмене key в URL (HMAC связан с key)', async () => {
app = await makeApp(stub);
const exp = Math.floor(Date.now() / 1000) + 60;
const sig = signReadUrl({
bucket: 'orders-images',
key: 'a.jpg',
expUnix: exp,
secret: OPTS.signingSecret,
});
const res = await request(app.getHttpServer())
.get('/api/storage/orders-images/b.jpg')
.query({ exp, sig });
expect(res.status).toBe(403);
});
it('404 при ObjectNotFoundError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageObjectNotFoundError('missing'),
);
const u = urlFor('orders-images', 'missing.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(404);
});
it('502 при BackendUnavailableError от адаптера', async () => {
app = await makeApp(stub);
stub.fetchObjectForReadProxy.mockRejectedValueOnce(
new InterFileStorageBackendUnavailableError('backend down'),
);
const u = urlFor('orders-images', 'x.jpg', 60);
const res = await request(app.getHttpServer()).get(u.path).query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(502);
});
it('правильно парсит составной ключ с / и спецсимволами', async () => {
app = await makeApp(stub);
const payload = Buffer.from('xy');
stub.fetchObjectForReadProxy.mockResolvedValueOnce({
stream: makeStream(payload),
size: payload.length,
contentType: 'application/pdf',
lastModified: new Date(),
});
const key = 'reports/q1 2026/r 1.pdf';
const u = urlFor('expenses-receipts', key, 60);
const res = await request(app.getHttpServer())
.get(u.path)
.query({ exp: u.exp, sig: u.sig });
expect(res.status).toBe(200);
expect(stub.fetchObjectForReadProxy).toHaveBeenCalledWith(`expenses-receipts/${key}`);
});
});
@@ -0,0 +1,131 @@
import {
Controller,
Get,
HttpStatus,
Inject,
Logger,
Param,
Query,
Req,
Res,
} from '@nestjs/common';
import type { Request, Response } from 'express';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageObjectNotFoundError,
} from '@coopenomics/inter';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
/**
* Служебная ручка отдачи объектов по HMAC-signed URL.
*
* Формат URL формирует `MinioFileStorageAdapter.getReadUrl`:
* `<publicBaseUrl>/api/storage/:bucket/:key?exp=<unix-ts>&sig=<hmac-hex>`
*
* - `:bucket` — публичная форма имени бакета (`<extension>-<purpose>`).
* - `:key` — caller-defined ключ; может содержать `/`, кодируется посегментно.
* - `exp` — unix-timestamp истечения; `now < exp` обязательно.
* - `sig` — `HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>")`, hex; constant-time сверка.
*
* ACL принимается ДО получения URL — на стороне доменного резолвера. Любой держатель URL до
* истечения TTL может скачать. Утечка → утечка контента до `exp`.
*/
@Controller('api/storage')
export class FileStorageHttpController {
private readonly logger = new Logger(FileStorageHttpController.name);
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
private readonly adapter: MinioFileStorageAdapter,
) {}
@Get(':bucket/*')
async serve(
@Param('bucket') bucketRaw: string,
@Query('exp') expRaw: string | undefined,
@Query('sig') sig: string | undefined,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const bucket = safeDecodeURIComponent(bucketRaw);
const restRaw = (req.params as Record<string, string>)['0'] ?? '';
const key = decodeKeyFromPath(restRaw);
const exp = Number(expRaw);
if (!Number.isFinite(exp) || !sig || !bucket || !key) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
if (nowUnix >= exp) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const ok = verifyReadUrl({
bucket,
key,
expUnix: exp,
sig,
secret: this.opts.signingSecret,
});
if (!ok) {
res.status(HttpStatus.FORBIDDEN).end();
return;
}
const physicalKey = `${bucket}/${key}`;
let obj;
try {
obj = await this.adapter.fetchObjectForReadProxy(physicalKey);
} catch (e) {
if (e instanceof InterFileStorageObjectNotFoundError) {
res.status(HttpStatus.NOT_FOUND).end();
return;
}
if (e instanceof InterFileStorageBackendUnavailableError) {
this.logger.warn(`backend недоступен на ${physicalKey}: ${(e as Error).message}`);
res.status(HttpStatus.BAD_GATEWAY).end();
return;
}
throw e;
}
const remaining = Math.max(0, exp - nowUnix);
res.setHeader('Content-Type', obj.contentType);
if (obj.size > 0) {
res.setHeader('Content-Length', String(obj.size));
}
res.setHeader('Cache-Control', `private, max-age=${remaining}`);
res.status(HttpStatus.OK);
obj.stream.on('error', (err) => {
this.logger.warn(`stream error на ${physicalKey}: ${err.message}`);
if (!res.headersSent) {
res.status(HttpStatus.BAD_GATEWAY).end();
} else {
res.end();
}
});
obj.stream.pipe(res);
}
}
function safeDecodeURIComponent(s: string): string {
try {
return decodeURIComponent(s);
} catch {
return '';
}
}
function decodeKeyFromPath(rest: string): string {
if (!rest) return '';
return rest.split('/').map(safeDecodeURIComponent).join('/');
}
@@ -0,0 +1,58 @@
import { DynamicModule, Global, Module, type Type } from '@nestjs/common';
import { INTER_FILE_STORAGE, type InterFileStoragePort } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
import {
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { FileStorageHttpController } from './file-storage-http.controller';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { bucketTokenFor } from './use-bucket.decorator';
/**
* Динамический модуль файлового хранилища.
*
* - `forRoot(options)` — провайдит адаптер `InterFileStoragePort` (токен `INTER_FILE_STORAGE`)
* и стартует `OnApplicationBootstrap` хук с `ensureBucketExists`. Глобальный — токен
* доступен `forFeature`-ам без явного импорта.
* - `forFeature(consumers)` — для каждого `@UseBucket`-класса регистрирует фабрику
* `bucketTokenFor(class)`, которая отдаёт `InterFileStorageBucket`. Импортируется в модуле
* расширения, где живут эти сервисы.
*/
@Global()
@Module({})
export class FileStorageInfrastructureModule {
static forRoot(options: FileStorageInfrastructureOptions): DynamicModule {
return {
module: FileStorageInfrastructureModule,
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: options },
MinioFileStorageAdapter,
{ provide: INTER_FILE_STORAGE, useExisting: MinioFileStorageAdapter },
],
exports: [INTER_FILE_STORAGE, MinioFileStorageAdapter],
};
}
static forFeature(consumers: ReadonlyArray<Type<unknown>>): DynamicModule {
const providers = consumers.map((cls) => {
const spec = BucketRegistry.get(cls);
if (!spec) {
throw new Error(
`FileStorageInfrastructureModule.forFeature: класс ${cls.name} не помечен @UseBucket`,
);
}
return {
provide: bucketTokenFor(cls),
useFactory: (port: InterFileStoragePort) => port.getBucket(spec),
inject: [INTER_FILE_STORAGE],
};
});
return {
module: FileStorageInfrastructureModule,
providers,
exports: providers.map((p) => p.provide),
};
}
}
@@ -0,0 +1,26 @@
/**
* Конфигурация подключения к MinIO/S3 и подписания read-URL.
* Передаётся в `FileStorageInfrastructureModule.forRoot(options)`.
*/
export interface FileStorageInfrastructureOptions {
/** URL S3-совместимого бэкенда. Для MinIO — `http://minio:9000`. */
endpoint: string;
accessKey: string;
secretKey: string;
/** Имя физического бакета на сервере (например, `coop-voskhod`). */
bucket: string;
/** Секрет для HMAC-подписи read-URL служебной ручки контроллера. */
signingSecret: string;
/** База публичного URL контроллера (например, `https://voskhod.example.org`). */
publicBaseUrl: string;
/** AWS region. Для MinIO любое непустое значение. По умолчанию `us-east-1`. */
region?: string;
/** Path-style addressing (true для MinIO, false для AWS S3). По умолчанию true. */
forcePathStyle?: boolean;
/** TTL по умолчанию для read-URL (сек). Перекрывается `BucketSpec.defaultUrlTtlSeconds`. По умолчанию 600. */
defaultUrlTtlSeconds?: number;
}
export const FILE_STORAGE_OPTIONS = Symbol.for('FileStorage.Options');
export const FILE_STORAGE_DEFAULT_URL_TTL_SECONDS = 600;
@@ -0,0 +1,14 @@
export {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
export { BucketRegistry, type RegisteredBucket } from './bucket-registry';
export { InjectBucket, UseBucket, bucketTokenFor } from './use-bucket.decorator';
export {
MinioFileStorageAdapter,
type FileStorageObjectStream,
} from './minio-file-storage.adapter';
export { FileStorageHttpController } from './file-storage-http.controller';
export { FileStorageInfrastructureModule } from './file-storage-infrastructure.module';
export { signReadUrl, verifyReadUrl } from './signing';
@@ -0,0 +1,365 @@
import 'reflect-metadata';
import {
CreateBucketCommand,
DeleteObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import type { FileStorageInfrastructureOptions } from './file-storage.config';
import { MinioFileStorageAdapter } from './minio-file-storage.adapter';
import { verifyReadUrl } from './signing';
const BASE_OPTS: FileStorageInfrastructureOptions = {
endpoint: 'http://minio:9000',
accessKey: 'AKIA-test',
secretKey: 'sk-test',
bucket: 'coop-test',
signingSecret: 'signing-secret-32-bytes-aaaaaaaa',
publicBaseUrl: 'https://test.example.org',
};
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 1024,
allowedMime: ['image/jpeg', 'image/png'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
};
function makeAdapter(opts: FileStorageInfrastructureOptions = BASE_OPTS): {
adapter: MinioFileStorageAdapter;
send: jest.Mock;
} {
const adapter = new MinioFileStorageAdapter(opts);
const send = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(adapter as any).s3 = { send };
return { adapter, send };
}
describe('MinioFileStorageAdapter — getBucket валидация спеки', () => {
it('отклоняет name без двоеточия', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, name: 'no-colon' })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет неположительный maxBytes', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, maxBytes: 0 })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
it('отклоняет пустой allowedMime', () => {
const { adapter } = makeAdapter();
expect(() => adapter.getBucket({ ...SPEC, allowedMime: [] })).toThrow(
InterFileStorageBucketNotConfiguredError,
);
});
});
describe('MinioFileStorageAdapter — put', () => {
it('успешный put передаёт верные Bucket/Key/ContentType/Metadata в PutObjectCommand', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"abc123"' });
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array([1, 2, 3, 4]);
const result = await bucket.put('orders/42/main.jpg', body, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
});
expect(send).toHaveBeenCalledTimes(1);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
expect(cmd).toBeInstanceOf(PutObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/orders/42/main.jpg');
expect(cmd.input.ContentType).toBe('image/jpeg');
expect(cmd.input.ContentLength).toBe(4);
expect(cmd.input.Metadata).toEqual({ ownerId: 'u-1' });
expect(result.key).toBe('orders/42/main.jpg');
expect(result.etag).toBe('abc123');
expect(result.size).toBe(4);
});
it('отклоняет put с неподходящим MIME без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('orders/x/main.exe', new Uint8Array([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (Uint8Array) без обращения к S3', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const body = new Uint8Array(SPEC.maxBytes + 1);
await expect(
bucket.put('big.jpg', body, { contentType: 'image/jpeg', metadata: { ownerId: 'u-1' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put сверх maxBytes (ReadableStream) до отправки', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const chunks: Uint8Array[] = [
new Uint8Array(SPEC.maxBytes - 100),
new Uint8Array(200), // overshoot at second chunk
];
const stream = makeWebStream(chunks) as unknown as ReadableStream<Uint8Array>;
await expect(
bucket.put('big-stream.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1' },
}),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
expect(send).not.toHaveBeenCalled();
});
it('отклоняет put без обязательного metadata-поля', async () => {
const { adapter, send } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
expect(send).not.toHaveBeenCalled();
});
it('путь успешного потока — стрим читается, размер контролируется, S3 получает Buffer', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({ ETag: '"e1"' });
const bucket = adapter.getBucket(SPEC);
const stream = makeWebStream([
new Uint8Array([10, 20]),
new Uint8Array([30]),
]) as unknown as ReadableStream<Uint8Array>;
const r = await bucket.put('s.jpg', stream, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
expect(r.size).toBe(3);
const cmd = send.mock.calls[0][0] as PutObjectCommand;
const body = cmd.input.Body as Buffer;
expect(body.length).toBe(3);
expect(body[0]).toBe(10);
expect(body[2]).toBe(30);
});
it('оборачивает backend-ошибку put в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('x.jpg', new Uint8Array([1]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageBackendUnavailableError);
});
});
describe('MinioFileStorageAdapter — head', () => {
it('возвращает метаданные при успешном HEAD', async () => {
const { adapter, send } = makeAdapter();
const lastModified = new Date('2026-04-23T10:00:00Z');
send.mockResolvedValueOnce({
ContentLength: 42,
ETag: '"deadbeef"',
ContentType: 'image/png',
LastModified: lastModified,
Metadata: { ownerid: 'u-1' },
});
const bucket = adapter.getBucket(SPEC);
const r = await bucket.head('a/b.png');
expect((send.mock.calls[0][0] as HeadObjectCommand).input.Key).toBe('orders-images/a/b.png');
expect(r.size).toBe(42);
expect(r.etag).toBe('deadbeef');
expect(r.contentType).toBe('image/png');
expect(r.lastModified).toEqual(lastModified);
expect(r.metadata.ownerid).toBe('u-1');
});
it('бросает ObjectNotFoundError при S3-ошибке NotFound', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('бросает ObjectNotFoundError при ошибке NoSuchKey', async () => {
const { adapter, send } = makeAdapter();
const err = Object.assign(new Error('no such key'), { name: 'NoSuchKey' });
send.mockRejectedValueOnce(err);
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('missing.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
it('оборачивает прочие backend-ошибки в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('timeout'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — delete', () => {
it('успешный delete вызывает DeleteObjectCommand с правильным Key', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({});
const bucket = adapter.getBucket(SPEC);
await bucket.delete('a/b.jpg');
const cmd = send.mock.calls[0][0] as DeleteObjectCommand;
expect(cmd).toBeInstanceOf(DeleteObjectCommand);
expect(cmd.input.Bucket).toBe('coop-test');
expect(cmd.input.Key).toBe('orders-images/a/b.jpg');
});
it('оборачивает backend-ошибку delete в BackendUnavailableError', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('500'));
const bucket = adapter.getBucket(SPEC);
await expect(bucket.delete('x.jpg')).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
describe('MinioFileStorageAdapter — getReadUrl', () => {
it('возвращает URL верной структуры с подписью, проходящей verifyReadUrl', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 60 });
const parsed = new URL(url);
expect(parsed.origin).toBe('https://test.example.org');
expect(parsed.pathname).toBe('/api/storage/orders-images/orders/42/main.jpg');
const exp = Number(parsed.searchParams.get('exp'));
const sig = parsed.searchParams.get('sig')!;
const now = Math.floor(Date.now() / 1000);
expect(exp).toBeGreaterThan(now);
expect(exp).toBeLessThanOrEqual(now + 61);
expect(sig).toMatch(/^[0-9a-f]{64}$/);
const ok = verifyReadUrl({
bucket: 'orders-images',
key: 'orders/42/main.jpg',
expUnix: exp,
sig,
secret: BASE_OPTS.signingSecret,
});
expect(ok).toBe(true);
});
it('берёт TTL из defaultUrlTtlSeconds бакета, если не передан', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket({ ...SPEC, defaultUrlTtlSeconds: 30 });
const url = await bucket.getReadUrl('x.jpg');
const exp = Number(new URL(url).searchParams.get('exp'));
const now = Math.floor(Date.now() / 1000);
expect(exp - now).toBeGreaterThanOrEqual(28);
expect(exp - now).toBeLessThanOrEqual(31);
});
it('экранирует ключ посегментно, сохраняя слэши как разделители', async () => {
const { adapter } = makeAdapter();
const bucket = adapter.getBucket(SPEC);
const url = await bucket.getReadUrl('a b/c?d=e/f.jpg');
const parsed = new URL(url);
expect(parsed.pathname).toBe('/api/storage/orders-images/a%20b/c%3Fd%3De/f.jpg');
});
});
describe('MinioFileStorageAdapter — onApplicationBootstrap', () => {
it('пропускает CreateBucket, если HeadBucket прошёл', async () => {
const { adapter, send } = makeAdapter();
send.mockResolvedValueOnce({}); // HeadBucket OK
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
});
it('создаёт бакет, если HeadBucket вернул 404', async () => {
const { adapter, send } = makeAdapter();
const notFound = Object.assign(new Error('not found'), {
name: 'NotFound',
$metadata: { httpStatusCode: 404 },
});
send.mockRejectedValueOnce(notFound).mockResolvedValueOnce({});
await adapter.onApplicationBootstrap();
expect(send).toHaveBeenCalledTimes(2);
expect(send.mock.calls[0][0]).toBeInstanceOf(HeadBucketCommand);
expect(send.mock.calls[1][0]).toBeInstanceOf(CreateBucketCommand);
});
it('бросает BackendUnavailableError, если HeadBucket упал не на 404', async () => {
const { adapter, send } = makeAdapter();
send.mockRejectedValueOnce(new Error('connection refused'));
await expect(adapter.onApplicationBootstrap()).rejects.toBeInstanceOf(
InterFileStorageBackendUnavailableError,
);
});
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function makeWebStream(chunks: Uint8Array[]): {
getReader: () => { read: () => Promise<{ value?: Uint8Array; done: boolean }>; cancel: () => Promise<void> };
} {
let i = 0;
return {
getReader() {
return {
async read() {
if (i >= chunks.length) return { value: undefined, done: true };
const value = chunks[i++];
return { value, done: false };
},
async cancel() {
/* noop */
},
};
},
};
}
@@ -0,0 +1,362 @@
import { Inject, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import {
CreateBucketCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBody,
type InterFileStorageBucket,
type InterFileStorageBucketSpec,
type InterFileStorageGetReadUrlOptions,
type InterFileStorageObjectMetadata,
type InterFileStoragePort,
type InterFileStoragePutOptions,
type InterFileStoragePutResult,
} from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
import {
FILE_STORAGE_DEFAULT_URL_TTL_SECONDS,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
} from './file-storage.config';
import { signReadUrl } from './signing';
/**
* Адаптер `InterFileStoragePort` поверх MinIO/S3. Реализация одна и та же для обоих —
* различается только `endpoint` и `forcePathStyle`. На bootstrap идемпотентно создаёт
* физический бакет, дальше отдаёт `InterFileStorageBucket`-хэндлы по спекам.
*
* URL чтения формируется на собственный домен контроллера (служебная ручка `/api/storage/...`),
* подписывается HMAC-SHA256 секретом из конфига. При переходе на нативный S3 здесь же
* можно начать возвращать настоящие S3 presigned URL — контракт строки не меняется.
*/
@Injectable()
export class MinioFileStorageAdapter implements InterFileStoragePort, OnApplicationBootstrap {
private readonly logger = new Logger(MinioFileStorageAdapter.name);
private readonly s3: S3Client;
constructor(
@Inject(FILE_STORAGE_OPTIONS)
private readonly opts: FileStorageInfrastructureOptions,
) {
this.s3 = new S3Client({
endpoint: opts.endpoint,
region: opts.region ?? 'us-east-1',
credentials: {
accessKeyId: opts.accessKey,
secretAccessKey: opts.secretKey,
},
forcePathStyle: opts.forcePathStyle ?? true,
});
}
async onApplicationBootstrap(): Promise<void> {
await this.ensureBucketExists();
const registered = BucketRegistry.list();
this.logger.log(
`File storage готов. Физический бакет '${this.opts.bucket}', эндпоинт '${this.opts.endpoint}'. ` +
`Зарегистрировано бакетов через @UseBucket: ${registered.length}` +
(registered.length > 0
? ` (${registered.map((r) => `${r.cls.name}${r.spec.name}`).join(', ')})`
: ''),
);
}
private async ensureBucketExists(): Promise<void> {
try {
await this.s3.send(new HeadBucketCommand({ Bucket: this.opts.bucket }));
return;
} catch (e) {
if (!isNotFound(e)) {
throw new InterFileStorageBackendUnavailableError(
`HeadBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
try {
await this.s3.send(new CreateBucketCommand({ Bucket: this.opts.bucket }));
this.logger.log(`Создан физический бакет '${this.opts.bucket}'`);
} catch (e) {
throw new InterFileStorageBackendUnavailableError(
`CreateBucket(${this.opts.bucket}) не удался: ${getMessage(e)}`,
{ cause: asError(e) },
);
}
}
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket {
validateSpec(spec);
return new MinioBucketHandle(this.s3, this.opts, spec);
}
/**
* Внутренний fetch для HTTP-ручки `/api/storage/:bucket/:key` (E59-4).
* `physicalKey` — это уже собранный ключ S3-объекта вида `<extension>-<purpose>/<caller-key>`.
* @throws InterFileStorageObjectNotFoundError, InterFileStorageBackendUnavailableError
*/
async fetchObjectForReadProxy(physicalKey: string): Promise<FileStorageObjectStream> {
try {
const r = await this.s3.send(
new GetObjectCommand({ Bucket: this.opts.bucket, Key: physicalKey }),
);
return {
stream: r.Body as NodeJS.ReadableStream,
size: r.ContentLength ?? 0,
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(`Объект '${physicalKey}' не найден`);
}
throw wrapBackendError(e, `getObject '${physicalKey}'`);
}
}
}
class MinioBucketHandle implements InterFileStorageBucket {
private readonly publicBucketName: string;
private readonly defaultTtlSeconds: number;
constructor(
private readonly s3: S3Client,
private readonly opts: FileStorageInfrastructureOptions,
private readonly spec: InterFileStorageBucketSpec,
) {
this.publicBucketName = spec.name.replace(/:/g, '-');
this.defaultTtlSeconds =
spec.defaultUrlTtlSeconds ?? opts.defaultUrlTtlSeconds ?? FILE_STORAGE_DEFAULT_URL_TTL_SECONDS;
}
private physicalKey(key: string): string {
return `${this.publicBucketName}/${key}`;
}
async put(
key: string,
body: InterFileStorageBody,
opts: InterFileStoragePutOptions,
): Promise<InterFileStoragePutResult> {
if (!this.spec.allowedMime.includes(opts.contentType)) {
throw new InterFileStorageMimeRejectedError(
`MIME '${opts.contentType}' не входит в allowedMime бакета '${this.spec.name}'`,
);
}
this.validateMetadata(opts.metadata);
const buffer = await materializeAndCheckSize(body, this.spec.maxBytes, this.spec.name);
try {
const r = await this.s3.send(
new PutObjectCommand({
Bucket: this.opts.bucket,
Key: this.physicalKey(key),
Body: buffer,
ContentType: opts.contentType,
ContentLength: buffer.byteLength,
Metadata: opts.metadata as Record<string, string> | undefined,
}),
);
return {
key,
etag: stripQuotes(r.ETag) ?? '',
size: buffer.byteLength,
};
} catch (e) {
throw wrapBackendError(e, `put '${this.spec.name}/${key}'`);
}
}
async getReadUrl(key: string, opts?: InterFileStorageGetReadUrlOptions): Promise<string> {
const ttl = opts?.ttlSeconds ?? this.defaultTtlSeconds;
const exp = Math.floor(Date.now() / 1000) + ttl;
const sig = signReadUrl({
bucket: this.publicBucketName,
key,
expUnix: exp,
secret: this.opts.signingSecret,
});
const base = this.opts.publicBaseUrl.replace(/\/+$/, '');
return `${base}/api/storage/${encodeURIComponent(this.publicBucketName)}/${encodeKeyForPath(key)}?exp=${exp}&sig=${sig}`;
}
async delete(key: string): Promise<void> {
try {
await this.s3.send(
new DeleteObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
} catch (e) {
// S3 DeleteObject идемпотентен — отсутствие объекта не ошибка. Сюда попадают только
// реальные backend-фейлы.
throw wrapBackendError(e, `delete '${this.spec.name}/${key}'`);
}
}
async head(key: string): Promise<InterFileStorageObjectMetadata> {
try {
const r = await this.s3.send(
new HeadObjectCommand({ Bucket: this.opts.bucket, Key: this.physicalKey(key) }),
);
return {
size: r.ContentLength ?? 0,
etag: stripQuotes(r.ETag) ?? '',
contentType: r.ContentType ?? 'application/octet-stream',
lastModified: r.LastModified ?? new Date(0),
metadata: r.Metadata ?? {},
};
} catch (e) {
if (isNotFound(e)) {
throw new InterFileStorageObjectNotFoundError(
`Объект '${this.spec.name}/${key}' не найден`,
);
}
throw wrapBackendError(e, `head '${this.spec.name}/${key}'`);
}
}
private validateMetadata(metadata?: Readonly<Record<string, string>>): void {
if (!this.spec.metadataSchema) return;
for (const [field, kind] of Object.entries(this.spec.metadataSchema)) {
if (kind !== 'required') continue;
const value = metadata?.[field];
if (!value) {
throw new InterFileStorageMetadataValidationError(
`Метаданное '${field}' обязательно для бакета '${this.spec.name}'`,
);
}
}
}
}
/**
* Результат внутреннего fetch для HTTP-ручки `/api/storage/...`.
* Не часть публичного `InterFileStoragePort` — только для proxy-стрима внутри контроллера.
*/
export interface FileStorageObjectStream {
stream: NodeJS.ReadableStream;
size: number;
contentType: string;
lastModified: Date;
}
function validateSpec(spec: InterFileStorageBucketSpec): void {
if (!spec.name || !spec.name.includes(':')) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.name должен иметь формат '<extension>:<purpose>', получено '${spec.name}'`,
);
}
if (!Number.isFinite(spec.maxBytes) || spec.maxBytes <= 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.maxBytes должно быть положительным конечным числом, получено ${spec.maxBytes}`,
);
}
if (!spec.allowedMime || spec.allowedMime.length === 0) {
throw new InterFileStorageBucketNotConfiguredError(
`BucketSpec.allowedMime должен содержать хотя бы один MIME-тип`,
);
}
}
async function materializeAndCheckSize(
body: InterFileStorageBody,
maxBytes: number,
specName: string,
): Promise<Buffer> {
if (body instanceof Uint8Array) {
if (body.byteLength > maxBytes) {
throw new InterFileStorageObjectTooLargeError(
`Размер ${body.byteLength} байт превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
return Buffer.from(body);
}
// Web ReadableStream<Uint8Array> — duck-typed, чтобы не зависеть от глобального типа `ReadableStream`.
if (
typeof body === 'object' &&
body !== null &&
'getReader' in body &&
typeof (body as { getReader: unknown }).getReader === 'function'
) {
const reader = (body as { getReader: () => StreamReader }).getReader();
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
if (value && value.byteLength > 0) {
total += value.byteLength;
if (total > maxBytes) {
// Освобождаем reader, чтобы не оставлять стрим висящим.
try {
await reader.cancel();
} catch {
// ignore
}
throw new InterFileStorageObjectTooLargeError(
`Размер тела превышает лимит ${maxBytes} бакета '${specName}'`,
);
}
chunks.push(value);
}
}
return Buffer.concat(chunks);
}
throw new InterFileStorageBackendUnavailableError(
`Неподдерживаемый тип тела для бакета '${specName}'`,
);
}
interface StreamReader {
read(): Promise<{ value?: Uint8Array; done: boolean }>;
cancel(): Promise<void>;
}
function isNotFound(e: unknown): boolean {
if (typeof e !== 'object' || e === null) return false;
const ex = e as {
name?: string;
Code?: string;
$metadata?: { httpStatusCode?: number };
};
if (ex.name === 'NotFound' || ex.name === 'NoSuchKey' || ex.name === 'NoSuchBucket') return true;
if (ex.Code === 'NotFound' || ex.Code === 'NoSuchKey' || ex.Code === 'NoSuchBucket') return true;
if (ex.$metadata?.httpStatusCode === 404) return true;
return false;
}
function wrapBackendError(e: unknown, op: string): InterFileStorageBackendUnavailableError {
return new InterFileStorageBackendUnavailableError(`${op}: ${getMessage(e)}`, {
cause: asError(e),
});
}
function getMessage(e: unknown): string {
if (e instanceof Error) return e.message;
if (typeof e === 'string') return e;
return 'unknown error';
}
function asError(e: unknown): Error | undefined {
return e instanceof Error ? e : undefined;
}
function stripQuotes(s: string | undefined): string | undefined {
if (!s) return s;
return s.replace(/^"|"$/g, '');
}
function encodeKeyForPath(key: string): string {
// '/' — разделитель сегментов URL, остальное per-segment encodeURIComponent.
return key.split('/').map(encodeURIComponent).join('/');
}
@@ -0,0 +1,52 @@
import { signReadUrl, verifyReadUrl } from './signing';
describe('signing', () => {
const secret = 'test-secret-32-bytes-aaaaaaaaaaaa';
it('подпись детерминирована для одинакового набора параметров', () => {
const a = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
const b = signReadUrl({ bucket: 'b1', key: 'k1', expUnix: 1000, secret });
expect(a).toBe(b);
expect(a).toMatch(/^[0-9a-f]{64}$/);
});
it('verify проходит на корректную подпись', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(true);
});
it('verify отклоняет подмену bucket', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'expenses-receipts', key: 'a/b.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену key', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/c.jpg', expUnix: 9999, sig, secret })).toBe(false);
});
it('verify отклоняет подмену exp', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 10000, sig, secret })).toBe(false);
});
it('verify отклоняет подмену секрета', () => {
const sig = signReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, secret });
expect(
verifyReadUrl({ bucket: 'orders-images', key: 'a/b.jpg', expUnix: 9999, sig, secret: 'other' }),
).toBe(false);
});
it('verify отклоняет подпись неверной длины без выброса исключения', () => {
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: 'abcd', secret }),
).toBe(false);
});
it('verify отклоняет не-hex без выброса исключения', () => {
const fake = 'z'.repeat(64);
expect(
verifyReadUrl({ bucket: 'b', key: 'k', expUnix: 1, sig: fake, secret }),
).toBe(false);
});
});
@@ -0,0 +1,41 @@
import { createHmac, timingSafeEqual } from 'crypto';
/**
* HMAC-SHA256(secret, "<bucket>\n<key>\n<exp>") в hex.
* Используется и адаптером (выдача URL), и HTTP-ручкой (валидация).
*/
export function signReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
secret: string;
}): string {
const h = createHmac('sha256', args.secret);
h.update(`${args.bucket}\n${args.key}\n${args.expUnix}`);
return h.digest('hex');
}
/**
* Constant-time сверка подписи. Возвращает false на любую структурную ошибку
* (несовпадение длин, не-hex), а не выбрасывает — чтобы не различать каналы side-channel-ом.
*/
export function verifyReadUrl(args: {
bucket: string;
key: string;
expUnix: number;
sig: string;
secret: string;
}): boolean {
const expected = signReadUrl(args);
if (expected.length !== args.sig.length) return false;
let expectedBuf: Buffer;
let actualBuf: Buffer;
try {
expectedBuf = Buffer.from(expected, 'hex');
actualBuf = Buffer.from(args.sig, 'hex');
} catch {
return false;
}
if (expectedBuf.length !== actualBuf.length) return false;
return timingSafeEqual(expectedBuf, actualBuf);
}
@@ -0,0 +1,26 @@
import 'reflect-metadata';
import { BucketRegistry } from './bucket-registry';
import { UseBucket, bucketTokenFor } from './use-bucket.decorator';
describe('UseBucket decorator', () => {
beforeEach(() => {
BucketRegistry._resetForTests();
});
it('регистрирует спеку класса в BucketRegistry', () => {
@UseBucket({ name: 'demo:images', maxBytes: 1024, allowedMime: ['image/png'] })
class DemoService {}
const got = BucketRegistry.get(DemoService);
expect(got).toBeDefined();
expect(got?.name).toBe('demo:images');
expect(got?.maxBytes).toBe(1024);
expect(got?.allowedMime).toEqual(['image/png']);
});
it('bucketTokenFor стабилен и зависит только от имени класса', () => {
class FooService {}
expect(bucketTokenFor(FooService)).toBe('__InterFileStorageBucket:FooService');
expect(bucketTokenFor(FooService)).toBe(bucketTokenFor(FooService));
});
});
@@ -0,0 +1,42 @@
import { Inject } from '@nestjs/common';
import type { InterFileStorageBucketSpec } from '@coopenomics/inter';
import { BucketRegistry } from './bucket-registry';
/**
* DI-токен per-class бакета, под которым `forFeature` регистрирует фабрику `BucketHandle`.
* Конструктор `@InjectBucket()` инжектится по этому токену.
*/
export function bucketTokenFor(cls: { name: string }): string {
return `__InterFileStorageBucket:${cls.name}`;
}
/**
* Класс-декоратор: декларирует бакет, в который пишет данный сервис.
* Конвенция — один бакет на один сервис; повторная регистрация под другим именем запрещена.
*
* @example
* ```ts
* @UseBucket({ name: 'stol-zakazov:images', maxBytes: 10*MB, allowedMime: ['image/jpeg'] })
* @Injectable()
* export class OrderImagesService {
* constructor(@InjectBucket() private readonly bucket: InterFileStorageBucket) {}
* }
* ```
*/
export function UseBucket(spec: InterFileStorageBucketSpec): ClassDecorator {
return (target) => {
BucketRegistry.add(target as unknown as { name: string }, spec);
};
}
/**
* Параметр-декоратор: инжектит `InterFileStorageBucket` для текущего класса.
* Резолвится в DI-токен `bucketTokenFor(<ClassName>)`, который провайдится через
* `FileStorageInfrastructureModule.forFeature([ThisClass])`.
*/
export function InjectBucket(): ParameterDecorator {
return (target, propertyKey, parameterIndex) => {
const cls = target as unknown as { name: string };
Inject(bucketTokenFor(cls))(target, propertyKey, parameterIndex);
};
}
@@ -0,0 +1,30 @@
# File-storage integration tests
Тесты против реального MinIO. По умолчанию ожидают MinIO на `http://localhost:9100` с
кредами `testuser` / `testpassword` (см. `docker-compose.yml` рядом).
## Как запустить
```bash
# 1. Поднять MinIO
docker compose -f components/controller/tests/file-storage/docker-compose.yml up -d
# дождаться healthy
# 2. Запустить интеграционные тесты
cd components/controller
npm run test:integration:file-storage
# 3. После работы
docker compose -f components/controller/tests/file-storage/docker-compose.yml down -v
```
## Переопределение через env
| Переменная | Default | Что делает |
|---|---|---|
| `FILE_STORAGE_TEST_MINIO_ENDPOINT` | `http://localhost:9100` | URL MinIO |
| `FILE_STORAGE_TEST_MINIO_ACCESS_KEY` | `testuser` | Access key |
| `FILE_STORAGE_TEST_MINIO_SECRET_KEY` | `testpassword` | Secret key |
| `FILE_STORAGE_TEST_BUCKET` | `coop-test-{timestamp}` | Имя физического бакета (уникально per-run) |
Если MinIO недоступен на `endpoint`, suite пропускает тесты (`it.skip`) с диагностическим сообщением.
@@ -0,0 +1,19 @@
version: '3'
services:
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
container_name: file-storage-test-minio
command: server /data --console-address ":9101"
environment:
MINIO_ROOT_USER: testuser
MINIO_ROOT_PASSWORD: testpassword
ports:
# Уникальные порты, чтобы не конфликтовать с prod-MinIO (9000) и другими инстансами.
- '9100:9000'
- '9101:9101'
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/live']
interval: 5s
timeout: 3s
retries: 10
@@ -0,0 +1,280 @@
import 'reflect-metadata';
import { execSync } from 'child_process';
import { Readable } from 'stream';
import type { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import {
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
type InterFileStorageBucketSpec,
} from '@coopenomics/inter';
import {
FileStorageHttpController,
FILE_STORAGE_OPTIONS,
type FileStorageInfrastructureOptions,
MinioFileStorageAdapter,
signReadUrl,
} from '../../src/infrastructure/file-storage';
const ENDPOINT = process.env.FILE_STORAGE_TEST_MINIO_ENDPOINT ?? 'http://localhost:9100';
const ACCESS_KEY = process.env.FILE_STORAGE_TEST_MINIO_ACCESS_KEY ?? 'testuser';
const SECRET_KEY = process.env.FILE_STORAGE_TEST_MINIO_SECRET_KEY ?? 'testpassword';
const BUCKET = process.env.FILE_STORAGE_TEST_BUCKET ?? `coop-test-${Date.now()}`;
const SIGNING_SECRET = 'integration-signing-secret-aaaaaaaaaaaaaaaa';
const PUBLIC_BASE_URL = 'https://test.example.org';
const SPEC: InterFileStorageBucketSpec = {
name: 'orders:images',
maxBytes: 10 * 1024,
allowedMime: ['image/jpeg', 'image/png', 'application/pdf'],
metadataSchema: {
ownerId: 'required',
altText: 'optional',
},
defaultUrlTtlSeconds: 60,
};
/** Sync-проверка доступности MinIO. Решение `it` vs `it.skip` принимается до describe(). */
function probeMinioSync(): boolean {
try {
execSync(`curl -fsS --max-time 2 ${ENDPOINT}/minio/health/live`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const minioReachable = probeMinioSync();
if (!minioReachable) {
// eslint-disable-next-line no-console
console.warn(
`[integration] MinIO недоступен на ${ENDPOINT} — тесты пропущены. ` +
`Подними: docker compose -f tests/file-storage/docker-compose.yml up -d`,
);
}
const itLive = (name: string, fn: jest.ProvidesCallback, timeout?: number) =>
(minioReachable ? it : it.skip)(name, fn, timeout);
describe('file-storage integration (real MinIO)', () => {
let adapter: MinioFileStorageAdapter;
let app: INestApplication;
const opts: FileStorageInfrastructureOptions = {
endpoint: ENDPOINT,
accessKey: ACCESS_KEY,
secretKey: SECRET_KEY,
bucket: BUCKET,
signingSecret: SIGNING_SECRET,
publicBaseUrl: PUBLIC_BASE_URL,
forcePathStyle: true,
};
beforeAll(async () => {
if (!minioReachable) return;
adapter = new MinioFileStorageAdapter(opts);
await adapter.onApplicationBootstrap();
const moduleRef = await Test.createTestingModule({
controllers: [FileStorageHttpController],
providers: [
{ provide: FILE_STORAGE_OPTIONS, useValue: opts },
{ provide: MinioFileStorageAdapter, useValue: adapter },
],
}).compile();
app = moduleRef.createNestApplication();
await app.init();
}, 60_000);
afterAll(async () => {
if (app) await app.close();
});
itLive('put → head → getReadUrl → GET → bytes равны (image)', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from(jpegFixture());
await bucket.put('orders/42/main.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u-1', altText: 'demo' },
});
const meta = await bucket.head('orders/42/main.jpg');
expect(meta.size).toBe(payload.length);
expect(meta.contentType).toBe('image/jpeg');
expect(meta.metadata.ownerid ?? meta.metadata.ownerId).toBe('u-1');
const url = await bucket.getReadUrl('orders/42/main.jpg', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect(res.headers['content-type']).toContain('image/jpeg');
expect((res.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('put PDF среднего размера через стрим, читается обратно идентично', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.alloc(8 * 1024); // 8 KiB
for (let i = 0; i < payload.length; i++) payload[i] = i % 251;
const stream = bufferToWebStream(payload);
await bucket.put('reports/r-1/r1.pdf', stream, {
contentType: 'application/pdf',
metadata: { ownerId: 'u-2' },
});
const meta = await bucket.head('reports/r-1/r1.pdf');
expect(meta.size).toBe(payload.length);
const url = await bucket.getReadUrl('reports/r-1/r1.pdf', { ttlSeconds: 30 });
const parsed = new URL(url);
const res = await request(app.getHttpServer())
.get(parsed.pathname)
.query({ exp: parsed.searchParams.get('exp')!, sig: parsed.searchParams.get('sig')! })
.buffer(true)
.parse((response, cb) => {
const chunks: Buffer[] = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
expect((res.body as Buffer).equals(payload)).toBe(true);
}, 30_000);
itLive('delete идемпотентен (повтор на отсутствующий — успех)', async () => {
const bucket = adapter.getBucket(SPEC);
await bucket.put('to-delete.jpg', Buffer.from([1, 2, 3]), {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
await bucket.delete('to-delete.jpg');
await expect(bucket.delete('to-delete.jpg')).resolves.toBeUndefined();
await expect(bucket.delete('never-existed.jpg')).resolves.toBeUndefined();
});
itLive('head отсутствующего → ObjectNotFoundError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(bucket.head('does-not-exist.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('превышение maxBytes → ObjectTooLargeError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
const tooBig = Buffer.alloc(SPEC.maxBytes + 1);
await expect(
bucket.put('too-big.jpg', tooBig, { contentType: 'image/jpeg', metadata: { ownerId: 'u' } }),
).rejects.toBeInstanceOf(InterFileStorageObjectTooLargeError);
await expect(bucket.head('too-big.jpg')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('запрещённый MIME → MimeRejectedError, объект не появился', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('bad.exe', Buffer.from([0]), {
contentType: 'application/octet-stream',
metadata: { ownerId: 'u' },
}),
).rejects.toBeInstanceOf(InterFileStorageMimeRejectedError);
await expect(bucket.head('bad.exe')).rejects.toBeInstanceOf(
InterFileStorageObjectNotFoundError,
);
});
itLive('отсутствие required-метаданного → MetadataValidationError', async () => {
const bucket = adapter.getBucket(SPEC);
await expect(
bucket.put('no-meta.jpg', Buffer.from([0]), { contentType: 'image/jpeg', metadata: {} }),
).rejects.toBeInstanceOf(InterFileStorageMetadataValidationError);
});
itLive('HMAC-роут: 200 → 403 (exp) → 403 (sig) → 404', async () => {
const bucket = adapter.getBucket(SPEC);
const payload = Buffer.from('end-to-end');
await bucket.put('hmac-test/x.jpg', payload, {
contentType: 'image/jpeg',
metadata: { ownerId: 'u' },
});
// 200
const okUrl = await bucket.getReadUrl('hmac-test/x.jpg', { ttlSeconds: 30 });
const okParsed = new URL(okUrl);
const ok = await request(app.getHttpServer())
.get(okParsed.pathname)
.query({ exp: okParsed.searchParams.get('exp')!, sig: okParsed.searchParams.get('sig')! });
expect(ok.status).toBe(200);
// 403 — истёкший exp
const pastExp = Math.floor(Date.now() / 1000) - 1;
const pastSig = signReadUrl({
bucket: 'orders-images',
key: 'hmac-test/x.jpg',
expUnix: pastExp,
secret: SIGNING_SECRET,
});
const expired = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: pastExp, sig: pastSig });
expect(expired.status).toBe(403);
// 403 — чужой sig
const futureExp = Math.floor(Date.now() / 1000) + 60;
const wrongSig = await request(app.getHttpServer())
.get('/api/storage/orders-images/hmac-test/x.jpg')
.query({ exp: futureExp, sig: 'a'.repeat(64) });
expect(wrongSig.status).toBe(403);
// 404 — несуществующий ключ, но валидная подпись
const missingKey = 'hmac-test/missing.jpg';
const missingSig = signReadUrl({
bucket: 'orders-images',
key: missingKey,
expUnix: futureExp,
secret: SIGNING_SECRET,
});
const notFound = await request(app.getHttpServer())
.get(`/api/storage/orders-images/${missingKey}`)
.query({ exp: futureExp, sig: missingSig });
expect(notFound.status).toBe(404);
}, 30_000);
});
// ─── helpers ─────────────────────────────────────────────────────────────────
function jpegFixture(): Uint8Array {
// Минимальный валидный JPEG header + EOI; для проверки трактовать как опаковые байты.
return new Uint8Array([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0xff, 0xd9]);
}
function bufferToWebStream(buf: Buffer): ReadableStream<Uint8Array> {
// Простая реализация без зависимости от node:stream/web.
let sent = false;
return {
getReader() {
return {
async read() {
if (sent) return { value: undefined, done: true };
sent = true;
return { value: new Uint8Array(buf), done: false };
},
async cancel() {
/* noop */
},
};
},
} as unknown as ReadableStream<Uint8Array>;
}
// suppress unused-import warning for Readable in case parser flags
void Readable;
+132
View File
@@ -0,0 +1,132 @@
/**
* Универсальное файловое хранилище контура кооператива.
* Реализация — адаптер `MinioFileStorageAdapter` поверх MinIO/S3 SDK; токен `INTER_FILE_STORAGE`
* регистрируется в composition root контроллера. Расширения декларируют свои бакеты декоратором
* `@UseBucket(spec)` и получают типизированный `InterFileStorageBucket` через DI.
*
* Per-bucket изоляция: каждый бакет принадлежит одному расширению, физический путь объектов —
* `<extension>-<purpose>/<caller-defined-key>` внутри одного MinIO/S3-бакета на кооператив.
*
* Подробнее — req «SPEC: файловое хранилище v1».
*/
/** Тело загрузки. ReadableStream — для больших файлов через graphql-upload, Uint8Array — для мелочей. */
export type InterFileStorageBody = ReadableStream<Uint8Array> | Uint8Array;
/**
* Декларация бакета. Передаётся в `@UseBucket` и далее в `getBucket` порта.
* Поля валидации (`maxBytes`, `allowedMime`, `metadataSchema`) проверяются адаптером на каждом `put`.
*/
export interface InterFileStorageBucketSpec {
/** Логическое имя в формате `<extension>:<purpose>`. */
name: string;
/** Жёсткий лимит размера на один put (байт). */
maxBytes: number;
/** Allowlist MIME-типов. Адаптер сравнивает с `InterFileStoragePutOptions.contentType`. */
allowedMime: readonly string[];
/** Схема пользовательских метаданных. Адаптер валидирует на put и возвращает в head. */
metadataSchema?: Readonly<Record<string, 'required' | 'optional'>>;
/** TTL по умолчанию для `getReadUrl` (сек). Default — на усмотрение адаптера (рекомендуется 600). */
defaultUrlTtlSeconds?: number;
}
export interface InterFileStoragePutOptions {
/** MIME-тип содержимого. Должен быть в `allowedMime` бакета. */
contentType: string;
/** Custom user-metadata. Должна соответствовать `metadataSchema` бакета. */
metadata?: Readonly<Record<string, string>>;
}
export interface InterFileStoragePutResult {
key: string;
etag: string;
size: number;
}
export interface InterFileStorageGetReadUrlOptions {
/** Перекрывает `defaultUrlTtlSeconds` бакета. */
ttlSeconds?: number;
}
export interface InterFileStorageObjectMetadata {
size: number;
etag: string;
contentType: string;
lastModified: Date;
metadata: Readonly<Record<string, string>>;
}
/**
* Хэндл одного зарегистрированного бакета. Расширение получает его через DI и работает только с ним.
* Префикс `<extension>-<purpose>` инкапсулирован адаптером — наружу ключи остаются caller-defined.
*/
export interface InterFileStorageBucket {
/**
* Загружает объект под caller-defined ключом. Перезапись по тому же ключу — overwrite, как в S3.
* @throws InterFileStorageObjectTooLargeError — размер превышает `maxBytes` бакета.
* @throws InterFileStorageMimeRejectedError — `contentType` не входит в `allowedMime`.
* @throws InterFileStorageMetadataValidationError — отсутствует обязательное поле `metadataSchema`.
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
*/
put(
key: string,
body: InterFileStorageBody,
opts: InterFileStoragePutOptions,
): Promise<InterFileStoragePutResult>;
/**
* Возвращает короткоживущий URL для скачивания объекта.
* В v1 (MinIO) — HMAC-signed URL служебной ручки контроллера; при переходе на нативный S3 —
* настоящий S3 presigned URL. Контракт строки не меняется.
* ACL принимается доменом ДО вызова: получив URL, любой держатель может скачать до истечения TTL.
*/
getReadUrl(
key: string,
opts?: InterFileStorageGetReadUrlOptions,
): Promise<string>;
/**
* Идемпотентное удаление: отсутствие объекта — успех (как DELETE в S3).
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
*/
delete(key: string): Promise<void>;
/**
* Метаданные объекта без скачивания тела.
* @throws InterFileStorageObjectNotFoundError — объекта нет.
* @throws InterFileStorageBackendUnavailableError — недоступен MinIO/S3.
*/
head(key: string): Promise<InterFileStorageObjectMetadata>;
}
/**
* Корневой порт. Получается через `@Inject(INTER_FILE_STORAGE)` в реестре бакетов контроллера;
* прикладной код ходит не в порт напрямую, а в `InterFileStorageBucket` от `@UseBucket`.
*/
export interface InterFileStoragePort {
/**
* Возвращает типизированный хэндл для данной спеки.
* Синхронно — обеспечение существования физического бакета у бэкенда происходит отдельно
* (хук `OnApplicationBootstrap` адаптера, идемпотентно).
*
* @throws InterFileStorageBucketNotConfiguredError — спека не валидна.
*/
getBucket(spec: InterFileStorageBucketSpec): InterFileStorageBucket;
}
// ─── Ошибки ────────────────────────────────────────────────────────────────────
/** Базовый класс ошибок порта. Доменный код ловит конкретные подклассы. */
export class InterFileStorageError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = new.target.name;
}
}
export class InterFileStorageObjectNotFoundError extends InterFileStorageError {}
export class InterFileStorageObjectTooLargeError extends InterFileStorageError {}
export class InterFileStorageMimeRejectedError extends InterFileStorageError {}
export class InterFileStorageMetadataValidationError extends InterFileStorageError {}
export class InterFileStorageBackendUnavailableError extends InterFileStorageError {}
export class InterFileStorageBucketNotConfiguredError extends InterFileStorageError {}
+22
View File
@@ -1,11 +1,33 @@
export {
INTER_CHATCOOP_CALENDAR,
INTER_COOP_CALENDAR_EVENT_NOTIFICATION,
INTER_FILE_STORAGE,
INTER_MATRIX_ROOM_MESSAGING,
INTER_PROJECT_CAPITAL_CLEARANCE,
INTER_PROJECT_COMMUNICATION_ARTIFACTS,
} from './tokens';
export {
InterFileStorageBackendUnavailableError,
InterFileStorageBucketNotConfiguredError,
InterFileStorageError,
InterFileStorageMetadataValidationError,
InterFileStorageMimeRejectedError,
InterFileStorageObjectNotFoundError,
InterFileStorageObjectTooLargeError,
} from './file-storage.port';
export type {
InterFileStorageBody,
InterFileStorageBucket,
InterFileStorageBucketSpec,
InterFileStorageGetReadUrlOptions,
InterFileStorageObjectMetadata,
InterFileStoragePort,
InterFileStoragePutOptions,
InterFileStoragePutResult,
} from './file-storage.port';
export type {
InterCompletedCallTranscriptionHead,
InterProjectCommunicationArtifactsPort,
+3
View File
@@ -16,3 +16,6 @@ export const INTER_COOP_CALENDAR_EVENT_NOTIFICATION = Symbol.for('InterCoopCalen
/** Capital: username с подтверждённым допуском к проекту (appendix confirmed). */
export const INTER_PROJECT_CAPITAL_CLEARANCE = Symbol.for('InterProjectCapitalClearance');
/** FileStorage: универсальное файловое хранилище контура кооператива (MinIO в v1, S3 в v2). */
export const INTER_FILE_STORAGE = Symbol.for('InterFileStorage');
+19
View File
@@ -114,7 +114,26 @@ services:
- "127.0.0.1:${OPENSEARCH_HOST_PORT:-9200}:9200"
restart: unless-stopped
minio:
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
volumes:
- minio_data:/data
ports:
- "127.0.0.1:${MINIO_HOST_PORT:-9000}:9000"
- "127.0.0.1:${MINIO_CONSOLE_HOST_PORT:-9001}:9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 3s
retries: 10
volumes:
mongo_data:
postgres_data:
opensearch_data:
minio_data:
+670 -1
View File
@@ -259,6 +259,9 @@ importers:
'@a2seven/yoo-checkout':
specifier: ^1.1.4
version: 1.1.4
'@aws-sdk/client-s3':
specifier: ^3.1035.0
version: 3.1046.0
'@coopenomics/factory':
specifier: workspace:*
version: link:../factory
@@ -620,6 +623,9 @@ importers:
'@graphql-codegen/typescript-operations':
specifier: ^4.3.1
version: 4.6.1(graphql@16.13.2)
'@nestjs/testing':
specifier: ^11.1.19
version: 11.1.21(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)
'@redocly/cli':
specifier: ^1.18.0
version: 1.34.11(ajv@8.18.0)(encoding@0.1.13)
@@ -1768,6 +1774,161 @@ packages:
peerDependencies:
graphql: '*'
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
'@aws-crypto/crc32c@5.2.0':
resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==}
'@aws-crypto/sha1-browser@5.2.0':
resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==}
'@aws-crypto/sha256-browser@5.2.0':
resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==}
'@aws-crypto/sha256-js@5.2.0':
resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==}
engines: {node: '>=16.0.0'}
'@aws-crypto/supports-web-crypto@5.2.0':
resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==}
'@aws-crypto/util@5.2.0':
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
'@aws-sdk/client-s3@3.1046.0':
resolution: {integrity: sha512-Cwl2SPm0CLczqNLEW2AZR9G/W+Jap85K7uUVOTRHzG3pErVeYRKOnbzsbAqGHnSYBdaxZ9a58YVaWj67P8tF4w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.974.9':
resolution: {integrity: sha512-bXxosFunr+v/kqNb99r1NRkrVBha7CG036fRSpWGbC1A/e363XFQN6wcZMx7MYTdRr1tYwNnkrWX2xc1rT3BCQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/crc64-nvme@3.972.8':
resolution: {integrity: sha512-fVfUCL/Xh2zINYMPZvj+iBn6XWouQf0DAnjaWCI9MkmqXzL2Iy5FoQB8O7syFe6gN6AH1ecDDU58T51Ou0kFkA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.35':
resolution: {integrity: sha512-WkFQ8BedszVomhh/Zzs8WwnE/XBmTqZjoQVB8u/4zH6kZCjouXZpPpb93gD8m0EZmzAl7dxHE/y+yDpuKzNCMw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.37':
resolution: {integrity: sha512-ylx0ZJTU+2eNcvXQ69VNR3TVSYa/ibpvdK717/NxqR9aXRMn2QRWZaiI8aa5yY/fOWZ5mknSmxGaVxxtdwv3EA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.972.39':
resolution: {integrity: sha512-QhRSrdkk+Gq0AFIylpiI0N6lcJqFYV9Jtr4Luz5FpYOYbjJSfyTG6iLhnK/UPIgN1Jnon8WAmSC//16XYGvwkA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.39':
resolution: {integrity: sha512-1hU0NtC04QbFIuoBuF4aQ2A97GsSE5/A0ZJpDijwexsBREIQ4KPRYl3v/FfKCPBYsaTeGjkOFx5nLhWHY24LOw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.40':
resolution: {integrity: sha512-ZgrQaGkpyTlVSCCsffzijVg+KgftTAWYvI5Otc36J/4jNiHb+7MmBiJIR0a5AHLvifC92PiYHt5pijP0dswd1w==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.35':
resolution: {integrity: sha512-hNj1rAwZWT1vfz54BwH8FUWxZuqStrM25Q5LEIwn2erHPMRVAjLlpZqEbCEEqS99eEEOhdeetnS0WeNa3iYeEQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.972.39':
resolution: {integrity: sha512-mwIPNPldyCZkvHozb6E0X/vuQLN1UCjcA6MwUf1gaO7EwghCmuNZXatq0L3zptKFvPC4Nds7+WFUkifI1XmbSw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.39':
resolution: {integrity: sha512-b9HT8CnpyPVn1hU14Q7ihjwSPlRzToYmRYJxRd5jNHEZ43lrIhoLaTT8JmfQQt5j5M8rTX1iN1X8mvu0SM1dXA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-bucket-endpoint@3.972.11':
resolution: {integrity: sha512-AhVDn+qObNacklqmBABnFa3YfVk08CzksuuecL/x+lo95dZxXuAkqJZLUsAEKQ3EiDd5E9wTUBjh0cSogmKMYA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-expect-continue@3.972.11':
resolution: {integrity: sha512-xpobcctR1AHSrvkiArgTyLffn78Lt9unPMpa/yic9RKn+bOf/5M55UIM6RaPL5xKzI06/GSsTDywTWvzEAbyyw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-flexible-checksums@3.974.17':
resolution: {integrity: sha512-Js24a6sdH9SU5DI5++nlQJayCuOweiiTjnCcAsY75/JtaXF+xysDQ6nRBYx6pUPNY22viRYmdDTFZDaA9AF46g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-host-header@3.972.11':
resolution: {integrity: sha512-CBC6+tVYaOJo7QXgN1zJ4Ba2f3/Cpy4eRViYFimXW/O5Mn8hBmgXXzHu4vy4ubT80YWnp8aCFygr7dTOa14yQg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-location-constraint@3.972.10':
resolution: {integrity: sha512-rI3NZvJcEvjoD0+0PI0iUAwlPw2IlSlhyvgBK/3WkKJQE/YiKFedd9dMN2lVacdNxPNhxL/jzQaKQdrGtQagjQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-logger@3.972.10':
resolution: {integrity: sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-recursion-detection@3.972.12':
resolution: {integrity: sha512-5eltYxKB4MfdQv7/VhWxRbAVQKow5dz9votRFigTYrWJHMQXwLMltlbk7KFWSZh5NDBySfmjT7Jv/DWfYCmDng==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.38':
resolution: {integrity: sha512-Yuv3urkJtd1/b3kIURzHwihc1SV6n1t+uiXffOD2OpylZ7+4/QnO2W73yhLZzK1Z762BaqwQ3IVRqAHWzNbQ4A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-ssec@3.972.10':
resolution: {integrity: sha512-Gli9A0u8EVVb+5bFDGS/QbSVg28w/wpEidg1ggVcSj65BDTdGR6punsOcVjqdiu1i42WHWo51MCvARPIIz9juw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-user-agent@3.972.39':
resolution: {integrity: sha512-MlNSvNsSVlMKKWaCzA0GP1nS4Cuq3WCXUN1vmMvd+Ctztib5kmRcpmTtKx9kikN8szAc+gcdp7uqJJervV2nQg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.7':
resolution: {integrity: sha512-jT2AXOODobQfTYGC2SChMSnZ/voIcRV/LHlY1suyhY1bdgP/voKkhEg8Ci1jiGQ4lBiaso5BEAV3ZWWpPTfmYA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/region-config-resolver@3.972.14':
resolution: {integrity: sha512-VuLXVmm7+lKVxqFcOItPkXhjbJ02iUfxkxheRu41SfWf6/xrZup2A2SwHZos/LeQGu3SBHeqTQht80Uo3ienPA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.26':
resolution: {integrity: sha512-2N62veqdMZBCwQUHsbhtnaovOFjOa5Dn3dAD1nRqFTUXR4QmirT3HZnfus/L1DS08Vm5CkoKmL0iMVt6YbqEag==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1046.0':
resolution: {integrity: sha512-9je8nZt+ntB8IjhpGNayU/AkBgvq/f4aFO2bH1LSNC5JX6K8zY4LUnr/ymqunePrwq+B5OVBpL7ILjYzMFSZAw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.973.8':
resolution: {integrity: sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-endpoints@3.996.9':
resolution: {integrity: sha512-ibx8Vd73rCTHekNGeXX8cpGWoBKbNAlwKHL3yjSxxttu5QnNDaSAM7/0MFYDjU31/F4lyrPoQcGirT0ew61xcg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-locate-window@3.965.5':
resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/util-user-agent-browser@3.972.11':
resolution: {integrity: sha512-kq3RS6XQtHMrLFShbkem6h+8fxazB3jEIsbMC6aaSInOciRGE+eGAqTgJ+obO7Euo/pjM8thVqLiLISEH9X9DA==}
'@aws-sdk/util-user-agent-node@3.973.25':
resolution: {integrity: sha512-066hKH/0nvV7x4ofV/iK9kz8r/qNfcR6rzuEOFqI2vQL/fcTTsDAbTw0jmXkyMzANK8ltQdALj19ns3zuOJiUw==}
engines: {node: '>=20.0.0'}
peerDependencies:
aws-crt: '>=1.0.0'
peerDependenciesMeta:
aws-crt:
optional: true
'@aws-sdk/xml-builder@3.972.23':
resolution: {integrity: sha512-A0YmgYFv+hTI9c17Ntvd2hSehm9bmJfkb+ggADBwVKA8H/3+Jx94SzR2qOB9bAA9WFeDqnfz9PKKQ+D+YAKomA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.2.4':
resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.12.11':
resolution: {integrity: sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw==}
@@ -5192,6 +5353,19 @@ packages:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/testing@11.1.21':
resolution: {integrity: sha512-RhzaUFxr6/bpXWjKIzr7p2eHKMFMLwPgsxJNFcCf2CkkT3UEjW+KRGb7E2JY+fh+ck3zAdvQJrzATDnSsVlFZw==}
peerDependencies:
'@nestjs/common': ^11.0.0
'@nestjs/core': ^11.0.0
'@nestjs/microservices': ^11.0.0
'@nestjs/platform-express': ^11.0.0
peerDependenciesMeta:
'@nestjs/microservices':
optional: true
'@nestjs/platform-express':
optional: true
'@nestjs/throttler@6.5.0':
resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
peerDependencies:
@@ -5216,6 +5390,9 @@ packages:
resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==}
engines: {node: ^14.21.3 || >=16}
'@nodable/entities@2.1.0':
resolution: {integrity: sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==}
'@nodelib/fs.scandir@2.1.5':
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
engines: {node: '>= 8'}
@@ -6587,6 +6764,42 @@ packages:
'@sinonjs/fake-timers@10.3.0':
resolution: {integrity: sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==}
'@smithy/core@3.24.2':
resolution: {integrity: sha512-IKS7qX59fAGCYBmt5JChcDswQDupZqT2Yn2ZBA3UgTlsjRNNkQzZobbn95xoAAdtTyJmBiJB3Y02qR3rgy3Zog==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.3.2':
resolution: {integrity: sha512-iYr9ekBjmZ+FwkiHEopqGscBbl78X62cq3p5Dd0eC+gNd7fybNZFQQdDuOQjTVmFymleuA8YRWZnuXWZ8B3kKA==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.4.2':
resolution: {integrity: sha512-3wF40g8OOCA5BnwQUvwtzZqYBbWWftDjpAlWIUo6Yld3ZzJaMAKqg7MWQBPjE8oLaqvZQUE7tVGlZPsae6A4bQ==}
engines: {node: '>=18.0.0'}
'@smithy/is-array-buffer@2.2.0':
resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==}
engines: {node: '>=14.0.0'}
'@smithy/node-http-handler@4.7.2':
resolution: {integrity: sha512-EdksTZ8UXYxGUgQ4mpIKrHoaj9WVGsp66TpZuixLAz1Jex8YDLnS4RH9ktGED5aOpN0OJlEtrsC9IGt76go1eA==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.4.2':
resolution: {integrity: sha512-1km1OjdLRFuITWpCPofjFqzZ+tbeWuB72ZhcYjbjkCxZ21tTPfIs4GUxRrelMyKMLxLghGD58RENnXorU/O8cw==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.14.1':
resolution: {integrity: sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==}
engines: {node: '>=18.0.0'}
'@smithy/util-buffer-from@2.2.0':
resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==}
engines: {node: '>=14.0.0'}
'@smithy/util-utf8@2.3.0':
resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==}
engines: {node: '>=14.0.0'}
'@so-ric/colorspace@1.1.6':
resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==}
@@ -8484,6 +8697,9 @@ packages:
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
bpmn-js@18.13.2:
resolution: {integrity: sha512-OSsCknc5J7lnLFZxyK9hET4dxCV+k44mvI3QmbWkgbmBwPsNgV9CzI6FS8aIV5uea4rgBGS+Xscs0+wuDZQKNA==}
@@ -10962,10 +11178,17 @@ packages:
fast-xml-builder@1.1.4:
resolution: {integrity: sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==}
fast-xml-builder@1.2.0:
resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==}
fast-xml-parser@5.5.9:
resolution: {integrity: sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==}
hasBin: true
fast-xml-parser@5.7.2:
resolution: {integrity: sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==}
hasBin: true
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
@@ -14787,6 +15010,10 @@ packages:
resolution: {integrity: sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==}
engines: {node: '>=14.0.0'}
path-expression-matcher@1.5.0:
resolution: {integrity: sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==}
engines: {node: '>=14.0.0'}
path-intersection@4.1.0:
resolution: {integrity: sha512-urUP6WvhnxbHPdHYl6L7Yrc6+1ny6uOFKPCzPxTSUSYGHG0o94RmI7SvMMaScNAM5RtTf08bg4skc6/kjfne3A==}
engines: {node: '>= 14.20'}
@@ -16947,6 +17174,9 @@ packages:
strnum@2.2.2:
resolution: {integrity: sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
strtok3@10.3.5:
resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==}
engines: {node: '>=18'}
@@ -17919,6 +18149,7 @@ packages:
uuid@10.0.0:
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@11.0.3:
@@ -17931,15 +18162,17 @@ packages:
uuid@3.4.0:
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true
v8-compile-cache-lib@3.0.1:
@@ -18704,6 +18937,10 @@ packages:
resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==}
engines: {node: '>=12'}
xml-naming@0.1.0:
resolution: {integrity: sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==}
engines: {node: '>=16.0.0'}
xml-utils@1.10.2:
resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==}
@@ -19395,6 +19632,360 @@ snapshots:
immutable: 5.1.5
invariant: 2.2.4
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.8
tslib: 2.8.1
'@aws-crypto/crc32c@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.8
tslib: 2.8.1
'@aws-crypto/sha1-browser@5.2.0':
dependencies:
'@aws-crypto/supports-web-crypto': 5.2.0
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.8
'@aws-sdk/util-locate-window': 3.965.5
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-crypto/sha256-browser@5.2.0':
dependencies:
'@aws-crypto/sha256-js': 5.2.0
'@aws-crypto/supports-web-crypto': 5.2.0
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.8
'@aws-sdk/util-locate-window': 3.965.5
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-crypto/sha256-js@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
'@aws-sdk/types': 3.973.8
tslib: 2.8.1
'@aws-crypto/supports-web-crypto@5.2.0':
dependencies:
tslib: 2.8.1
'@aws-crypto/util@5.2.0':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/util-utf8': 2.3.0
tslib: 2.8.1
'@aws-sdk/client-s3@3.1046.0':
dependencies:
'@aws-crypto/sha1-browser': 5.2.0
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.974.9
'@aws-sdk/credential-provider-node': 3.972.40
'@aws-sdk/middleware-bucket-endpoint': 3.972.11
'@aws-sdk/middleware-expect-continue': 3.972.11
'@aws-sdk/middleware-flexible-checksums': 3.974.17
'@aws-sdk/middleware-host-header': 3.972.11
'@aws-sdk/middleware-location-constraint': 3.972.10
'@aws-sdk/middleware-logger': 3.972.10
'@aws-sdk/middleware-recursion-detection': 3.972.12
'@aws-sdk/middleware-sdk-s3': 3.972.38
'@aws-sdk/middleware-ssec': 3.972.10
'@aws-sdk/middleware-user-agent': 3.972.39
'@aws-sdk/region-config-resolver': 3.972.14
'@aws-sdk/signature-v4-multi-region': 3.996.26
'@aws-sdk/types': 3.973.8
'@aws-sdk/util-endpoints': 3.996.9
'@aws-sdk/util-user-agent-browser': 3.972.11
'@aws-sdk/util-user-agent-node': 3.973.25
'@smithy/core': 3.24.2
'@smithy/fetch-http-handler': 5.4.2
'@smithy/node-http-handler': 4.7.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/core@3.974.9':
dependencies:
'@aws-sdk/types': 3.973.8
'@aws-sdk/xml-builder': 3.972.23
'@smithy/core': 3.24.2
'@smithy/signature-v4': 5.4.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/crc64-nvme@3.972.8':
dependencies:
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.35':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.37':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/fetch-http-handler': 5.4.2
'@smithy/node-http-handler': 4.7.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.972.39':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/credential-provider-env': 3.972.35
'@aws-sdk/credential-provider-http': 3.972.37
'@aws-sdk/credential-provider-login': 3.972.39
'@aws-sdk/credential-provider-process': 3.972.35
'@aws-sdk/credential-provider-sso': 3.972.39
'@aws-sdk/credential-provider-web-identity': 3.972.39
'@aws-sdk/nested-clients': 3.997.7
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/credential-provider-imds': 4.3.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-login@3.972.39':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/nested-clients': 3.997.7
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-node@3.972.40':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.35
'@aws-sdk/credential-provider-http': 3.972.37
'@aws-sdk/credential-provider-ini': 3.972.39
'@aws-sdk/credential-provider-process': 3.972.35
'@aws-sdk/credential-provider-sso': 3.972.39
'@aws-sdk/credential-provider-web-identity': 3.972.39
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/credential-provider-imds': 4.3.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-process@3.972.35':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.972.39':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/nested-clients': 3.997.7
'@aws-sdk/token-providers': 3.1046.0
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/credential-provider-web-identity@3.972.39':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/nested-clients': 3.997.7
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/middleware-bucket-endpoint@3.972.11':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-expect-continue@3.972.11':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-flexible-checksums@3.974.17':
dependencies:
'@aws-crypto/crc32': 5.2.0
'@aws-crypto/crc32c': 5.2.0
'@aws-crypto/util': 5.2.0
'@aws-sdk/core': 3.974.9
'@aws-sdk/crc64-nvme': 3.972.8
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-host-header@3.972.11':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-location-constraint@3.972.10':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-logger@3.972.10':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-recursion-detection@3.972.12':
dependencies:
'@aws-sdk/types': 3.973.8
'@aws/lambda-invoke-store': 0.2.4
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.38':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/signature-v4-multi-region': 3.996.26
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/signature-v4': 5.4.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-ssec@3.972.10':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/middleware-user-agent@3.972.39':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/types': 3.973.8
'@aws-sdk/util-endpoints': 3.996.9
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.7':
dependencies:
'@aws-crypto/sha256-browser': 5.2.0
'@aws-crypto/sha256-js': 5.2.0
'@aws-sdk/core': 3.974.9
'@aws-sdk/middleware-host-header': 3.972.11
'@aws-sdk/middleware-logger': 3.972.10
'@aws-sdk/middleware-recursion-detection': 3.972.12
'@aws-sdk/middleware-user-agent': 3.972.39
'@aws-sdk/region-config-resolver': 3.972.14
'@aws-sdk/signature-v4-multi-region': 3.996.26
'@aws-sdk/types': 3.973.8
'@aws-sdk/util-endpoints': 3.996.9
'@aws-sdk/util-user-agent-browser': 3.972.11
'@aws-sdk/util-user-agent-node': 3.973.25
'@smithy/core': 3.24.2
'@smithy/fetch-http-handler': 5.4.2
'@smithy/node-http-handler': 4.7.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/region-config-resolver@3.972.14':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.26':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/signature-v4': 5.4.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/token-providers@3.1046.0':
dependencies:
'@aws-sdk/core': 3.974.9
'@aws-sdk/nested-clients': 3.997.7
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
transitivePeerDependencies:
- aws-crt
'@aws-sdk/types@3.973.8':
dependencies:
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/util-endpoints@3.996.9':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/util-locate-window@3.965.5':
dependencies:
tslib: 2.8.1
'@aws-sdk/util-user-agent-browser@3.972.11':
dependencies:
'@aws-sdk/types': 3.973.8
'@smithy/types': 4.14.1
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/util-user-agent-node@3.973.25':
dependencies:
'@aws-sdk/middleware-user-agent': 3.972.39
'@aws-sdk/types': 3.973.8
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.23':
dependencies:
'@nodable/entities': 2.1.0
'@smithy/types': 4.14.1
fast-xml-parser: 5.7.2
tslib: 2.8.1
'@aws/lambda-invoke-store@0.2.4': {}
'@babel/code-frame@7.12.11':
dependencies:
'@babel/highlight': 7.25.9
@@ -23580,6 +24171,14 @@ snapshots:
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/testing@11.1.21(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(@nestjs/platform-express@10.4.22)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2)
'@nestjs/core': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/platform-express@10.4.22)(encoding@0.1.13)(reflect-metadata@0.1.14)(rxjs@7.8.2)
tslib: 2.8.1
optionalDependencies:
'@nestjs/platform-express': 10.4.22(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)
'@nestjs/throttler@6.5.0(@nestjs/common@10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2))(@nestjs/core@10.4.22)(reflect-metadata@0.1.14)':
dependencies:
'@nestjs/common': 10.4.22(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.1.14)(rxjs@7.8.2)
@@ -23599,6 +24198,8 @@ snapshots:
'@noble/hashes@1.8.0': {}
'@nodable/entities@2.1.0': {}
'@nodelib/fs.scandir@2.1.5':
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -25267,6 +25868,54 @@ snapshots:
dependencies:
'@sinonjs/commons': 3.0.1
'@smithy/core@3.24.2':
dependencies:
'@aws-crypto/crc32': 5.2.0
'@smithy/types': 4.14.1
tslib: 2.8.1
'@smithy/credential-provider-imds@4.3.2':
dependencies:
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@smithy/fetch-http-handler@5.4.2':
dependencies:
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@smithy/is-array-buffer@2.2.0':
dependencies:
tslib: 2.8.1
'@smithy/node-http-handler@4.7.2':
dependencies:
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@smithy/signature-v4@5.4.2':
dependencies:
'@smithy/core': 3.24.2
'@smithy/types': 4.14.1
tslib: 2.8.1
'@smithy/types@4.14.1':
dependencies:
tslib: 2.8.1
'@smithy/util-buffer-from@2.2.0':
dependencies:
'@smithy/is-array-buffer': 2.2.0
tslib: 2.8.1
'@smithy/util-utf8@2.3.0':
dependencies:
'@smithy/util-buffer-from': 2.2.0
tslib: 2.8.1
'@so-ric/colorspace@1.1.6':
dependencies:
color: 5.0.3
@@ -27817,6 +28466,8 @@ snapshots:
boolean@3.2.0:
optional: true
bowser@2.14.1: {}
bpmn-js@18.13.2:
dependencies:
bpmn-moddle: 10.0.0
@@ -31469,12 +32120,24 @@ snapshots:
dependencies:
path-expression-matcher: 1.2.0
fast-xml-builder@1.2.0:
dependencies:
path-expression-matcher: 1.5.0
xml-naming: 0.1.0
fast-xml-parser@5.5.9:
dependencies:
fast-xml-builder: 1.1.4
path-expression-matcher: 1.2.0
strnum: 2.2.2
fast-xml-parser@5.7.2:
dependencies:
'@nodable/entities': 2.1.0
fast-xml-builder: 1.2.0
path-expression-matcher: 1.5.0
strnum: 2.3.0
fastq@1.20.1:
dependencies:
reusify: 1.1.0
@@ -36218,6 +36881,8 @@ snapshots:
path-expression-matcher@1.2.0: {}
path-expression-matcher@1.5.0: {}
path-intersection@4.1.0: {}
path-is-absolute@1.0.1: {}
@@ -38803,6 +39468,8 @@ snapshots:
strnum@2.2.2: {}
strnum@2.3.0: {}
strtok3@10.3.5:
dependencies:
'@tokenizer/token': 0.3.0
@@ -40726,6 +41393,8 @@ snapshots:
xml-name-validator@4.0.0: {}
xml-naming@0.1.0: {}
xml-utils@1.10.2: {}
xmlbuilder2@4.0.3: