codex/backend-architecture-refactor #48
@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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({
|
||||
|
||||
76
apps/backend/src/config/backend-runtime-config.spec.ts
Normal file
76
apps/backend/src/config/backend-runtime-config.spec.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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',
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal file
65
apps/backend/src/modules/cache/cache.service.spec.ts
vendored
Normal 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 });
|
||||
});
|
||||
});
|
||||
28
apps/backend/src/modules/cache/cache.service.ts
vendored
28
apps/backend/src/modules/cache/cache.service.ts
vendored
@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
43
apps/backend/src/modules/portfolio/dto/position.dto.spec.ts
Normal file
43
apps/backend/src/modules/portfolio/dto/position.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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' },
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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' }],
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -92,9 +92,17 @@
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"status": "ok",
|
||||
"timestamp": "2026-06-13T12:00:00.000Z",
|
||||
"uptime": 1234.56
|
||||
"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 | Позиции счёта (с пагинацией) |
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
13
docs/epics/BackendArchitecture.md
Normal file
13
docs/epics/BackendArchitecture.md
Normal 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 возможностей.
|
||||
1123
docs/features/backend-architecture-refactor/plan.md
Normal file
1123
docs/features/backend-architecture-refactor/plan.md
Normal file
File diff suppressed because it is too large
Load Diff
117
docs/features/backend-architecture-refactor/spec.md
Normal file
117
docs/features/backend-architecture-refactor/spec.md
Normal 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` — раздел «Технический долг — кандидат на следующую итерацию»
|
||||
23
docs/features/backend-architecture-refactor/tasks.md
Normal file
23
docs/features/backend-architecture-refactor/tasks.md
Normal 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.
|
||||
@ -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-тестов.
|
||||
|
||||
@ -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
368
package-lock.json
generated
@ -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",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user