diff --git a/AGENTS.md b/AGENTS.md index 68df9e6..89ad694 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,12 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`. | `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX | | `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker | | `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть circuit breaker | +| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest | +| `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest | +| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты | +| `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank | +| `T_BANK_RATE_LIMIT_PER_SECOND` | 5 | Локальный rate limiter для T-Bank | +| `T_BANK_REQUEST_TIMEOUT_MS` | 10000 | Deadline gRPC-запроса (мс) | | `CACHE_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) | | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) | | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) | diff --git a/apps/backend/certs/russian-trusted-root-ca.pem b/apps/backend/certs/russian-trusted-root-ca.pem new file mode 100644 index 0000000..fe7ad30 --- /dev/null +++ b/apps/backend/certs/russian-trusted-root-ca.pem @@ -0,0 +1,33 @@ +-----BEGIN CERTIFICATE----- +MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx +PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu +ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg +Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS +VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg +YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v +dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n +qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q +XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U +zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX +YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y +Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD +U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD +4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 +G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH +BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX +ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa +OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf +BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS +BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF +AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH +tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq +W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ +/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS +AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj +C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV +4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d +WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ +D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC +EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq +391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= +-----END CERTIFICATE----- diff --git a/apps/backend/src/config/configuration.ts b/apps/backend/src/config/configuration.ts index be1ca82..cb3472a 100644 --- a/apps/backend/src/config/configuration.ts +++ b/apps/backend/src/config/configuration.ts @@ -17,6 +17,7 @@ export default registerAs('app', () => ({ tbank: { token: process.env.T_BANK_TOKEN || '', baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', + caCertPath: process.env.T_BANK_CA_CERT_PATH || '', appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe', rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), 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 4c556ba..4aa8cb1 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 @@ -1,6 +1,9 @@ import { ServiceUnavailableException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; +import { ChannelCredentials, ClientUnaryCall, Metadata, ServiceError, status } from '@grpc/grpc-js'; +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { TBankClientService } from './tbank-client.service'; describe('TBankClientService', () => { @@ -41,6 +44,30 @@ describe('TBankClientService', () => { expect(() => service.getServiceClient('UsersService')).not.toThrow(); }); + 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'); + const createSslSpy = vi.spyOn(ChannelCredentials, 'createSsl'); + const caConfig = { + get: vi.fn((key: string, fallback?: unknown) => { + const values: Record = { + 'app.tbank.token': 'token-1', + 'app.tbank.caCertPath': caPath, + }; + + return values[key] ?? fallback; + }), + } as unknown as ConfigService; + const service = new TBankClientService(caConfig); + + service.getServiceClient('UsersService'); + + const [rootCerts] = createSslSpy.mock.calls.at(-1) ?? []; + expect(Buffer.isBuffer(rootCerts)).toBe(true); + expect((rootCerts as Buffer).toString('utf8')).toContain('test-ca'); + createSslSpy.mockRestore(); + }); + it('throws integration unavailable when token is missing', async () => { const missingConfig = { get: vi.fn((key: string, fallback?: unknown) => 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 a818747..52be652 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -17,7 +17,7 @@ import { } from '@grpc/grpc-js'; import { loadSync } from '@grpc/proto-loader'; import PQueue from 'p-queue'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config'; @@ -108,7 +108,7 @@ export class TBankClientService { const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor; const client = new ServiceCtor( this.configService.get('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), - ChannelCredentials.createSsl(), + this.createChannelCredentials(), ); this.clientCache.set(serviceName, client); @@ -148,6 +148,22 @@ export class TBankClientService { ); } + private createChannelCredentials(): ChannelCredentials { + const caCertPath = this.configService.get('app.tbank.caCertPath', ''); + if (!caCertPath) return ChannelCredentials.createSsl(); + + try { + return ChannelCredentials.createSsl(readFileSync(caCertPath)); + } catch (error) { + this.logger.error( + `Failed to read T-Bank CA certificate from ${caCertPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + throw new ServiceUnavailableException('T-Bank CA certificate is not readable'); + } + } + private mapGrpcError(label: string, error: ServiceError): Error { const trackingId = error.metadata?.get('x-tracking-id')?.[0]; const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0]; diff --git a/apps/backend/src/modules/tbank/tbank.config.spec.ts b/apps/backend/src/modules/tbank/tbank.config.spec.ts index a3ae781..e3f9a34 100644 --- a/apps/backend/src/modules/tbank/tbank.config.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.config.spec.ts @@ -26,6 +26,7 @@ describe('T-Bank configuration', () => { it('reads T-Bank token and TTL overrides from environment', () => { process.env.T_BANK_TOKEN = 'secret-token'; process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443'; + process.env.T_BANK_CA_CERT_PATH = '/tmp/tbank-root-ca.pem'; process.env.T_BANK_RATE_LIMIT_PER_SECOND = '2'; process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; @@ -33,6 +34,7 @@ describe('T-Bank configuration', () => { expect(config.tbank.token).toBe('secret-token'); expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443'); + expect(config.tbank.caCertPath).toBe('/tmp/tbank-root-ca.pem'); expect(config.tbank.rateLimitPerSecond).toBe(2); expect(config.cache.tbankAccountsTtl).toBe(120); }); diff --git a/apps/backend/src/modules/tbank/tbank.controller.spec.ts b/apps/backend/src/modules/tbank/tbank.controller.spec.ts index 9e792d1..e5340a5 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.spec.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.spec.ts @@ -16,8 +16,8 @@ describe('TBankController', () => { vi.clearAllMocks(); }); - it('limits broker endpoints to admins because they use a server-side T-Bank token', () => { - expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['admin']); + it('allows authenticated users to access broker endpoints', () => { + expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']); }); it('returns accounts in a single API envelope', async () => { @@ -43,7 +43,7 @@ describe('TBankController', () => { expect(response.meta).toEqual({ fromCache: true, cachedAt: '2026-06-17T00:00:00.000Z' }); }); - it('exposes an admin sync trigger for durable operation history', async () => { + it('exposes a sync trigger for durable operation history', async () => { vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 }); const controller = new TBankController(accounts, portfolio, operations, sync); diff --git a/apps/backend/src/modules/tbank/tbank.controller.ts b/apps/backend/src/modules/tbank/tbank.controller.ts index b676120..5b3f316 100644 --- a/apps/backend/src/modules/tbank/tbank.controller.ts +++ b/apps/backend/src/modules/tbank/tbank.controller.ts @@ -17,7 +17,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service'; @ApiTags('Broker') @ApiBearerAuth() -@Roles('admin') +@Roles('user') @Controller('broker') export class TBankController { constructor( diff --git a/apps/docs/docs/backend/configuration.md b/apps/docs/docs/backend/configuration.md index d4b723d..d6e5941 100644 --- a/apps/docs/docs/backend/configuration.md +++ b/apps/docs/docs/backend/configuration.md @@ -13,6 +13,7 @@ | `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker | | `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest | | `T_BANK_BASE_URL` | `invest-public-api.tbank.ru:443` | gRPC endpoint T-Bank Invest | +| `T_BANK_CA_CERT_PATH` | `''` | Путь к PEM root CA для gRPC TLS, если локальная сеть подменяет сертификаты | | `T_BANK_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank | | `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank | | `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) | @@ -45,6 +46,7 @@ registerAs('app', () => ({ tbank: { token: process.env.T_BANK_TOKEN || '', baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', + caCertPath: process.env.T_BANK_CA_CERT_PATH || '', appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe', rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), diff --git a/apps/docs/docs/backend/modules.md b/apps/docs/docs/backend/modules.md index f4cb9a4..bcbed9e 100644 --- a/apps/docs/docs/backend/modules.md +++ b/apps/docs/docs/backend/modules.md @@ -149,7 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч - `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов - `BrokerOperationsService` отдаёт cursor-paginated историю операций - `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы -- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side +- Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side `T_BANK_TOKEN` - Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной `PortfolioModule` diff --git a/apps/docs/docs/backend/tbank-invest.md b/apps/docs/docs/backend/tbank-invest.md index b2e2158..058a5fe 100644 --- a/apps/docs/docs/backend/tbank-invest.md +++ b/apps/docs/docs/backend/tbank-invest.md @@ -27,9 +27,14 @@ x-app-name: ksv741.moex-vibe Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи. +Если gRPC-запросы падают с `self-signed certificate in certificate chain`, значит Node.js не доверяет +root CA, которым локальная сеть или proxy подписывает TLS-цепочку. В этом случае нужно экспортировать +доверенный root certificate в PEM-файл и указать путь в `T_BANK_CA_CERT_PATH`. Проверку TLS отключать +не нужно. + ## Backend endpoints -Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`. +Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`. | Endpoint | Описание | |---|---| @@ -63,14 +68,14 @@ Direct-read endpoints используют короткий in-memory cache, ч - `BrokerOperation` — нормализованная операция и сырой JSON payload; - `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту. -Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query -параметрами `from` и `to` в ISO-8601 формате. +Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from` +и `to` в ISO-8601 формате. Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`. ## Безопасность -Текущая версия рассчитана на single-user/admin сценарий: используется один server-side -`T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед -multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку -каждого брокерского счёта к владельцу. +Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а +broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом +нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского +счёта к владельцу. diff --git a/apps/frontend/src/components/portfolios/PortfolioCard.tsx b/apps/frontend/src/components/portfolios/PortfolioCard.tsx index 82312a2..5d3e094 100644 --- a/apps/frontend/src/components/portfolios/PortfolioCard.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioCard.tsx @@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {

{portfolio.name}

- {portfolio.totalValue.toLocaleString('ru-RU', { + {portfolio.totalValue?.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })} diff --git a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx index 0e376b7..b126e3d 100644 --- a/apps/frontend/src/components/portfolios/PortfolioSummary.tsx +++ b/apps/frontend/src/components/portfolios/PortfolioSummary.tsx @@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail }) Общая стоимость
- {portfolio.totalValue.toLocaleString('ru-RU', { + {portfolio.totalValue?.toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2, })}