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;
|
error = (r.error as string) || exception.name;
|
||||||
}
|
}
|
||||||
} else if (exception instanceof Error) {
|
} else if (exception instanceof Error) {
|
||||||
message = exception.message;
|
|
||||||
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack);
|
||||||
|
} else {
|
||||||
|
this.logger.error(`Unhandled non-error exception: ${String(exception)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.status(status).json({
|
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';
|
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', () => ({
|
export default registerAs('app', () => ({
|
||||||
port: parseInt(process.env.PORT || '3000', 10),
|
port: parseInt(process.env.PORT || '3000', 10),
|
||||||
database: {
|
database: {
|
||||||
@ -38,6 +47,9 @@ export default registerAs('app', () => ({
|
|||||||
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
tbankInstrumentTtl: parseInt(process.env.CACHE_TBANK_INSTRUMENT_TTL || '86400', 10),
|
||||||
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
tbankAnalyticsTtl: parseInt(process.env.CACHE_TBANK_ANALYTICS_TTL || '300', 10),
|
||||||
},
|
},
|
||||||
|
cors: {
|
||||||
|
origins: parseCsv(process.env.BACKEND_CORS_ORIGINS),
|
||||||
|
},
|
||||||
auth: {
|
auth: {
|
||||||
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
jwtSecret: process.env.JWT_SECRET || 'dev-jwt-secret-change-in-production',
|
||||||
jwtRefreshSecret: process.env.JWT_REFRESH_SECRET || 'dev-refresh-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 { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import cookieParser from 'cookie-parser';
|
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() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -17,7 +46,20 @@ async function bootstrap() {
|
|||||||
app.useGlobalInterceptors(new TransformInterceptor());
|
app.useGlobalInterceptors(new TransformInterceptor());
|
||||||
app.use(cookieParser());
|
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()
|
const config = new DocumentBuilder()
|
||||||
.setTitle('MoexVibe API')
|
.setTitle('MoexVibe API')
|
||||||
@ -32,4 +74,7 @@ async function bootstrap() {
|
|||||||
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
console.log(`MoexVibe API running on http://localhost:${port}/api/v1`);
|
||||||
console.log(`Swagger docs: http://localhost:${port}/api/docs`);
|
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 { Cache } from 'cache-manager';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
type CacheEntry<T> = {
|
||||||
|
data: T;
|
||||||
|
cachedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CacheService {
|
export class CacheService {
|
||||||
constructor(
|
constructor(
|
||||||
@ -18,6 +23,16 @@ export class CacheService {
|
|||||||
await this.cacheManager.set(key, value, ttl);
|
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 {
|
private buildKey(...parts: string[]): string {
|
||||||
return parts.join(':');
|
return parts.join(':');
|
||||||
}
|
}
|
||||||
@ -31,14 +46,19 @@ export class CacheService {
|
|||||||
const key = this.buildKey(keyPrefix, ...keyParts);
|
const key = this.buildKey(keyPrefix, ...keyParts);
|
||||||
const ttl = this.configService.get<number>(`app.cache.${ttlConfigKey}`, 900);
|
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) {
|
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();
|
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,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
IsDateString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -31,7 +32,7 @@ export class AddPositionDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 10 })
|
@ApiProperty({ example: 10 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
quantity!: number;
|
quantity!: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: 250.5 })
|
@ApiPropertyOptional({ example: 250.5 })
|
||||||
@ -41,7 +42,7 @@ export class AddPositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-01' })
|
@ApiPropertyOptional({ example: '2026-06-01' })
|
||||||
@IsString()
|
@IsDateString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
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,
|
IsArray,
|
||||||
IsIn,
|
IsIn,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
IsDateString,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
@ -24,7 +25,7 @@ const TAGS = [
|
|||||||
export class UpdatePositionDto {
|
export class UpdatePositionDto {
|
||||||
@ApiPropertyOptional({ example: 15 })
|
@ApiPropertyOptional({ example: 15 })
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@Min(0)
|
@Min(1)
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
quantity?: number;
|
quantity?: number;
|
||||||
|
|
||||||
@ -35,7 +36,7 @@ export class UpdatePositionDto {
|
|||||||
buyPrice?: number;
|
buyPrice?: number;
|
||||||
|
|
||||||
@ApiPropertyOptional({ example: '2026-06-15' })
|
@ApiPropertyOptional({ example: '2026-06-15' })
|
||||||
@IsString()
|
@IsDateString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
buyDate?: string;
|
buyDate?: string;
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { CacheService } from '../../cache/cache.service';
|
|||||||
|
|
||||||
describe('BrokerAccountsService', () => {
|
describe('BrokerAccountsService', () => {
|
||||||
const client = {
|
const client = {
|
||||||
getServiceClient: vi.fn(),
|
getUsersClient: vi.fn(),
|
||||||
callUnary: vi.fn(),
|
callUnary: vi.fn(),
|
||||||
} as unknown as TBankClientService;
|
} as unknown as TBankClientService;
|
||||||
const cache = {
|
const cache = {
|
||||||
@ -23,7 +23,7 @@ describe('BrokerAccountsService', () => {
|
|||||||
cachedAt: '2026-06-16T02:30:00.000Z',
|
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({
|
vi.mocked(client.callUnary).mockResolvedValue({
|
||||||
accounts: [
|
accounts: [
|
||||||
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
|
{ id: '1', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Broker', status: 'ACCOUNT_STATUS_OPEN' },
|
||||||
|
|||||||
@ -32,9 +32,9 @@ export class BrokerAccountsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
private async fetchAccounts(): Promise<BrokerAccount[]> {
|
||||||
const usersClient = this.tbankClient.getServiceClient('UsersService') as any;
|
const usersClient = this.tbankClient.getUsersClient();
|
||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
Record<string, string>,
|
{ status: string },
|
||||||
TBankAccountsResponse
|
TBankAccountsResponse
|
||||||
>(
|
>(
|
||||||
'UsersService/GetAccounts',
|
'UsersService/GetAccounts',
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class BrokerInstrumentsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchByUid(instrumentUid: string): Promise<TBankInstrument | null> {
|
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<
|
const response = await this.tbankClient.callUnary<
|
||||||
{ idType: string; id: string },
|
{ idType: string; id: string },
|
||||||
TBankInstrumentResponse
|
TBankInstrumentResponse
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { TBankClientService } from './tbank-client.service';
|
|||||||
|
|
||||||
describe('BrokerOperationsService', () => {
|
describe('BrokerOperationsService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
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;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -36,7 +36,7 @@ describe('BrokerOperationsService', () => {
|
|||||||
cachedAt: null,
|
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({
|
vi.mocked(client.callUnary).mockResolvedValue({
|
||||||
hasNext: false,
|
hasNext: false,
|
||||||
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
|
items: [{ cursor: 'c1', brokerAccountId: 'acc-1', type: 'OPERATION_TYPE_BUY' }],
|
||||||
|
|||||||
@ -69,7 +69,7 @@ export class BrokerOperationsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
request: Record<string, unknown>,
|
request: Record<string, unknown>,
|
||||||
): Promise<BrokerOperationsPage> {
|
): Promise<BrokerOperationsPage> {
|
||||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
const operationsClient = this.tbankClient.getOperationsClient();
|
||||||
const response = await this.tbankClient.callUnary<
|
const response = await this.tbankClient.callUnary<
|
||||||
Record<string, unknown>,
|
Record<string, unknown>,
|
||||||
TBankOperationsByCursorResponse
|
TBankOperationsByCursorResponse
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import { TBankClientService } from './tbank-client.service';
|
|||||||
describe('BrokerPortfolioService', () => {
|
describe('BrokerPortfolioService', () => {
|
||||||
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
const accounts = { findById: vi.fn() } as unknown as BrokerAccountsService;
|
||||||
const instruments = { findByInstrumentUid: vi.fn() } as unknown as BrokerInstrumentsService;
|
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;
|
const cache = { getOrFetch: vi.fn() } as unknown as CacheService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@ -38,7 +38,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
cachedAt: null,
|
cachedAt: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
getPositions: vi.fn(),
|
getPositions: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
@ -104,7 +104,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns first page of positions', async () => {
|
it('returns first page of positions', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -139,7 +139,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('paginates using cursor', async () => {
|
it('paginates using cursor', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -179,7 +179,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns last page with hasNext=false', async () => {
|
it('returns last page with hasNext=false', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -206,7 +206,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
it('caches positions with cursor/limit/type in key and tbankPositionsTtl', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -229,7 +229,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('filters by instrument type and caches with type in key', async () => {
|
it('filters by instrument type and caches with type in key', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
@ -279,7 +279,7 @@ describe('BrokerPortfolioService', () => {
|
|||||||
it('returns empty items when type filter matches nothing', async () => {
|
it('returns empty items when type filter matches nothing', async () => {
|
||||||
mockAccount();
|
mockAccount();
|
||||||
mockCache();
|
mockCache();
|
||||||
vi.mocked(client.getServiceClient).mockReturnValue({
|
vi.mocked(client.getOperationsClient).mockReturnValue({
|
||||||
getPortfolio: vi.fn(),
|
getPortfolio: vi.fn(),
|
||||||
} as any);
|
} as any);
|
||||||
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
vi.mocked(client.callUnary).mockResolvedValueOnce({
|
||||||
|
|||||||
@ -120,7 +120,7 @@ export class BrokerPortfolioService {
|
|||||||
'tbank:raw-portfolio',
|
'tbank:raw-portfolio',
|
||||||
[accountId],
|
[accountId],
|
||||||
async () => {
|
async () => {
|
||||||
const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any;
|
const operationsClient = this.tbankClient.getOperationsClient();
|
||||||
return this.tbankClient.callUnary<
|
return this.tbankClient.callUnary<
|
||||||
{ accountId: string; currency: string },
|
{ accountId: string; currency: string },
|
||||||
TBankPortfolioResponse
|
TBankPortfolioResponse
|
||||||
@ -139,7 +139,7 @@ export class BrokerPortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async fetchPositions(accountId: string): Promise<TBankPositionsResponse> {
|
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>(
|
return this.tbankClient.callUnary<{ accountId: string }, TBankPositionsResponse>(
|
||||||
'OperationsService/GetPositions',
|
'OperationsService/GetPositions',
|
||||||
operationsClient.getPositions.bind(operationsClient),
|
operationsClient.getPositions.bind(operationsClient),
|
||||||
|
|||||||
@ -44,6 +44,16 @@ describe('TBankClientService', () => {
|
|||||||
expect(() => service.getServiceClient('UsersService')).not.toThrow();
|
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', () => {
|
it('creates grpc SSL credentials with configured custom CA certificate', () => {
|
||||||
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
const caPath = join(mkdtempSync(join(tmpdir(), 'tbank-ca-')), 'root.pem');
|
||||||
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
writeFileSync(caPath, '-----BEGIN CERTIFICATE-----\ntest-ca\n-----END CERTIFICATE-----\n');
|
||||||
|
|||||||
@ -1,6 +1,13 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
|
import { TBankNotConfiguredException, TBankApiException } from '../../../common/exceptions/tbank-api.exception';
|
||||||
|
import type {
|
||||||
|
TBankAccountsResponse,
|
||||||
|
TBankInstrumentResponse,
|
||||||
|
TBankOperationsByCursorResponse,
|
||||||
|
TBankPortfolioResponse,
|
||||||
|
TBankPositionsResponse,
|
||||||
|
} from '../types/tbank-proto.types';
|
||||||
import {
|
import {
|
||||||
CallOptions,
|
CallOptions,
|
||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
@ -26,6 +33,25 @@ type GrpcUnary<TRequest, TResponse> = (
|
|||||||
|
|
||||||
type GrpcServiceConstructor = new (address: string, credentials: ChannelCredentials) => Client;
|
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';
|
type QueueName = 'operations' | 'instruments' | 'users';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -120,6 +146,18 @@ export class TBankClientService {
|
|||||||
return client;
|
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>(
|
async callUnary<TRequest, TResponse>(
|
||||||
label: string,
|
label: string,
|
||||||
method: GrpcUnary<TRequest, TResponse>,
|
method: GrpcUnary<TRequest, TResponse>,
|
||||||
|
|||||||
@ -92,9 +92,17 @@
|
|||||||
**Response:**
|
**Response:**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"data": {
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"timestamp": "2026-06-13T12:00:00.000Z",
|
"timestamp": "2026-06-25T12:00:00.000Z",
|
||||||
"uptime": 1234.56
|
"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/portfolio` | GET | Портфель счёта: позиции, cash, метаданные |
|
||||||
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
|
| `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам |
|
||||||
| `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) |
|
| `/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 | Позиции счёта (с пагинацией) |
|
| `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) |
|
||||||
|
|||||||
@ -25,20 +25,20 @@ flowchart TB
|
|||||||
|
|
||||||
PrismaService["PrismaService"]
|
PrismaService["PrismaService"]
|
||||||
CacheService["CacheService"]
|
CacheService["CacheService"]
|
||||||
MoexClientService["MoexClientService"]
|
MoexHttpClient["MoexHttpClient"]
|
||||||
TBankClientService["TBankClientService"]
|
TBankClientService["TBankClientService"]
|
||||||
|
|
||||||
PrismaModule --> PrismaService
|
PrismaModule --> PrismaService
|
||||||
CacheModule --> CacheService
|
CacheModule --> CacheService
|
||||||
MoexClientModule --> MoexClientService
|
MoexClientModule --> MoexHttpClient
|
||||||
|
|
||||||
AuthModule --> PrismaService
|
AuthModule --> PrismaService
|
||||||
PortfolioModule --> PrismaService
|
PortfolioModule --> PrismaService
|
||||||
PortfolioModule --> CacheService
|
PortfolioModule --> CacheService
|
||||||
PortfolioModule --> MoexClientService
|
PortfolioModule --> MoexHttpClient
|
||||||
|
|
||||||
MarketModules --> CacheService
|
MarketModules --> CacheService
|
||||||
MarketModules --> MoexClientService
|
MarketModules --> MoexHttpClient
|
||||||
|
|
||||||
TBankModule --> CacheService
|
TBankModule --> CacheService
|
||||||
TBankModule --> PrismaService
|
TBankModule --> PrismaService
|
||||||
@ -51,7 +51,7 @@ flowchart TB
|
|||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
|
| `PrismaModule` | Да | `modules/prisma/` | Prisma client для SQLite |
|
||||||
| `CacheModule` | Да | `modules/cache/` | In-memory cache через cache-manager |
|
| `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 |
|
| `HealthModule` | Нет | `modules/health/` | Health check endpoint |
|
||||||
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
|
| `AuthModule` | Нет | `modules/auth/` | JWT auth, refresh cookie, guards |
|
||||||
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
|
| `SecuritiesModule` | Нет | `modules/securities/` | Поиск инструментов |
|
||||||
@ -79,17 +79,17 @@ flowchart TB
|
|||||||
|
|
||||||
### MoexClientModule
|
### MoexClientModule
|
||||||
|
|
||||||
Глобальный HTTP-клиент для MOEX ISS.
|
Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты.
|
||||||
|
|
||||||
- Rate limiter: p-queue (10 req/s по умолчанию, настраивается через `MOEX_RATE_LIMIT`)
|
- `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing.
|
||||||
- Circuit breaker: открывается после 5 ошибок, сбрасывается через 30s
|
- Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`.
|
||||||
- Все ответы нормализуются из табличного формата MOEX в доменные типы
|
- Все ответы нормализуются из табличного формата MOEX в доменные типы.
|
||||||
|
|
||||||
### HealthModule
|
### HealthModule
|
||||||
|
|
||||||
Проверка состояния сервиса.
|
Проверка состояния сервиса.
|
||||||
|
|
||||||
- `GET /api/v1/health` → `{ status: 'ok', timestamp, uptime }`
|
- `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }`
|
||||||
|
|
||||||
### AuthModule
|
### 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`:
|
Использует `p-queue`:
|
||||||
|
|
||||||
@ -17,7 +29,7 @@ this.queue = new PQueue({
|
|||||||
|
|
||||||
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
|
Все запросы к MOEX проходят через очередь — не более `MOEX_RATE_LIMIT` запросов в секунду.
|
||||||
|
|
||||||
## Circuit breaker
|
### Circuit breaker
|
||||||
|
|
||||||
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
|
Состояние: закрыт → открыт → полуоткрыт (через таймаут).
|
||||||
|
|
||||||
@ -30,7 +42,7 @@ private circuitErrorCount = 0;
|
|||||||
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
|
- В открытом состоянии все запросы мгновенно падают с ошибкой `"Circuit breaker is open"`
|
||||||
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
|
- Через `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` (30) автоматически сбрасывается
|
||||||
|
|
||||||
## Метод request
|
### Метод request
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
private async request<T>(path: string, params?: Record<string, string>): Promise<T>
|
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` (отключает метаданные)
|
- Устанавливает `iss.meta=off` (отключает метаданные)
|
||||||
- Таймаут: 10s
|
- Таймаут: 10s
|
||||||
|
|
||||||
## Разбор response
|
### Разбор response
|
||||||
|
|
||||||
MOEX возвращает данные в табличном формате:
|
MOEX возвращает данные в табличном формате:
|
||||||
|
|
||||||
@ -55,19 +67,17 @@ MOEX возвращает данные в табличном формате:
|
|||||||
|
|
||||||
Метод `extractTable` преобразует это в массив объектов по колонкам.
|
Метод `extractTable` преобразует это в массив объектов по колонкам.
|
||||||
|
|
||||||
## Доступные методы MOEX
|
## Domain clients
|
||||||
|
|
||||||
| Метод | MOEX path | Описание |
|
Каждый domain-клиент использует `MoexHttpClient` для HTTP и предоставляет свои методы:
|
||||||
|---|---|---|
|
|
||||||
| `searchSecurities` | `/securities?q=` | Поиск инструментов |
|
| Client | Методы |
|
||||||
| `getSecurityDescription` | `/securities/{secid}` | Спецификация |
|
|---|---|
|
||||||
| `getShareMarketData` | `/engines/stock/markets/shares/securities/{secid}` | Рыночные данные акции (board: TQBR) |
|
| `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` |
|
||||||
| `getBondData` | `/engines/stock/markets/bonds/securities/{secid}` | Данные облигации (board: TQCB) |
|
| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` |
|
||||||
| `getBondMarketData` | `/engines/stock/markets/bonds/securities/{secid}` | Рыночные данные облигации |
|
| `MoexCandlesClient` | `getCandles` |
|
||||||
| `getDividends` | `/securities/{secid}/dividends` | Дивиденды |
|
| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` |
|
||||||
| `getCandles` | `/engines/{engine}/markets/{market}/securities/{secid}/candles` | Свечи |
|
| `MoexDividendsClient` | `getDividends` |
|
||||||
| `getHistory` | `/engines/stock/markets/shares/securities/{secid}` | История акций |
|
|
||||||
| `getBondHistory` | `/engines/stock/markets/bonds/securities/{secid}` | История облигаций |
|
|
||||||
|
|
||||||
## MOEX ISS types
|
## 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 с детальным статусом
|
> - Health check прокачка — проверки Prisma, MOEX, T-Bank с детальным статусом
|
||||||
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
|
> - RequestLoggingMiddleware — перевод на `configure()` в AppModule
|
||||||
> - MoexClientService split — 6 клиентов вместо God Service, убран `@Global()`
|
> - 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,
|
Текущее состояние quality gates хорошее: на момент аудита проходят lint, format-check, backend build,
|
||||||
frontend build, 94 backend-теста и 168 frontend-тестов.
|
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)
|
### [Портфель брокера](epics/BrokerPortfolio.md)
|
||||||
|
|
||||||
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
|
Цель: дать пользователю целостный доступ к реальным брокерским счетам T-Bank.
|
||||||
@ -90,7 +101,7 @@ Roadmap отражает порядок продуктовой работы, н
|
|||||||
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
- [x] [frontend-test-hygiene](features/frontend-test-hygiene/spec.md) — минимизация test helpers,
|
||||||
нормализация conventions
|
нормализация conventions
|
||||||
|
|
||||||
### [Backend Architecture Improvements](features/backend-architecture-improvements/spec.md)
|
### Backend Architecture Improvements
|
||||||
|
|
||||||
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
- [x] Shared envelope DTO — единый `ApiResponseMeta` вместо 6 дублирующихся классов
|
||||||
- [x] Screener TTL — отдельный кеш-параметр `CACHE_SCREENER_TTL` (900s)
|
- [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==",
|
"integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==",
|
||||||
"license": "Apache-2.0"
|
"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": {
|
"node_modules/acorn": {
|
||||||
"version": "8.17.0",
|
"version": "8.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||||
@ -15275,6 +15289,20 @@
|
|||||||
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
|
"integrity": "sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/content-type": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||||
@ -15324,6 +15352,16 @@
|
|||||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/copy-text-to-clipboard": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.2.tgz",
|
"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": ">=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": {
|
"node_modules/exsolve": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
|
||||||
@ -18866,6 +19080,28 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/find-cache-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/fs-extra": {
|
||||||
"version": "10.1.0",
|
"version": "10.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||||
@ -21162,6 +21408,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/is-property": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
|
||||||
@ -23436,6 +23689,19 @@
|
|||||||
"node": ">= 4.0.0"
|
"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": {
|
"node_modules/merge-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
|
||||||
@ -25332,6 +25598,23 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||||
@ -25715,6 +25998,16 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/neo-async": {
|
||||||
"version": "2.6.2",
|
"version": "2.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
|
||||||
@ -30332,6 +30625,34 @@
|
|||||||
"points-on-path": "^0.2.1"
|
"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": {
|
"node_modules/rtlcss": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz",
|
||||||
@ -30645,6 +30966,33 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/seq-queue": {
|
||||||
"version": "0.0.5",
|
"version": "0.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz",
|
||||||
@ -30880,6 +31228,26 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/set-cookie-parser": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user