From 120db3c2abfafd8fb86dec1f6800d61e0fcfce5c Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Thu, 25 Jun 2026 21:41:57 +0300 Subject: [PATCH] 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)