Compare commits

...

9 Commits

29 changed files with 2119 additions and 61 deletions

View File

@ -0,0 +1,67 @@
import { ArgumentsHost, BadRequestException, HttpStatus } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
const createHost = () => {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
const host = {
switchToHttp: () => ({
getResponse: () => ({ status }),
getRequest: () => ({ url: '/api/v1/test' }),
}),
} as unknown as ArgumentsHost;
return { host, status, json };
};
it('does not expose internal Error.message for unhandled exceptions', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new Error('Prisma failed at file:///secret/path'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
path: '/api/v1/test',
}),
);
expect(json.mock.calls[0][0].message).not.toContain('Prisma failed');
});
it('returns safe defaults for non-Error thrown values', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch('some string error', host);
expect(status).toHaveBeenCalledWith(HttpStatus.INTERNAL_SERVER_ERROR);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
message: 'Internal server error',
error: 'Internal Server Error',
}),
);
});
it('keeps HttpException response messages intact', () => {
const filter = new HttpExceptionFilter();
const { host, status, json } = createHost();
filter.catch(new BadRequestException('Invalid request'), host);
expect(status).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST);
expect(json).toHaveBeenCalledWith(
expect.objectContaining({
statusCode: HttpStatus.BAD_REQUEST,
message: 'Invalid request',
error: 'Bad Request',
}),
);
});
});

View File

@ -26,8 +26,9 @@ export class HttpExceptionFilter implements ExceptionFilter {
error = (r.error as string) || exception.name;
}
} else if (exception instanceof Error) {
message = exception.message;
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
} else {
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
}
response.status(status).json({

View File

@ -0,0 +1,76 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
describe('backend runtime configuration', () => {
const OLD_ENV = process.env;
beforeEach(() => {
vi.resetModules();
process.env = { ...OLD_ENV };
delete process.env.JWT_SECRET;
delete process.env.JWT_REFRESH_SECRET;
delete process.env.BACKEND_CORS_ORIGINS;
});
afterEach(() => {
process.env = OLD_ENV;
});
it('keeps dev auth defaults outside production', async () => {
const configuration = (await import('./configuration')).default;
expect(configuration().auth).toMatchObject({
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'dev-refresh-secret-change-in-production',
});
});
it('parses backend CORS origins from comma-separated env', async () => {
process.env.BACKEND_CORS_ORIGINS = 'https://app.example.com, http://localhost:5173 ';
const configuration = (await import('./configuration')).default;
expect(configuration().cors.origins).toEqual([
'https://app.example.com',
'http://localhost:5173',
]);
});
it('rejects production defaults for JWT secrets', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: ['https://app.example.com'],
}),
).toThrow('JWT_SECRET must be set to a non-default value in production');
});
it('rejects production credentialed CORS without explicit origins', async () => {
const { assertSafeProductionConfig } = await import('../main');
expect(() =>
assertSafeProductionConfig({
nodeEnv: 'production',
jwtSecret: 'custom-access-secret',
jwtRefreshSecret: 'custom-refresh-secret',
corsOrigins: [],
}),
).toThrow('BACKEND_CORS_ORIGINS must contain at least one origin in production');
});
it('allows development with reflected CORS', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('development', [])).toBe(true);
});
it('uses explicit production CORS origins', async () => {
const { buildCorsOrigin } = await import('../main');
expect(buildCorsOrigin('production', ['https://app.example.com'])).toEqual([
'https://app.example.com',
]);
});
});

View File

@ -1,5 +1,14 @@
import { registerAs } from '@nestjs/config';
export const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production';
export const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production';
const parseCsv = (value: string | undefined): string[] =>
(value ?? '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
export default registerAs('app', () => ({
port: parseInt(process.env.PORT || '3000', 10),
database: {
@ -38,6 +47,9 @@ export default registerAs('app', () => ({
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
},
cors: {
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
},
auth: {
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-secret-change-in-production',

View File

@ -5,7 +5,36 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import cookieParser from 'cookie-parser';
import { DEV_JWT_SECRET, DEV_JWT_REFRESH_SECRET } from './config/configuration';
export type BackendRuntimeConfig = {
nodeEnv: string;
jwtSecret: string;
jwtRefreshSecret: string;
corsOrigins: string[];
};
export function assertSafeProductionConfig(config: BackendRuntimeConfig): void {
if (config.nodeEnv !== 'production') return;
if (!config.jwtSecret || config.jwtSecret === DEV_JWT_SECRET) {
throw new Error('JWT_SECRET must be set to a non-default value in production');
}
if (!config.jwtRefreshSecret || config.jwtRefreshSecret === DEV_JWT_REFRESH_SECRET) {
throw new Error('JWT_REFRESH_SECRET must be set to a non-default value in production');
}
if (config.corsOrigins.length === 0) {
throw new Error('BACKEND_CORS_ORIGINS must contain at least one origin in production');
}
}
export function buildCorsOrigin(nodeEnv: string, corsOrigins: string[]): boolean | string[] {
return nodeEnv === 'production' ? corsOrigins : true;
}
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -17,7 +46,20 @@ async function bootstrap() {
app.useGlobalInterceptors(new TransformInterceptor());
app.use(cookieParser());
app.enableCors({ origin: true, credentials: true });
const configService = app.get(ConfigService);
const runtimeConfig: BackendRuntimeConfig = {
nodeEnv: process.env.NODE_ENV || 'development',
jwtSecret: configService.get<string>('app.auth.jwtSecret', ''),
jwtRefreshSecret: configService.get<string>('app.auth.jwtRefreshSecret', ''),
corsOrigins: configService.get<string[]>('app.cors.origins', []),
};
assertSafeProductionConfig(runtimeConfig);
app.enableCors({
origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins),
credentials: true,
});
const config = new DocumentBuilder()
.setTitle('MoexVibe API')
@ -32,4 +74,7 @@ async function bootstrap() {
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
}
bootstrap();
if (process.env.NODE_ENV !== 'test') {
void bootstrap();
}

View File

@ -0,0 +1,65 @@
import { ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
describe('CacheService', () => {
const configService = {
get: vi.fn((_key: string, fallback?: unknown) => fallback),
} as unknown as ConfigService;
const createCache = () => ({
get: vi.fn(),
set: vi.fn(),
});
it('stores data with cachedAt metadata on cache miss', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-25T10:00:00.000Z'));
const cache = createCache();
cache.get.mockResolvedValue(undefined);
const service = new CacheService(cache as never, configService);
try {
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 1 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: false,
cachedAt: '2026-06-25T10:00:00.000Z',
});
expect(cache.set).toHaveBeenCalledWith(
'prefix:a',
{ data: { value: 1 }, cachedAt: '2026-06-25T10:00:00.000Z' },
900,
);
} finally {
vi.useRealTimers();
}
});
it('returns cachedAt metadata on cache hit', async () => {
const cache = createCache();
cache.get.mockResolvedValue({
data: { value: 1 },
cachedAt: '2026-06-25T10:00:00.000Z',
});
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({
data: { value: 1 },
fromCache: true,
cachedAt: '2026-06-25T10:00:00.000Z',
});
});
it('supports legacy raw cache values during rollout', async () => {
const cache = createCache();
cache.get.mockResolvedValue({ value: 1 });
const service = new CacheService(cache as never, configService);
const result = await service.getOrFetch('prefix', ['a'], async () => ({ value: 2 }), 'ttlKey');
expect(result).toEqual({ data: { value: 1 }, fromCache: true, cachedAt: null });
});
});

View File

@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { ConfigService } from '@nestjs/config';
type CacheEntry<T> = {
data: T;
cachedAt: string;
};
@Injectable()
export class CacheService {
constructor(
@ -18,6 +23,16 @@ export class CacheService {
await this.cacheManager.set(key, value, ttl);
}
private isCacheEntry<T>(value: unknown): value is CacheEntry<T> {
return (
typeof value === 'object' &&
value !== null &&
'data' in value &&
'cachedAt' in value &&
typeof (value as { cachedAt?: unknown }).cachedAt === 'string'
);
}
private buildKey(...parts: string[]): string {
return parts.join(':');
}
@ -31,14 +46,19 @@ export class CacheService {
const key = this.buildKey(keyPrefix, ...keyParts);
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
const cached = await this.get<T>(key);
const cached = await this.get<CacheEntry<T> | T>(key);
if (cached !== undefined) {
return { data: cached, fromCache: true, cachedAt: null };
if (this.isCacheEntry<T>(cached)) {
return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt };
}
return { data: cached as T, fromCache: true, cachedAt: null };
}
const data = await fetchFn();
await this.set(key, data, ttl);
const cachedAt = new Date().toISOString();
await this.set(key, { data, cachedAt }, ttl);
return { data, fromCache: false, cachedAt: new Date().toISOString() };
return { data, fromCache: false, cachedAt };
}
}

View File

@ -8,6 +8,7 @@ import {
IsIn,
MaxLength,
MinLength,
IsDateString,
} from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
@ -31,7 +32,7 @@ export class AddPositionDto {
@ApiProperty({ example: 10 })
@IsInt()
@Min(0)
@Min(1)
quantity!: number;
@ApiPropertyOptional({ example: 250.5 })
@ -41,7 +42,7 @@ export class AddPositionDto {
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-01' })
@IsString()
@IsDateString()
@IsOptional()
buyDate?: string;

View File

@ -0,0 +1,43 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { AddPositionDto } from './add-position.dto';
import { UpdatePositionDto } from './update-position.dto';
describe('position DTO validation', () => {
const validateDto = async <T extends object>(cls: new () => T, payload: Record<string, unknown>) =>
validate(plainToInstance(cls, payload));
it('rejects zero quantity when adding a position', async () => {
const errors = await validateDto(AddPositionDto, { secid: 'SBER', quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects zero quantity when updating a position', async () => {
const errors = await validateDto(UpdatePositionDto, { quantity: 0 });
expect(errors.some((error) => error.property === 'quantity')).toBe(true);
});
it('rejects invalid buyDate values', async () => {
const addErrors = await validateDto(AddPositionDto, {
secid: 'SBER',
quantity: 1,
buyDate: 'not-a-date',
});
const updateErrors = await validateDto(UpdatePositionDto, { buyDate: 'not-a-date' });
expect(addErrors.some((error) => error.property === 'buyDate')).toBe(true);
expect(updateErrors.some((error) => error.property === 'buyDate')).toBe(true);
});
it('accepts valid position payloads', async () => {
await expect(
validateDto(AddPositionDto, { secid: 'SBER', quantity: 1, buyDate: '2026-06-01' }),
).resolves.toHaveLength(0);
await expect(
validateDto(UpdatePositionDto, { quantity: 2, buyDate: '2026-06-15' }),
).resolves.toHaveLength(0);
});
});

View File

@ -7,6 +7,7 @@ import {
IsArray,
IsIn,
MaxLength,
IsDateString,
} from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
@ -24,7 +25,7 @@ const TAGS = [
export class UpdatePositionDto {
@ApiPropertyOptional({ example: 15 })
@IsInt()
@Min(0)
@Min(1)
@IsOptional()
quantity?: number;
@ -35,7 +36,7 @@ export class UpdatePositionDto {
buyPrice?: number;
@ApiPropertyOptional({ example: '2026-06-15' })
@IsString()
@IsDateString()
@IsOptional()
buyDate?: string;

View File

@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
describe('BrokerAccountsService', () => {
const client = {
getServiceClient: vi.fn(),
getUsersClient: vi.fn(),
callUnary: vi.fn(),
} as unknown as TBankClientService;
const cache = {
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
cachedAt: '2026-06-16T02:30:00.000Z',
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.getUsersClient).mockReturnValue({ getAccounts: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
accounts: [
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },

View File

@ -32,9 +32,9 @@ export class BrokerAccountsService {
}
private async fetchAccounts(): Promise<BrokerAccount[]> {
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
const usersClient = this.tbankClient.getUsersClient();
const response = await this.tbankClient.callUnary<
Record<string, string>,
{ status: string },
TBankAccountsResponse
>(
'UsersService/GetAccounts',

View File

@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
}
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any;
const instrumentsClient = this.tbankClient.getInstrumentsClient();
const response = await this.tbankClient.callUnary<
{ idType: string; id: string },
TBankInstrumentResponse

View File

@ -6,7 +6,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerOperationsService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.getOperationsClient).mockReturnValue({ getOperationsByCursor: vi.fn() } as any);
vi.mocked(client.callUnary).mockResolvedValue({
hasNext: false,
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],

View File

@ -69,7 +69,7 @@ export class BrokerOperationsService {
accountId: string,
request: Record<string, unknown>,
): Promise<BrokerOperationsPage> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
const response = await this.tbankClient.callUnary<
Record<string, unknown>,
TBankOperationsByCursorResponse

View File

@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
describe('BrokerPortfolioService', () => {
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
const client = { getServiceClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const client = { getOperationsClient: vi.fn(), callUnary: vi.fn() } as unknown as TBankClientService;
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
beforeEach(() => {
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
@ -104,7 +104,7 @@ describe('BrokerPortfolioService', () => {
it('returns first page of positions', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
it('paginates using cursor', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
it('returns last page with hasNext=false', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
it('filters by instrument type and caches with type in key', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
it('returns empty items when type filter matches nothing', async () => {
mockAccount();
mockCache();
vi.mocked(client.getServiceClient).mockReturnValue({
vi.mocked(client.getOperationsClient).mockReturnValue({
getPortfolio: vi.fn(),
} as any);
vi.mocked(client.callUnary).mockResolvedValueOnce({

View File

@ -120,7 +120,7 @@ export class BrokerPortfolioService {
'tbank:raw-portfolio',
[accountId],
async () => {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
return this.tbankClient.callUnary<
{ accountId: string; currency: string },
TBankPortfolioResponse
@ -139,7 +139,7 @@ export class BrokerPortfolioService {
}
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
const operationsClient = this.tbankClient.getOperationsClient();
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
'OperationsService/GetPositions',
operationsClient.getPositions.bind(operationsClient),

View File

@ -44,6 +44,16 @@ describe('TBankClientService', () => {
expect(() => service.getServiceClient('UsersService')).not.toThrow();
});
it('exposes typed service-client facades for broker services', () => {
const service = new TBankClientService(config);
expect(service.getUsersClient()).toHaveProperty('getAccounts');
expect(service.getOperationsClient()).toHaveProperty('getPortfolio');
expect(service.getOperationsClient()).toHaveProperty('getPositions');
expect(service.getOperationsClient()).toHaveProperty('getOperationsByCursor');
expect(service.getInstrumentsClient()).toHaveProperty('getInstrumentBy');
});
it('creates grpc SSL credentials with configured custom CA certificate', () => {
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');

View File

@ -1,6 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
import type {
TBankAccountsResponse,
TBankInstrumentResponse,
TBankOperationsByCursorResponse,
TBankPortfolioResponse,
TBankPositionsResponse,
} from '../types/tbank-proto.types';
import {
CallOptions,
ChannelCredentials,
@ -26,6 +33,25 @@ type GrpcUnary<TRequest, TResponse> = (
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
type TBankAccountsRequest = { status: string };
type TBankPortfolioRequest = { accountId: string; currency: string };
type TBankPositionsRequest = { accountId: string };
type TBankInstrumentRequest = { idType: string; id: string };
export type TBankUsersClient = Client & {
getAccounts: GrpcUnary<TBankAccountsRequest, TBankAccountsResponse>;
};
export type TBankOperationsClient = Client & {
getPortfolio: GrpcUnary<TBankPortfolioRequest, TBankPortfolioResponse>;
getPositions: GrpcUnary<TBankPositionsRequest, TBankPositionsResponse>;
getOperationsByCursor: GrpcUnary<Record<string, unknown>, TBankOperationsByCursorResponse>;
};
export type TBankInstrumentsClient = Client & {
getInstrumentBy: GrpcUnary<TBankInstrumentRequest, TBankInstrumentResponse>;
};
type QueueName = 'operations' | 'instruments' | 'users';
@Injectable()
@ -120,6 +146,18 @@ export class TBankClientService {
return client;
}
getUsersClient(): TBankUsersClient {
return this.getServiceClient('UsersService') as TBankUsersClient;
}
getOperationsClient(): TBankOperationsClient {
return this.getServiceClient('OperationsService') as TBankOperationsClient;
}
getInstrumentsClient(): TBankInstrumentsClient {
return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient;
}
async callUnary<TRequest, TResponse>(
label: string,
method: GrpcUnary<TRequest, TResponse>,

View File

@ -92,9 +92,17 @@
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-06-13T12:00:00.000Z",
"uptime": 1234.56
"data": {
"status": "ok",
"timestamp": "2026-06-25T12:00:00.000Z",
"uptime": 1234.56,
"checks": [
{ "name": "database", "status": "ok" },
{ "name": "moex", "status": "ok" },
{ "name": "tbank", "status": "ok" }
]
},
"meta": { "fromCache": false, "cachedAt": null }
}
```
@ -403,5 +411,5 @@ codegen types.
| `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
| `/api/v1/broker/accounts/:accountId/operations/refresh` | POST | Принудительная синхронизация операций из T-Bank |
| `/api/v1/broker/accounts/:accountId/operations/sync` | POST | Принудительная синхронизация операций из T-Bank |
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |

View File

@ -25,20 +25,20 @@ flowchart TB
PrismaService["PrismaService"]
CacheService["CacheService"]
MoexClientService["MoexClientService"]
MoexHttpClient["MoexHttpClient"]
TBankClientService["TBankClientService"]
PrismaModule --> PrismaService
CacheModule --> CacheService
MoexClientModule --> MoexClientService
MoexClientModule --> MoexHttpClient
AuthModule --> PrismaService
PortfolioModule --> PrismaService
PortfolioModule --> CacheService
PortfolioModule --> MoexClientService
PortfolioModule --> MoexHttpClient
MarketModules --> CacheService
MarketModules --> MoexClientService
MarketModules --> MoexHttpClient
TBankModule --> CacheService
TBankModule --> PrismaService
@ -51,7 +51,7 @@ flowchart TB
|---|---|---|---|
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
| `MoexClientModule` | Да | `modules/moex-client/` | HTTP-клиент MOEX ISS |
| `MoexClientModule` | Нет | `modules/moex-client/` | HTTP- и domain-клиенты MOEX ISS |
| `HealthModule` | Нет | `modules/health/` | Health check endpoint |
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
@ -79,17 +79,17 @@ flowchart TB
### MoexClientModule
Глобальный HTTP-клиент для MOEX ISS.
Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты.
- Rate limiter: p-queue (10 req/s по умолчанию, настраивается через `MOEX_RATE_LIMIT`)
- Circuit breaker: открывается после 5 ошибок, сбрасывается через 30s
- Все ответы нормализуются из табличного формата MOEX в доменные типы
- `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing.
- Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`.
- Все ответы нормализуются из табличного формата MOEX в доменные типы.
### HealthModule
Проверка состояния сервиса.
- `GET /api/v1/health``{ status: 'ok', timestamp, uptime }`
- `GET /api/v1/health``{ data: { status, timestamp, uptime, checks }, meta }`
### AuthModule

View File

@ -2,9 +2,21 @@
## Обзор
`MoexClientService` (`apps/backend/src/modules/moex-client/moex-client.service.ts`) — HTTP-клиент для MOEX ISS API.
MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under
`apps/backend/src/modules/moex-client/`:
## Rate limiting
- `MoexHttpClient` — request queue, rate limiting, circuit breaker, ISS JSON parsing.
- `MoexSecuritiesClient` — security search and descriptions.
- `MoexMarketDataClient` — share/bond market data and batch position enrichment.
- `MoexCandlesClient` — candle history.
- `MoexHistoryClient` — share and bond history.
- `MoexDividendsClient` — dividend calendar.
## `MoexHttpClient`
Базовый HTTP-клиент, используемый всеми domain-клиентами. Реализован в `moex-http-client.service.ts`.
### Rate limiting
Использует `p-queue`:
@ -17,7 +29,7 @@ this.queue = new PQueue({
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
## Circuit breaker
### Circuit breaker
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
@ -30,7 +42,7 @@ private circuitErrorCount = 0;
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
## Метод request
### Метод request
```typescript
private async request<T>(path: string, params?: Record<string, string>): Promise<T>
@ -40,7 +52,7 @@ private async request<T>(path: string, params?: Record<string, string>): Promise
- Устанавливает `iss.meta=off` (отключает метаданные)
- Таймаут: 10s
## Разбор response
### Разбор response
MOEX возвращает данные в табличном формате:
@ -55,19 +67,17 @@ MOEX возвращает данные в табличном формате:
Метод `extractTable` преобразует это в массив объектов по колонкам.
## Доступные методы MOEX
## Domain clients
| Метод | MOEX path | Описание |
|---|---|---|
| `searchSecurities` | `/securities?q=` | Поиск инструментов |
| `getSecurityDescription` | `/securities/{secid}` | Спецификация |
| `getShareMarketData` | `/engines/stock/markets/shares/securities/{secid}` | Рыночные данные акции (board: TQBR) |
| `getBondData` | `/engines/stock/markets/bonds/securities/{secid}` | Данные облигации (board: TQCB) |
| `getBondMarketData` | `/engines/stock/markets/bonds/securities/{secid}` | Рыночные данные облигации |
| `getDividends` | `/securities/{secid}/dividends` | Дивиденды |
| `getCandles` | `/engines/{engine}/markets/{market}/securities/{secid}/candles` | Свечи |
| `getHistory` | `/engines/stock/markets/shares/securities/{secid}` | История акций |
| `getBondHistory` | `/engines/stock/markets/bonds/securities/{secid}` | История облигаций |
Каждый domain-клиент использует `MoexHttpClient` для HTTP и предоставляет свои методы:
| Client | Методы |
|---|---|
| `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` |
| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` |
| `MoexCandlesClient` | `getCandles` |
| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` |
| `MoexDividendsClient` | `getDividends` |
## MOEX ISS types

View File

@ -0,0 +1,13 @@
# Backend Architecture
Статус: активный
Цель: поддерживать backend в состоянии, где архитектурные границы, контракты, безопасность и
наблюдаемость позволяют развивать продукт без скрытого роста технического долга.
Features:
- [x] [backend-architecture-improvements](../features/backend-architecture-improvements/spec.md) —
закрытие первой волны backend-аудита: envelope DTO, screener TTL, domain exceptions, health checks,
middleware DI, MoexClient split.
- [ ] [backend-architecture-refactor](../features/backend-architecture-refactor/spec.md) —
refactor-only итерация без новых user-facing возможностей.

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,117 @@
# Backend Architecture Refactor
Дата: 2026-06-25
Статус: draft
## Контекст
После аудита backend-архитектуры от 2026-06-25 и последующей фичи
`backend-architecture-improvements` большая часть первого слоя долга закрыта: общий envelope DTO,
domain exceptions, dependency-aware health checks, DI-подключение middleware и разделение
`MoexClientService`.
Оставшийся долг неоднороден. Часть пунктов требует новых продуктовых или доменных решений
(`T-Bank` multi-tenancy, локальный read-path истории операций, device sessions, ledger-модель). Эти
изменения не должны попадать в локальный рефакторинг без отдельной спецификации, потому что меняют
поведение, модель данных или threat model.
Текущая фича фиксирует только refactor-only итерацию: привести существующий backend к более
правильным архитектурным границам без добавления новых пользовательских возможностей и без изменения
публичной формы API, кроме уточнения валидации некорректных входных данных.
## Цель
Снизить backend technical debt в существующем поведении за счёт точечных архитектурных правок:
безопаснее обрабатывать ошибки, формализовать production-конфигурацию, усилить DTO-валидацию,
сузить `any` на границе T-Bank gRPC, улучшить cache metadata и синхронизировать опубликованную
backend-документацию с текущим кодом.
## Требования
### 1. Error masking для необработанных исключений
Backend не должен возвращать клиенту внутренние сообщения необработанных `Error` в ответах `500`.
Подробности должны оставаться в backend-логах. Публичный ответ для unknown/internal errors должен быть
стабильным и безопасным.
### 2. Production configuration hardening
Backend должен явно отделять dev defaults от production-конфигурации:
- production-запуск не должен молча использовать дефолтные JWT access/refresh secrets;
- CORS с credentials не должен отражать произвольный origin в production;
- список допустимых origins должен задаваться конфигурацией окружения.
### 3. DTO validation hardening
Существующие DTO должны отсеивать заведомо некорректные значения до попадания в service-layer:
- даты покупки позиции должны валидироваться как ISO/date строки;
- количество позиции не должно допускать `0` там, где service-layer уже трактует это как ошибку;
- изменения должны сохранять существующий успешный пользовательский сценарий для валидных данных.
### 4. T-Bank gRPC typed boundary
Динамическая природа protobuf/gRPC клиента должна быть локализована в одном typed boundary, чтобы
доменные broker-сервисы не приводили service clients к `any` напрямую. Цель — улучшить compile-time
границы без переписывания vendored proto contract и без изменения внешнего T-Bank API behavior.
### 5. Cache metadata consistency
Cache helper должен сохранять полезность `meta.cachedAt`: cache hit не должен выглядеть как состояние
без времени кеширования, если timestamp уже можно сохранить вместе с cached payload.
### 6. Backend documentation sync
Опубликованные docs в `apps/docs/docs/backend/` должны отражать текущее состояние backend после
закрытых refactor-работ:
- split MOEX clients вместо устаревшего `MoexClientService` как единого God Service;
- актуальный envelope contract;
- актуальные health response и broker operations sync endpoint.
## Ограничения
- Не добавлять новые user-facing возможности.
- Не менять публичный API shape для успешных ответов.
- Не вводить multi-tenancy или пользовательские T-Bank connections в рамках этой фичи.
- Не переводить операции T-Bank на локальный read-path в рамках этой фичи.
- Не менять модель сессий на device/session table в рамках этой фичи.
- Не менять финансовую модель хранения (`Float`, `Int`, JSON/string fields) и не создавать ledger ADR в
рамках этой фичи.
- Не выполнять механическое дробление больших сервисов без проверяемой архитектурной цели.
- Не редактировать Prisma migrations вручную.
## Acceptance Criteria
- Необработанные backend exceptions логируются, но `500` response не раскрывает внутренний
`Error.message`.
- Production-конфигурация не стартует с дефолтными JWT secrets и не использует wildcard/reflected CORS
для credentialed requests.
- DTO портфельных позиций валидируют даты и количество на boundary-уровне; добавлены regression tests.
- Production-код broker-сервисов не содержит прямых `as any` для получения T-Bank gRPC service clients;
небезопасное приведение, если оно необходимо, локализовано и покрыто типом/facade.
- Cache metadata на hit/miss согласована тестами и не ломает `ApiEnvelopePayload`/`ApiResponse` contract.
- Backend docs обновлены и не ссылаются на удалённый `MoexClientService` как primary abstraction,
устаревший `/operations/refresh` endpoint или raw health response без envelope.
- Затронутые backend tests проходят.
- Backend build проходит.
- OpenAPI/types обновляются только если реально меняется Swagger contract; `apps/frontend/src/api/types.ts`
не редактируется вручную.
## Out Of Scope
- T-Bank data isolation and multi-tenancy.
- Local T-Bank operations read-path.
- Device-level sessions, refresh-token rotation и reuse detection.
- Ledger/financial data model migration.
- Rate limiting, CSRF и security headers, если они требуют отдельной threat model или middleware policy.
- Декомпозиция `PortfolioService` или `tbank/` на новые модули без отдельного плана.
## Источники
- `docs/research/2026-06-25-backend-audit.md`
- `docs/features/backend-architecture-improvements/spec.md`
- `docs/features/moex-client-split/spec.md`
- `docs/features/api-envelope-contract/spec.md`
- `docs/inbox.md` — раздел «Технический долг — кандидат на следующую итерацию»

View File

@ -0,0 +1,23 @@
# Backend Architecture Refactor — Tasks
Статус: done
- [x] Task 0: Baseline verification before runtime changes.
- [x] Task 1: Mask unhandled `500` errors without changing `HttpException` responses.
- [x] Task 2: Harden production runtime config for JWT secrets and credentialed CORS.
- [x] Task 3: Harden portfolio position DTO validation for quantity and buyDate.
- [x] Task 4: Localize T-Bank gRPC `any` casts behind typed facade methods.
- [x] Task 5: Preserve cache `cachedAt` metadata on cache hits.
- [x] Task 6: Synchronize published backend docs with current MOEX/envelope/health/T-Bank contracts.
- [x] Task 7: Run final lint/test/build/docs quality gate and inspect final diff.
## Verification Log
- Baseline backend tests: 30 files, 141 tests — PASS.
- Baseline backend build: PASS.
- Final backend lint: PASS.
- Final backend tests: 34 files, 158 tests — PASS.
- Final backend build: PASS.
- Final docs build: PASS.
- No `getServiceClient('X') as any` in production broker services: 0 matches.
- No `MoexClientService` or `operations/refresh` in docs: 0 matches.

View File

@ -367,6 +367,12 @@ cash flow, бюджеты, аналитика, прогнозы и автома
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
>
> **Обновление 2026-06-25:** Для следующей итерации создана refactor-only фича
> `backend-architecture-refactor`. В неё входят только правки текущего поведения: error masking,
> production config hardening, DTO validation, typed T-Bank gRPC boundary, cache metadata и backend docs
> sync. Multi-tenancy, local T-Bank read-path, device sessions и ledger-модель остаются отдельными
> follow-up фичами, потому что меняют доменную модель или поведение.
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
frontend build, 94 backend-теста и 168 frontend-тестов.

View File

@ -24,6 +24,17 @@ Roadmap отражает порядок продуктовой работы, н
## Активные эпики
### [Backend Architecture](epics/BackendArchitecture.md)
Цель: удерживать backend-архитектуру, security boundaries и published contracts в состоянии,
пригодном для безопасного развития продукта.
- [x] [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md) — envelope
DTO, screener TTL, domain exceptions, health checks, middleware DI, MoexClient split.
- [ ] [Backend Architecture Refactor](features/backend-architecture-refactor/spec.md) — refactor-only
итерация: error masking, production config hardening, DTO validation, typed T-Bank boundary, cache
metadata, backend docs sync.
### [Портфель брокера](epics/BrokerPortfolio.md)
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
@ -90,7 +101,7 @@ Roadmap отражает порядок продуктовой работы, н
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
нормализация conventions
### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md)
### Backend Architecture Improvements
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)

368
package-lock.json generated
View File

@ -13150,6 +13150,20 @@
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
"license": "Apache-2.0"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
@ -15275,6 +15289,20 @@
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
@ -15324,6 +15352,16 @@
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/copy-text-to-clipboard": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
@ -18563,6 +18601,182 @@
"node": ">=12.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"peer": true,
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/body-parser": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
"integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^2.0.0",
"debug": "^4.4.3",
"http-errors": "^2.0.1",
"iconv-lite": "^0.7.2",
"on-finished": "^2.4.1",
"qs": "^6.15.2",
"raw-body": "^3.0.2",
"type-is": "^2.1.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/body-parser/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/qs": {
"version": "6.15.3",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
"license": "BSD-3-Clause",
"peer": true,
"dependencies": {
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/express/node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"peer": true,
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/express/node_modules/type-is": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
"integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
"license": "MIT",
"peer": true,
"dependencies": {
"content-type": "^2.0.0",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/express/node_modules/type-is/node_modules/content-type": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
"integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@ -18866,6 +19080,28 @@
"node": ">=8"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/find-cache-dir": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
@ -19117,6 +19353,16 @@
"url": "https://github.com/sponsors/rawify"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/fs-extra": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
@ -21162,6 +21408,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT",
"peer": true
},
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
@ -23436,6 +23689,19 @@
"node": ">= 4.0.0"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@ -25332,6 +25598,23 @@
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"license": "MIT",
"peer": true,
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mimic-fn": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
@ -25715,6 +25998,16 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
@ -30332,6 +30625,34 @@
"points-on-path": "^0.2.1"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/router/node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"peer": true,
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/rtlcss": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
@ -30645,6 +30966,33 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/seq-queue": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
@ -30880,6 +31228,26 @@
"node": ">= 0.6"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",