From 120db3c2abfafd8fb86dec1f6800d61e0fcfce5c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:41:57 +0300 Subject: [PATCH 1/9] docs: add backend-architecture-refactor SDD artifacts and update epic/roadmap/inbox --- docs/epics/BackendArchitecture.md | 13 + .../backend-architecture-refactor/plan.md | 1123 +++++++++++++++++ .../backend-architecture-refactor/spec.md | 117 ++ .../backend-architecture-refactor/tasks.md | 21 + docs/inbox.md | 6 + docs/roadmap.md | 13 +- 6 files changed, 1292 insertions(+), 1 deletion(-) create mode 100644 docs/epics/BackendArchitecture.md create mode 100644 docs/features/backend-architecture-refactor/plan.md create mode 100644 docs/features/backend-architecture-refactor/spec.md create mode 100644 docs/features/backend-architecture-refactor/tasks.md diff --git a/docs/epics/BackendArchitecture.md b/docs/epics/BackendArchitecture.md new file mode 100644 index 0000000..7739ead --- /dev/null +++ b/docs/epics/BackendArchitecture.md @@ -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 возможностей. diff --git a/docs/features/backend-architecture-refactor/plan.md b/docs/features/backend-architecture-refactor/plan.md new file mode 100644 index 0000000..4f4d716 --- /dev/null +++ b/docs/features/backend-architecture-refactor/plan.md @@ -0,0 +1,1123 @@ +# Backend Architecture Refactor Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve backend architecture and safety without adding user-facing features or changing successful API response shapes. + +**Architecture:** Keep the refactor vertical and minimal: each task hardens one boundary, adds regression tests first, then makes the smallest production change. Configuration/security logic stays near bootstrap/config, DTO validation stays at request boundaries, T-Bank dynamic gRPC casts are centralized in `TBankClientService`, and docs are synchronized after runtime contracts are stable. + +**Tech Stack:** NestJS 10, TypeScript, Vitest, class-validator/class-transformer, Prisma, `@grpc/grpc-js`, cache-manager, Docusaurus docs. + +--- + +## Scope + +This plan implements `docs/features/backend-architecture-refactor/spec.md` only. + +In scope: + +- Error masking for unhandled `500` responses. +- Production JWT secret validation and credentialed CORS allowlist. +- Portfolio position DTO validation for quantity/date inputs. +- Typed T-Bank gRPC service-client boundary. +- Cache metadata consistency on cache hits. +- Published backend docs sync. + +Out of scope: + +- T-Bank multi-tenancy and user-owned broker connections. +- Local T-Bank operations read-path. +- Device sessions, refresh-token rotation, reuse detection. +- Ledger/financial storage migration. +- Rate limiting, CSRF, security headers. +- Large service/module decomposition. + +--- + +## File Structure + +### Runtime Files + +- `apps/backend/src/common/filters/http-exception.filter.ts` + - Owns public error response formatting and internal logging for unhandled exceptions. +- `apps/backend/src/config/configuration.ts` + - Owns typed application config values, including auth secrets and CORS origins. +- `apps/backend/src/main.ts` + - Owns Nest bootstrap wiring, including CORS options and production config assertions. +- `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` + - Request-boundary validation for position creation. +- `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` + - Request-boundary validation for position updates. +- `apps/backend/src/modules/tbank/services/tbank-client.service.ts` + - Single unsafe boundary for dynamic proto clients and typed service facade methods. +- `apps/backend/src/modules/tbank/services/broker-accounts.service.ts` + - Consumer of typed `UsersService` facade. +- `apps/backend/src/modules/tbank/services/broker-operations.service.ts` + - Consumer of typed `OperationsService` facade. +- `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` + - Consumer of typed `OperationsService` facade. +- `apps/backend/src/modules/tbank/services/broker-instruments.service.ts` + - Consumer of typed `InstrumentsService` facade. +- `apps/backend/src/modules/cache/cache.service.ts` + - Owns cache helper payload format and `cachedAt` metadata. + +### Test Files + +- Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`. +- Create `apps/backend/src/config/backend-runtime-config.spec.ts`. +- Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`. +- Modify `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`. +- Create `apps/backend/src/modules/cache/cache.service.spec.ts`. +- Modify affected broker service specs only if the typed facade requires mock updates. + +### Documentation Files + +- Modify `apps/docs/docs/backend/moex-client.md`. +- Modify `apps/docs/docs/backend/modules.md`. +- Modify `apps/docs/docs/backend/api.md`. +- Modify `apps/docs/docs/backend/tbank-invest.md` only if endpoint naming or sync wording needs final alignment. +- Modify `docs/features/backend-architecture-refactor/tasks.md` as each task is completed. + +--- + +## Verification Commands + +Use these commands from the repository root unless a task says otherwise: + +- Backend tests: `npm run test -w apps/backend` +- Backend build: `npm run build -w apps/backend` +- Backend lint: `npm run lint -w apps/backend` +- Docs build after docs sync: `npm run build -w apps/docs` + +--- + +### Task 0: Baseline Verification + +**Files:** none + +- [ ] **Step 1: Confirm branch and working tree** + +Run: + +```bash +git branch --show-current +rtk git status --short +``` + +Expected: + +```text +codex/backend-architecture-refactor +``` + +`rtk git status --short` may show the SDD docs created before implementation. It must not show unrelated runtime changes. + +- [ ] **Step 2: Run backend tests before implementation** + +Run: + +```bash +npm run test -w apps/backend +``` + +Expected: PASS. If this fails before code changes, stop and report the baseline failure. + +- [ ] **Step 3: Run backend build before implementation** + +Run: + +```bash +npm run build -w apps/backend +``` + +Expected: PASS. If this fails before code changes, stop and report the baseline failure. + +--- + +### Task 1: Mask Unhandled 500 Errors + +**Files:** + +- Create: `apps/backend/src/common/filters/http-exception.filter.spec.ts` +- Modify: `apps/backend/src/common/filters/http-exception.filter.ts` + +- [ ] **Step 1: Write failing tests for public error masking** + +Create `apps/backend/src/common/filters/http-exception.filter.spec.ts`: + +```ts +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('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', + }), + ); + }); +}); +``` + +- [ ] **Step 2: Run the new test and verify it fails** + +Run: + +```bash +npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts +``` + +Expected: FAIL because the current filter returns `exception.message` for non-`HttpException` errors. + +- [ ] **Step 3: Implement minimal masking change** + +In `apps/backend/src/common/filters/http-exception.filter.ts`, keep the initial safe defaults and remove the public assignment of `exception.message` in the non-HTTP branch: + +```ts + } else if (exception instanceof Error) { + this.logger.error(`Unhandled exception: ${exception.message}`, exception.stack); + } else { + this.logger.error(`Unhandled non-error exception: ${String(exception)}`); + } +``` + +Do not change the `HttpException` branch. + +- [ ] **Step 4: Verify the filter test passes** + +Run: + +```bash +npm run test -w apps/backend -- src/common/filters/http-exception.filter.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 1 complete after verification passes. + +--- + +### Task 2: Harden Production Runtime Config + +**Files:** + +- Create: `apps/backend/src/config/backend-runtime-config.spec.ts` +- Modify: `apps/backend/src/config/configuration.ts` +- Modify: `apps/backend/src/main.ts` + +- [ ] **Step 1: Write failing config tests** + +Create `apps/backend/src/config/backend-runtime-config.spec.ts`: + +```ts +describe('backend runtime configuration', () => { + const OLD_ENV = process.env; + + beforeEach(() => { + vi.resetModules(); + process.env = { ...OLD_ENV }; + delete process.env.NODE_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', + ]); + }); +}); +``` + +- [ ] **Step 2: Run config tests and verify they fail** + +Run: + +```bash +npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts +``` + +Expected: FAIL because `configuration().cors` and exported bootstrap helpers do not exist yet. + +- [ ] **Step 3: Add CORS origins to configuration** + +In `apps/backend/src/config/configuration.ts`, add a small parser and `cors` config: + +```ts +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), + // existing sections stay unchanged + 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', + jwtAccessExpires: process.env.JWT_ACCESS_EXPIRES || '15m', + jwtRefreshExpires: process.env.JWT_REFRESH_EXPIRES || '7d', + }, +})); +``` + +Keep the existing `database`, `moex`, `tbank`, and `cache` sections exactly as they are. + +- [ ] **Step 4: Export bootstrap helpers from main** + +In `apps/backend/src/main.ts`, add imports and helpers above `bootstrap()`: + +```ts +import { ConfigService } from '@nestjs/config'; + +const DEV_JWT_SECRET = 'dev-jwt-secret-change-in-production'; +const DEV_JWT_REFRESH_SECRET = 'dev-refresh-secret-change-in-production'; + +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; +} +``` + +Then update `bootstrap()` after `app.use(cookieParser())`: + +```ts + const configService = app.get(ConfigService); + const runtimeConfig: BackendRuntimeConfig = { + nodeEnv: process.env.NODE_ENV || 'development', + jwtSecret: configService.get('app.auth.jwtSecret', ''), + jwtRefreshSecret: configService.get('app.auth.jwtRefreshSecret', ''), + corsOrigins: configService.get('app.cors.origins', []), + }; + + assertSafeProductionConfig(runtimeConfig); + + app.enableCors({ + origin: buildCorsOrigin(runtimeConfig.nodeEnv, runtimeConfig.corsOrigins), + credentials: true, + }); +``` + +Remove the previous line: + +```ts + app.enableCors({ origin: true, credentials: true }); +``` + +- [ ] **Step 5: Prevent bootstrap from running during import tests** + +At the bottom of `apps/backend/src/main.ts`, replace unconditional bootstrap with: + +```ts +if (process.env.NODE_ENV !== 'test') { + void bootstrap(); +} +``` + +This keeps helper imports from starting a Nest server in Vitest. + +- [ ] **Step 6: Verify config tests pass** + +Run: + +```bash +npm run test -w apps/backend -- src/config/backend-runtime-config.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 2 complete after verification passes. + +--- + +### Task 3: Harden Portfolio Position DTO Validation + +**Files:** + +- Create: `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts` +- Modify: `apps/backend/src/modules/portfolio/dto/add-position.dto.ts` +- Modify: `apps/backend/src/modules/portfolio/dto/update-position.dto.ts` + +- [ ] **Step 1: Write failing DTO validation tests** + +Create `apps/backend/src/modules/portfolio/dto/position.dto.spec.ts`: + +```ts +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 (cls: new () => T, payload: Record) => + 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); + }); +}); +``` + +- [ ] **Step 2: Run DTO tests and verify they fail** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts +``` + +Expected: FAIL because `quantity: 0` and arbitrary date strings are currently accepted by DTO validation. + +- [ ] **Step 3: Update add-position validation** + +In `apps/backend/src/modules/portfolio/dto/add-position.dto.ts`: + +1. Add `IsDateString` to the import list from `class-validator`. +2. Change `@Min(0)` on `quantity` to `@Min(1)`. +3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`. + +The relevant fields should become: + +```ts + @ApiProperty({ example: 10 }) + @IsInt() + @Min(1) + quantity!: number; + + @ApiPropertyOptional({ example: '2026-06-01' }) + @IsDateString() + @IsOptional() + buyDate?: string; +``` + +- [ ] **Step 4: Update update-position validation** + +In `apps/backend/src/modules/portfolio/dto/update-position.dto.ts`: + +1. Add `IsDateString` to the import list from `class-validator`. +2. Change `@Min(0)` on `quantity` to `@Min(1)`. +3. Change `buyDate` validation from `@IsString()` to `@IsDateString()`. + +The relevant fields should become: + +```ts + @ApiPropertyOptional({ example: 15 }) + @IsInt() + @Min(1) + @IsOptional() + quantity?: number; + + @ApiPropertyOptional({ example: '2026-06-15' }) + @IsDateString() + @IsOptional() + buyDate?: string; +``` + +- [ ] **Step 5: Verify DTO tests pass** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/portfolio/dto/position.dto.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Decide whether service-level zero guard stays** + +Keep the existing service guard in `PortfolioService.addPosition()` for defense in depth: + +```ts +if (dto.quantity === 0) throw new BadRequestException('Quantity must be greater than 0'); +``` + +Do not add new behavior to `updatePosition()` beyond DTO validation. + +- [ ] **Step 7: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 3 complete after verification passes. + +--- + +### Task 4: Localize T-Bank gRPC `any` Casts Behind Typed Facade + +**Files:** + +- Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.ts` +- Modify: `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts` +- Modify: `apps/backend/src/modules/tbank/services/broker-accounts.service.ts` +- Modify: `apps/backend/src/modules/tbank/services/broker-operations.service.ts` +- Modify: `apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` +- Modify: `apps/backend/src/modules/tbank/services/broker-instruments.service.ts` + +- [ ] **Step 1: Write failing facade tests** + +In `apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts`, add this test after `creates service clients from vendored proto contracts`: + +```ts + 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'); + }); +``` + +- [ ] **Step 2: Run facade test and verify it fails** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts +``` + +Expected: FAIL because the facade methods do not exist. + +- [ ] **Step 3: Add service-client types and facade methods** + +In `apps/backend/src/modules/tbank/services/tbank-client.service.ts`, import T-Bank proto response types: + +```ts +import type { + TBankAccountsResponse, + TBankInstrumentResponse, + TBankOperationsByCursorResponse, + TBankPortfolioResponse, + TBankPositionsResponse, +} from '../types/tbank-proto.types'; +``` + +Add request and client types below `GrpcServiceConstructor`: + +```ts +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; +}; + +export type TBankOperationsClient = Client & { + getPortfolio: GrpcUnary; + getPositions: GrpcUnary; + getOperationsByCursor: GrpcUnary, TBankOperationsByCursorResponse>; +}; + +export type TBankInstrumentsClient = Client & { + getInstrumentBy: GrpcUnary; +}; +``` + +Add facade methods inside `TBankClientService` after `getServiceClient()`: + +```ts + getUsersClient(): TBankUsersClient { + return this.getServiceClient('UsersService') as TBankUsersClient; + } + + getOperationsClient(): TBankOperationsClient { + return this.getServiceClient('OperationsService') as TBankOperationsClient; + } + + getInstrumentsClient(): TBankInstrumentsClient { + return this.getServiceClient('InstrumentsService') as TBankInstrumentsClient; + } +``` + +The only remaining dynamic casts for service clients should be these facade methods. + +- [ ] **Step 4: Replace broker service direct casts** + +Update consumers: + +`apps/backend/src/modules/tbank/services/broker-accounts.service.ts`: + +```ts + const usersClient = this.tbankClient.getUsersClient(); +``` + +`apps/backend/src/modules/tbank/services/broker-operations.service.ts`: + +```ts + const operationsClient = this.tbankClient.getOperationsClient(); +``` + +`apps/backend/src/modules/tbank/services/broker-portfolio.service.ts` in both methods: + +```ts + const operationsClient = this.tbankClient.getOperationsClient(); +``` + +```ts + const operationsClient = this.tbankClient.getOperationsClient(); +``` + +`apps/backend/src/modules/tbank/services/broker-instruments.service.ts`: + +```ts + const instrumentsClient = this.tbankClient.getInstrumentsClient(); +``` + +- [ ] **Step 5: Verify no direct service-client casts remain in broker services** + +Run: + +```bash +rg "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services +``` + +Expected: no matches. + +- [ ] **Step 6: Verify T-Bank service tests pass** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-operations.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts +``` + +Expected: PASS. If mocks fail because specs stub `getServiceClient`, update those specs to stub the new facade method used by each service. + +- [ ] **Step 7: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 4 complete after verification passes. + +--- + +### Task 5: Preserve Cache `cachedAt` Metadata on Hits + +**Files:** + +- Create: `apps/backend/src/modules/cache/cache.service.spec.ts` +- Modify: `apps/backend/src/modules/cache/cache.service.ts` + +- [ ] **Step 1: Write failing cache metadata tests** + +Create `apps/backend/src/modules/cache/cache.service.spec.ts`: + +```ts +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 }); + }); +}); +``` + +- [ ] **Step 2: Run cache tests and verify they fail** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts +``` + +Expected: FAIL because cache hits currently return `cachedAt: null` and misses store raw values. + +- [ ] **Step 3: Implement cache entry wrapper** + +In `apps/backend/src/modules/cache/cache.service.ts`, add a private type near imports: + +```ts +type CacheEntry = { + data: T; + cachedAt: string; +}; +``` + +Add a type guard inside `CacheService`: + +```ts + private isCacheEntry(value: unknown): value is CacheEntry { + return ( + typeof value === 'object' && + value !== null && + 'data' in value && + 'cachedAt' in value && + typeof (value as { cachedAt?: unknown }).cachedAt === 'string' + ); + } +``` + +Replace `getOrFetch()` implementation with: + +```ts + async getOrFetch( + keyPrefix: string, + keyParts: string[], + fetchFn: () => Promise, + ttlConfigKey: string, + ): Promise<{ data: T; fromCache: boolean; cachedAt: string | null }> { + const key = this.buildKey(keyPrefix, ...keyParts); + const ttl = this.configService.get(`app.cache.${ttlConfigKey}`, 900); + + const cached = await this.get | T>(key); + if (cached !== undefined) { + if (this.isCacheEntry(cached)) { + return { data: cached.data, fromCache: true, cachedAt: cached.cachedAt }; + } + + return { data: cached as T, fromCache: true, cachedAt: null }; + } + + const data = await fetchFn(); + const cachedAt = new Date().toISOString(); + await this.set(key, { data, cachedAt }, ttl); + + return { data, fromCache: false, cachedAt }; + } +``` + +- [ ] **Step 4: Verify cache tests pass** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/cache/cache.service.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 5: Run service tests that use cache wrapper** + +Run: + +```bash +npm run test -w apps/backend -- src/modules/shares/shares.service.spec.ts src/modules/bonds/bonds.service.spec.ts src/modules/candles/candles.service.spec.ts src/modules/securities/screener.service.spec.ts src/modules/tbank/services/broker-accounts.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts src/modules/tbank/services/broker-analytics.service.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 6: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 5 complete after verification passes. + +--- + +### Task 6: Synchronize Published Backend Docs + +**Files:** + +- Modify: `apps/docs/docs/backend/moex-client.md` +- Modify: `apps/docs/docs/backend/modules.md` +- Modify: `apps/docs/docs/backend/api.md` +- Modify: `apps/docs/docs/backend/tbank-invest.md` + +- [ ] **Step 1: Confirm stale docs signals before editing** + +Run: + +```bash +rg -n "MoexClientService|operations/refresh|\"status\": \"ok\"" apps/docs/docs/backend +``` + +Expected: matches in `moex-client.md`, `modules.md`, and `api.md`. + +- [ ] **Step 2: Update MOEX client docs** + +In `apps/docs/docs/backend/moex-client.md`, replace the overview and method table so the primary abstraction is: + +```md +MOEX integration is split into a shared HTTP infrastructure client and focused domain clients under +`apps/backend/src/modules/moex-client/`: + +- `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. +``` + +Keep the rate limiting, circuit breaker, response parsing, and ISS table format sections, but make them describe `MoexHttpClient` instead of removed `MoexClientService`. + +- [ ] **Step 3: Update backend module docs** + +In `apps/docs/docs/backend/modules.md`: + +1. Replace diagram labels/references to `MoexClientService` with split clients. +2. Remove wording that says `MoexClientModule` is global if present. +3. Update health summary from raw `{ status, timestamp, uptime }` to envelope response with `checks`. + +Use this health endpoint wording: + +```md +- `GET /api/v1/health` → `{ data: { status, timestamp, uptime, checks }, meta }` +``` + +- [ ] **Step 4: Update API reference** + +In `apps/docs/docs/backend/api.md`: + +1. Replace the raw health response example with: + +```json +{ + "data": { + "status": "ok", + "timestamp": "2026-06-25T12:00:00.000Z", + "uptime": 1234.56, + "checks": [ + { "name": "database", "status": "ok" }, + { "name": "moex", "status": "ok" }, + { "name": "tbank", "status": "ok" } + ] + }, + "meta": { "fromCache": false, "cachedAt": null } +} +``` + +2. Replace `/api/v1/broker/accounts/:accountId/operations/refresh` with `/api/v1/broker/accounts/:accountId/operations/sync`. + +- [ ] **Step 5: Align T-Bank docs if needed** + +In `apps/docs/docs/backend/tbank-invest.md`, ensure it consistently names the sync endpoint as: + +```md +POST /api/v1/broker/accounts/:accountId/operations/sync +``` + +Do not introduce local read-path claims; this feature does not implement local-first reads. + +- [ ] **Step 6: Verify stale docs signals are gone** + +Run: + +```bash +rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend +``` + +Expected: no matches. + +- [ ] **Step 7: Build docs** + +Run: + +```bash +npm run build -w apps/docs +``` + +Expected: PASS. + +- [ ] **Step 8: Update task checklist** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 6 complete after verification passes. + +--- + +### Task 7: Final Quality Gate + +**Files:** + +- Modify: `docs/features/backend-architecture-refactor/tasks.md` +- Modify: `docs/features/backend-architecture-refactor/spec.md` only if implementation reveals a spec ambiguity. + +- [ ] **Step 1: Run backend lint** + +Run: + +```bash +npm run lint -w apps/backend +``` + +Expected: PASS. + +- [ ] **Step 2: Run full backend tests** + +Run: + +```bash +npm run test -w apps/backend +``` + +Expected: PASS. + +- [ ] **Step 3: Run backend build** + +Run: + +```bash +npm run build -w apps/backend +``` + +Expected: PASS. + +- [ ] **Step 4: Run docs build** + +Run: + +```bash +npm run build -w apps/docs +``` + +Expected: PASS. + +- [ ] **Step 5: Confirm no direct T-Bank service-client `as any` remains in production broker services** + +Run: + +```bash +rg -n "getServiceClient\('.*Service'\) as any|getServiceClient\(\".*Service\"\) as any" apps/backend/src/modules/tbank/services --glob '!*.spec.ts' +``` + +Expected: no matches. + +- [ ] **Step 6: Confirm docs no longer reference stale backend contracts** + +Run: + +```bash +rg -n "MoexClientService|operations/refresh" apps/docs/docs/backend +``` + +Expected: no matches. + +- [ ] **Step 7: Mark final checklist complete** + +In `docs/features/backend-architecture-refactor/tasks.md`, mark Task 7 complete and add the final verification commands with PASS results. + +- [ ] **Step 8: Inspect final diff** + +Run: + +```bash +rtk git status --short +rtk git diff +``` + +Expected: only files related to this SDD feature and implementation are changed. + +--- + +## Plan Self-Review + +- Spec requirement `Error masking`: covered by Task 1. +- Spec requirement `Production configuration hardening`: covered by Task 2. +- Spec requirement `DTO validation hardening`: covered by Task 3. +- Spec requirement `T-Bank gRPC typed boundary`: covered by Task 4. +- Spec requirement `Cache metadata consistency`: covered by Task 5. +- Spec requirement `Backend documentation sync`: covered by Task 6. +- Final quality gates and acceptance checks: covered by Task 7. +- Out-of-scope items are not implemented by any task. diff --git a/docs/features/backend-architecture-refactor/spec.md b/docs/features/backend-architecture-refactor/spec.md new file mode 100644 index 0000000..103747b --- /dev/null +++ b/docs/features/backend-architecture-refactor/spec.md @@ -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` — раздел «Технический долг — кандидат на следующую итерацию» diff --git a/docs/features/backend-architecture-refactor/tasks.md b/docs/features/backend-architecture-refactor/tasks.md new file mode 100644 index 0000000..091825a --- /dev/null +++ b/docs/features/backend-architecture-refactor/tasks.md @@ -0,0 +1,21 @@ +# Backend Architecture Refactor — Tasks + +Статус: draft + +- [ ] Task 0: Baseline verification before runtime changes. +- [ ] Task 1: Mask unhandled `500` errors without changing `HttpException` responses. +- [ ] Task 2: Harden production runtime config for JWT secrets and credentialed CORS. +- [ ] Task 3: Harden portfolio position DTO validation for quantity and buyDate. +- [ ] Task 4: Localize T-Bank gRPC `any` casts behind typed facade methods. +- [ ] Task 5: Preserve cache `cachedAt` metadata on cache hits. +- [ ] Task 6: Synchronize published backend docs with current MOEX/envelope/health/T-Bank contracts. +- [ ] Task 7: Run final lint/test/build/docs quality gate and inspect final diff. + +## Verification Log + +- Baseline backend tests: not run yet. +- Baseline backend build: not run yet. +- Final backend lint: not run yet. +- Final backend tests: not run yet. +- Final backend build: not run yet. +- Final docs build: not run yet. diff --git a/docs/inbox.md b/docs/inbox.md index c58ff4a..6657334 100644 --- a/docs/inbox.md +++ b/docs/inbox.md @@ -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-тестов. diff --git a/docs/roadmap.md b/docs/roadmap.md index cc6954d..e117687 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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) -- 2.47.2 From 0e8f65c457847b0d418b230881959fcce979347e Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:42:59 +0300 Subject: [PATCH 2/9] fix: mask internal Error.message in unhandled 500 responses --- .../filters/http-exception.filter.spec.ts | 67 +++++++++++++++++++ .../common/filters/http-exception.filter.ts | 3 +- 2 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 apps/backend/src/common/filters/http-exception.filter.spec.ts diff --git a/apps/backend/src/common/filters/http-exception.filter.spec.ts b/apps/backend/src/common/filters/http-exception.filter.spec.ts new file mode 100644 index 0000000..1a50bac --- /dev/null +++ b/apps/backend/src/common/filters/http-exception.filter.spec.ts @@ -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', + }), + ); + }); +}); diff --git a/apps/backend/src/common/filters/http-exception.filter.ts b/apps/backend/src/common/filters/http-exception.filter.ts index e44966f..c95e41a 100644 --- a/apps/backend/src/common/filters/http-exception.filter.ts +++ b/apps/backend/src/common/filters/http-exception.filter.ts @@ -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({ -- 2.47.2 From db28f464811b69d60bf386ab35ff0f1aa5a92825 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:48:18 +0300 Subject: [PATCH 3/9] feat: add production config hardening for JWT secrets and CORS origins --- apps/backend/package.json | 1 + .../src/config/backend-runtime-config.spec.ts | 76 ++++ apps/backend/src/config/configuration.ts | 12 + apps/backend/src/main.ts | 49 ++- package-lock.json | 347 ++++++++++++++++++ 5 files changed, 483 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/config/backend-runtime-config.spec.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index 561d622..1ddc313 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -51,6 +51,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", + "express": "^5.2.1", "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", diff --git a/apps/backend/src/config/backend-runtime-config.spec.ts b/apps/backend/src/config/backend-runtime-config.spec.ts new file mode 100644 index 0000000..6620be8 --- /dev/null +++ b/apps/backend/src/config/backend-runtime-config.spec.ts @@ -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', + ]); + }); +}); diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index 6544673..bc4ad20 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -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', diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 022be54..98b11a0 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -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('app.auth.jwtSecret', ''), + jwtRefreshSecret: configService.get('app.auth.jwtRefreshSecret', ''), + corsOrigins: configService.get('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(); +} diff --git a/package-lock.json b/package-lock.json index 06b6af7..60b4d46 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,6 +59,7 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", + "express": "^5.2.1", "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", @@ -13150,6 +13151,19 @@ "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", + "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,19 @@ "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", + "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 +15351,15 @@ "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", + "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 +18599,173 @@ "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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", + "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 +19069,27 @@ "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", + "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 +19341,15 @@ "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", + "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 +21395,12 @@ "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" + }, "node_modules/is-property": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", @@ -23436,6 +23675,18 @@ "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", + "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 +25583,22 @@ "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", + "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 +25982,15 @@ "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", + "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 +30608,32 @@ "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", + "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", + "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 +30947,32 @@ "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", + "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 +31208,25 @@ "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", + "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", -- 2.47.2 From af0c93fd3421e32230e4f5e525caa06bf72f06cd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:51:08 +0300 Subject: [PATCH 4/9] fix: harden portfolio DTO validation for quantity and date fields --- .../modules/portfolio/dto/add-position.dto.ts | 5 ++- .../portfolio/dto/position.dto.spec.ts | 43 +++++++++++++++++++ .../portfolio/dto/update-position.dto.ts | 5 ++- 3 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/modules/portfolio/dto/position.dto.spec.ts diff --git a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts index 7ed69b3..ea9e01f 100644 --- a/apps/backend/src/modules/portfolio/dto/add-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/add-position.dto.ts @@ -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; diff --git a/apps/backend/src/modules/portfolio/dto/position.dto.spec.ts b/apps/backend/src/modules/portfolio/dto/position.dto.spec.ts new file mode 100644 index 0000000..9034cba --- /dev/null +++ b/apps/backend/src/modules/portfolio/dto/position.dto.spec.ts @@ -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 (cls: new () => T, payload: Record) => + 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); + }); +}); diff --git a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts index 2aa0cd7..edb83aa 100644 --- a/apps/backend/src/modules/portfolio/dto/update-position.dto.ts +++ b/apps/backend/src/modules/portfolio/dto/update-position.dto.ts @@ -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; -- 2.47.2 From fe938b27067469fcad09b9b6399189dd486c2bd6 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:55:23 +0300 Subject: [PATCH 5/9] refactor: localize T-Bank gRPC as any casts behind typed facade methods --- .../services/broker-accounts.service.spec.ts | 4 +- .../tbank/services/broker-accounts.service.ts | 4 +- .../services/broker-instruments.service.ts | 2 +- .../broker-operations.service.spec.ts | 4 +- .../services/broker-operations.service.ts | 2 +- .../services/broker-portfolio.service.spec.ts | 16 ++++---- .../services/broker-portfolio.service.ts | 4 +- .../services/tbank-client.service.spec.ts | 10 +++++ .../tbank/services/tbank-client.service.ts | 38 +++++++++++++++++++ 9 files changed, 66 insertions(+), 18 deletions(-) diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts index dc63349..ecfc8e2 100644 --- a/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.spec.ts @@ -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' }, diff --git a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts index 4c6191d..8c2c918 100644 --- a/apps/backend/src/modules/tbank/services/broker-accounts.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-accounts.service.ts @@ -32,9 +32,9 @@ export class BrokerAccountsService { } private async fetchAccounts(): Promise { - const usersClient = this.tbankClient.getServiceClient('UsersService') as any; + const usersClient = this.tbankClient.getUsersClient(); const response = await this.tbankClient.callUnary< - Record, + { status: string }, TBankAccountsResponse >( 'UsersService/GetAccounts', diff --git a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts index be65b9f..2679575 100644 --- a/apps/backend/src/modules/tbank/services/broker-instruments.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-instruments.service.ts @@ -23,7 +23,7 @@ export class BrokerInstrumentsService { } private async fetchByUid(instrumentUid: string): Promise { - const instrumentsClient = this.tbankClient.getServiceClient('InstrumentsService') as any; + const instrumentsClient = this.tbankClient.getInstrumentsClient(); const response = await this.tbankClient.callUnary< { idType: string; id: string }, TBankInstrumentResponse diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts index f36b405..f05d356 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.spec.ts @@ -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' }], diff --git a/apps/backend/src/modules/tbank/services/broker-operations.service.ts b/apps/backend/src/modules/tbank/services/broker-operations.service.ts index f5ea5f1..d182c36 100644 --- a/apps/backend/src/modules/tbank/services/broker-operations.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-operations.service.ts @@ -69,7 +69,7 @@ export class BrokerOperationsService { accountId: string, request: Record, ): Promise { - const operationsClient = this.tbankClient.getServiceClient('OperationsService') as any; + const operationsClient = this.tbankClient.getOperationsClient(); const response = await this.tbankClient.callUnary< Record, TBankOperationsByCursorResponse diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index e9b52f8..ec8dd55 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -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({ diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index 45244f9..89796f0 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -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 { - 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), diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts index 953b0cb..1f7474c 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -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'); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index dea60f3..abc7b39 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -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 = ( 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; +}; + +export type TBankOperationsClient = Client & { + getPortfolio: GrpcUnary; + getPositions: GrpcUnary; + getOperationsByCursor: GrpcUnary, TBankOperationsByCursorResponse>; +}; + +export type TBankInstrumentsClient = Client & { + getInstrumentBy: GrpcUnary; +}; + 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( label: string, method: GrpcUnary, -- 2.47.2 From b87ed761ed962fdc8b2cf182d7c42c46569586a4 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:58:05 +0300 Subject: [PATCH 6/9] fix: preserve cachedAt metadata on cache hits --- .../src/modules/cache/cache.service.spec.ts | 65 +++++++++++++++++++ .../src/modules/cache/cache.service.ts | 28 ++++++-- 2 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 apps/backend/src/modules/cache/cache.service.spec.ts diff --git a/apps/backend/src/modules/cache/cache.service.spec.ts b/apps/backend/src/modules/cache/cache.service.spec.ts new file mode 100644 index 0000000..0c04fcd --- /dev/null +++ b/apps/backend/src/modules/cache/cache.service.spec.ts @@ -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 }); + }); +}); diff --git a/apps/backend/src/modules/cache/cache.service.ts b/apps/backend/src/modules/cache/cache.service.ts index 11efcd2..1919d4a 100644 --- a/apps/backend/src/modules/cache/cache.service.ts +++ b/apps/backend/src/modules/cache/cache.service.ts @@ -3,6 +3,11 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager'; import { ConfigService } from '@nestjs/config'; +type CacheEntry = { + 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(value: unknown): value is CacheEntry { + 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(`app.cache.${ttlConfigKey}`, 900); - const cached = await this.get(key); + const cached = await this.get | T>(key); if (cached !== undefined) { - return { data: cached, fromCache: true, cachedAt: null }; + if (this.isCacheEntry(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 }; } } -- 2.47.2 From 0e7ecbb1ef006d86dc252ff45cb2050a80ce2fc1 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 22:00:44 +0300 Subject: [PATCH 7/9] docs: sync backend docs with refactored MOEX, health envelope, and operations/sync endpoint --- apps/docs/docs/backend/api.md | 16 +++++++--- apps/docs/docs/backend/modules.md | 18 +++++------ apps/docs/docs/backend/moex-client.md | 44 ++++++++++++++++----------- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/apps/docs/docs/backend/api.md b/apps/docs/docs/backend/api.md index cd1d285..0d4b26a 100644 --- a/apps/docs/docs/backend/api.md +++ b/apps/docs/docs/backend/api.md @@ -92,9 +92,17 @@ **Response:** ```json { - "status": "ok", - "timestamp": "2026-06-13T12:00:00.000Z", - "uptime": 1234.56 + "data": { + "status": "ok", + "timestamp": "2026-06-25T12:00:00.000Z", + "uptime": 1234.56, + "checks": [ + { "name": "database", "status": "ok" }, + { "name": "moex", "status": "ok" }, + { "name": "tbank", "status": "ok" } + ] + }, + "meta": { "fromCache": false, "cachedAt": null } } ``` @@ -403,5 +411,5 @@ codegen types. | `/api/v1/broker/accounts/:accountId/portfolio` | GET | Портфель счёта: позиции, cash, метаданные | | `/api/v1/broker/accounts/:accountId/events` | GET | События и будущие выплаты (дивиденды, купоны) с фильтром по датам | | `/api/v1/broker/accounts/:accountId/operations` | GET | История операций (cursor pagination) | -| `/api/v1/broker/accounts/:accountId/operations/refresh` | POST | Принудительная синхронизация операций из T-Bank | +| `/api/v1/broker/accounts/:accountId/operations/sync` | POST | Принудительная синхронизация операций из T-Bank | | `/api/v1/broker/accounts/:accountId/positions` | GET | Позиции счёта (с пагинацией) | diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index bcbed9e..3f68d47 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -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 @@ -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 diff --git a/apps/docs/docs/backend/moex-client.md b/apps/docs/docs/backend/moex-client.md index b55e8b9..9dce825 100644 --- a/apps/docs/docs/backend/moex-client.md +++ b/apps/docs/docs/backend/moex-client.md @@ -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(path: string, params?: Record): Promise @@ -40,7 +52,7 @@ private async request(path: string, params?: Record): 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`, `getBondMarketData`, `batchPositions` | +| `MoexCandlesClient` | `getCandles` | +| `MoexHistoryClient` | `getShareHistory`, `getBondHistory` | +| `MoexDividendsClient` | `getDividends` | ## MOEX ISS types -- 2.47.2 From 53bab79c5fc4ecd28e3a1072157a2e6a0b87d6df Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 22:01:23 +0300 Subject: [PATCH 8/9] docs: mark all tasks complete in backend-architecture-refactor task list --- .../backend-architecture-refactor/tasks.md | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/docs/features/backend-architecture-refactor/tasks.md b/docs/features/backend-architecture-refactor/tasks.md index 091825a..6330363 100644 --- a/docs/features/backend-architecture-refactor/tasks.md +++ b/docs/features/backend-architecture-refactor/tasks.md @@ -1,21 +1,23 @@ # Backend Architecture Refactor — Tasks -Статус: draft +Статус: done -- [ ] Task 0: Baseline verification before runtime changes. -- [ ] Task 1: Mask unhandled `500` errors without changing `HttpException` responses. -- [ ] Task 2: Harden production runtime config for JWT secrets and credentialed CORS. -- [ ] Task 3: Harden portfolio position DTO validation for quantity and buyDate. -- [ ] Task 4: Localize T-Bank gRPC `any` casts behind typed facade methods. -- [ ] Task 5: Preserve cache `cachedAt` metadata on cache hits. -- [ ] Task 6: Synchronize published backend docs with current MOEX/envelope/health/T-Bank contracts. -- [ ] Task 7: Run final lint/test/build/docs quality gate and inspect final diff. +- [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: not run yet. -- Baseline backend build: not run yet. -- Final backend lint: not run yet. -- Final backend tests: not run yet. -- Final backend build: not run yet. -- Final docs build: not run yet. +- 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. -- 2.47.2 From b27d6ad83645c0ed24ef9f62ec44f9c4dab37d63 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 22:13:11 +0300 Subject: [PATCH 9/9] chore: remove redundant express dependency and fix docs --- apps/backend/package.json | 1 - apps/docs/docs/backend/modules.md | 4 ++-- apps/docs/docs/backend/moex-client.md | 2 +- package-lock.json | 25 +++++++++++++++++++++++-- 4 files changed, 26 insertions(+), 6 deletions(-) diff --git a/apps/backend/package.json b/apps/backend/package.json index 1ddc313..561d622 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -51,7 +51,6 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", - "express": "^5.2.1", "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index 3f68d47..0785dec 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -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,7 +79,7 @@ flowchart TB ### MoexClientModule -Глобальный модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты. +Модуль для MOEX ISS, разделённый на `MoexHttpClient` и domain-specific клиенты. - `MoexHttpClient` — rate limiter (p-queue, 10 req/s), circuit breaker (5 errors → 30s open), ISS JSON parsing. - Domain clients — `MoexSecuritiesClient`, `MoexMarketDataClient`, `MoexCandlesClient`, `MoexHistoryClient`, `MoexDividendsClient`. diff --git a/apps/docs/docs/backend/moex-client.md b/apps/docs/docs/backend/moex-client.md index 9dce825..b89d0c9 100644 --- a/apps/docs/docs/backend/moex-client.md +++ b/apps/docs/docs/backend/moex-client.md @@ -74,7 +74,7 @@ MOEX возвращает данные в табличном формате: | Client | Методы | |---|---| | `MoexSecuritiesClient` | `searchSecurities`, `getSecurityDescription` | -| `MoexMarketDataClient` | `getShareMarketData`, `getBondMarketData`, `batchPositions` | +| `MoexMarketDataClient` | `getShareMarketData`, `getShareMarketDataBatch`, `getBondData`, `getBondMarketData`, `getBondPositionDataBatch` | | `MoexCandlesClient` | `getCandles` | | `MoexHistoryClient` | `getShareHistory`, `getBondHistory` | | `MoexDividendsClient` | `getDividends` | diff --git a/package-lock.json b/package-lock.json index 60b4d46..f32785e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -59,7 +59,6 @@ "@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/parser": "^7.0.0", "eslint": "^8.0.0", - "express": "^5.2.1", "prisma": "^7.8.0", "typescript": "^5.3.0", "unplugin-swc": "^1.5.9", @@ -13156,6 +13155,7 @@ "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" @@ -15294,6 +15294,7 @@ "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" }, @@ -15356,6 +15357,7 @@ "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" } @@ -18604,6 +18606,7 @@ "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", @@ -18647,6 +18650,7 @@ "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", @@ -18671,6 +18675,7 @@ "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" }, @@ -18684,6 +18689,7 @@ "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" }, @@ -18700,6 +18706,7 @@ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -18709,6 +18716,7 @@ "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" @@ -18725,6 +18733,7 @@ "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", @@ -18740,6 +18749,7 @@ "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", @@ -18758,6 +18768,7 @@ "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" }, @@ -19074,6 +19085,7 @@ "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", @@ -19346,6 +19358,7 @@ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", + "peer": true, "engines": { "node": ">= 0.8" } @@ -21399,7 +21412,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/is-property": { "version": "1.0.2", @@ -23680,6 +23694,7 @@ "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" }, @@ -25588,6 +25603,7 @@ "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" }, @@ -25987,6 +26003,7 @@ "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" } @@ -30613,6 +30630,7 @@ "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", @@ -30629,6 +30647,7 @@ "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" @@ -30952,6 +30971,7 @@ "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", @@ -31213,6 +31233,7 @@ "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", -- 2.47.2