fix: trust tbank grpc root certificate
All checks were successful
CI / lint (pull_request) Successful in 2m11s
CI / test (pull_request) Successful in 1m58s
CI / build (pull_request) Successful in 2m3s
CI / lint (push) Successful in 1m57s
CI / test (push) Successful in 2m4s
CI / build (push) Successful in 2m8s

This commit is contained in:
Sergey Krylov 2026-06-17 07:39:37 +03:00
parent ea916dfec9
commit 8c2a6c9e3c
13 changed files with 109 additions and 17 deletions

View File

@ -54,6 +54,12 @@ Live MOEX integration tests opt-in: `npm run test:integration -w apps/backend`.
| `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX | | `MOEX_RATE_LIMIT` | 10 | Запросов/с к MOEX |
| `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker | | `MOEX_CIRCUIT_BREAKER_THRESHOLD` | 5 | Количество ошибок до открытия circuit breaker |
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | 30 | Время до попытки закрыть 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_MARKET_DATA_TTL` | 900 | TTL рыночных данных (с) |
| `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) | | `CACHE_HISTORY_TTL` | 3600 | TTL истории (с) |
| `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) | | `CACHE_CANDLES_TTL` | 3600 | TTL свечей (с) |

View File

@ -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-----

View File

@ -17,6 +17,7 @@ export default registerAs('app', () => ({
tbank: { tbank: {
token: process.env.T_BANK_TOKEN || '', token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', 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', appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),

View File

@ -1,6 +1,9 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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'; import { TBankClientService } from './tbank-client.service';
describe('TBankClientService', () => { describe('TBankClientService', () => {
@ -41,6 +44,30 @@ describe('TBankClientService', () => {
expect(() => service.getServiceClient('UsersService')).not.toThrow(); 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<string, unknown> = {
'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 () => { it('throws integration unavailable when token is missing', async () => {
const missingConfig = { const missingConfig = {
get: vi.fn((key: string, fallback?: unknown) => get: vi.fn((key: string, fallback?: unknown) =>

View File

@ -17,7 +17,7 @@ import {
} from '@grpc/grpc-js'; } from '@grpc/grpc-js';
import { loadSync } from '@grpc/proto-loader'; import { loadSync } from '@grpc/proto-loader';
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { existsSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config'; import { TBANK_PROTO_FILES, TBANK_PROTO_PACKAGE } from '../tbank.config';
@ -108,7 +108,7 @@ export class TBankClientService {
const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor; const ServiceCtor = namespace[serviceName] as GrpcServiceConstructor;
const client = new ServiceCtor( const client = new ServiceCtor(
this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'), this.configService.get<string>('app.tbank.baseUrl', 'invest-public-api.tbank.ru:443'),
ChannelCredentials.createSsl(), this.createChannelCredentials(),
); );
this.clientCache.set(serviceName, client); this.clientCache.set(serviceName, client);
@ -148,6 +148,22 @@ export class TBankClientService {
); );
} }
private createChannelCredentials(): ChannelCredentials {
const caCertPath = this.configService.get<string>('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 { private mapGrpcError(label: string, error: ServiceError): Error {
const trackingId = error.metadata?.get('x-tracking-id')?.[0]; const trackingId = error.metadata?.get('x-tracking-id')?.[0];
const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0]; const retryAfter = error.metadata?.get('x-ratelimit-reset')?.[0];

View File

@ -26,6 +26,7 @@ describe('T-Bank configuration', () => {
it('reads T-Bank token and TTL overrides from environment', () => { it('reads T-Bank token and TTL overrides from environment', () => {
process.env.T_BANK_TOKEN = 'secret-token'; process.env.T_BANK_TOKEN = 'secret-token';
process.env.T_BANK_BASE_URL = 'sandbox-invest-public-api.tbank.ru:443'; 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.T_BANK_RATE_LIMIT_PER_SECOND = '2';
process.env.CACHE_TBANK_ACCOUNTS_TTL = '120'; 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.token).toBe('secret-token');
expect(config.tbank.baseUrl).toBe('sandbox-invest-public-api.tbank.ru:443'); 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.tbank.rateLimitPerSecond).toBe(2);
expect(config.cache.tbankAccountsTtl).toBe(120); expect(config.cache.tbankAccountsTtl).toBe(120);
}); });

View File

@ -16,8 +16,8 @@ describe('TBankController', () => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('limits broker endpoints to admins because they use a server-side T-Bank token', () => { it('allows authenticated users to access broker endpoints', () => {
expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['admin']); expect(Reflect.getMetadata(ROLES_KEY, TBankController)).toEqual(['user']);
}); });
it('returns accounts in a single API envelope', async () => { 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' }); 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 }); vi.mocked(sync.syncAccount).mockResolvedValueOnce({ upserted: 2 });
const controller = new TBankController(accounts, portfolio, operations, sync); const controller = new TBankController(accounts, portfolio, operations, sync);

View File

@ -17,7 +17,7 @@ import { BrokerPortfolioService } from './services/broker-portfolio.service';
@ApiTags('Broker') @ApiTags('Broker')
@ApiBearerAuth() @ApiBearerAuth()
@Roles('admin') @Roles('user')
@Controller('broker') @Controller('broker')
export class TBankController { export class TBankController {
constructor( constructor(

View File

@ -13,6 +13,7 @@
| `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker | | `MOEX_CIRCUIT_BREAKER_RESET_SECONDS` | `30` | Время в секундах до сброса circuit breaker |
| `T_BANK_TOKEN` | `''` | Server-side токен T-Bank Invest | | `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_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_APP_NAME` | `ksv741.moex-vibe` | Metadata приложения для T-Bank |
| `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank | | `T_BANK_RATE_LIMIT_PER_SECOND` | `5` | Локальный rate limiter для T-Bank |
| `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) | | `T_BANK_REQUEST_TIMEOUT_MS` | `10000` | Deadline gRPC-запроса (мс) |
@ -45,6 +46,7 @@ registerAs('app', () => ({
tbank: { tbank: {
token: process.env.T_BANK_TOKEN || '', token: process.env.T_BANK_TOKEN || '',
baseUrl: process.env.T_BANK_BASE_URL || 'invest-public-api.tbank.ru:443', 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', appName: process.env.T_BANK_APP_NAME || 'ksv741.moex-vibe',
rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10), rateLimitPerSecond: parseInt(process.env.T_BANK_RATE_LIMIT_PER_SECOND || '5', 10),
requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10), requestTimeoutMs: parseInt(process.env.T_BANK_REQUEST_TIMEOUT_MS || '10000', 10),

View File

@ -149,7 +149,7 @@ Read-only интеграция с T-Bank Invest для брокерских сч
- `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов - `BrokerPortfolioService` объединяет портфель, позиции, cash и метаданные инструментов
- `BrokerOperationsService` отдаёт cursor-paginated историю операций - `BrokerOperationsService` отдаёт cursor-paginated историю операций
- `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы - `BrokerOperationSyncService` сохраняет историю операций в отдельные Prisma-таблицы
- Broker endpoints требуют роль `admin`, потому что текущая версия использует один server-side - Broker endpoints требуют роль `user`; текущая single-user версия использует один server-side
`T_BANK_TOKEN` `T_BANK_TOKEN`
- Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной - Direct-read endpoints используют `CacheService` с T-Bank TTL и не записывают данные в ручной
`PortfolioModule` `PortfolioModule`

View File

@ -27,9 +27,14 @@ x-app-name: ksv741.moex-vibe
Токен читается из env-переменных backend и не возвращается во frontend, Swagger responses или логи. Токен читается из 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 ## Backend endpoints
Все endpoints защищены JWT, требуют роль `admin` и возвращают стандартную оболочку `{ data, meta }`. Все endpoints защищены JWT, требуют роль `user` и возвращают стандартную оболочку `{ data, meta }`.
| Endpoint | Описание | | Endpoint | Описание |
|---|---| |---|---|
@ -63,14 +68,14 @@ Direct-read endpoints используют короткий in-memory cache, ч
- `BrokerOperation` — нормализованная операция и сырой JSON payload; - `BrokerOperation` — нормализованная операция и сырой JSON payload;
- `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту. - `BrokerOperationSyncState` — состояние последней синхронизации по брокерскому счёту.
Синхронизация запускается явно через admin-only endpoint `POST .../operations/sync` с query Синхронизация запускается явно через endpoint `POST .../operations/sync` с query параметрами `from`
параметрами `from` и `to` в ISO-8601 формате. и `to` в ISO-8601 формате.
Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`. Эти таблицы не связаны с ручными портфелями `Portfolio` и `Position`.
## Безопасность ## Безопасность
Текущая версия рассчитана на single-user/admin сценарий: используется один server-side Текущая версия рассчитана на single-user сценарий: используется один server-side `T_BANK_TOKEN`, а
`T_BANK_TOKEN`, поэтому broker endpoints доступны только пользователям с ролью `admin`. Перед broker endpoints доступны аутентифицированным пользователям с ролью `user`. Перед multi-user режимом
multi-user режимом нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку нужно добавить зашифрованное хранение пользовательских T-Bank токенов и привязку каждого брокерского
каждого брокерского счёта к владельцу. счёта к владельцу.

View File

@ -38,7 +38,7 @@ export function PortfolioCard({ portfolio }: { portfolio: Portfolio }) {
<h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3> <h3 style={{ margin: 0, fontSize: 16, fontWeight: 600, flex: 1 }}>{portfolio.name}</h3>
<div style={{ textAlign: 'right' }}> <div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}> <div style={{ fontSize: 20, fontWeight: 700, lineHeight: 1.2 }}>
{portfolio.totalValue.toLocaleString('ru-RU', { {portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} })}

View File

@ -19,7 +19,7 @@ export function PortfolioSummary({ portfolio }: { portfolio: PortfolioDetail })
Общая стоимость Общая стоимость
</div> </div>
<div style={{ fontSize: 24, fontWeight: 700 }}> <div style={{ fontSize: 24, fontWeight: 700 }}>
{portfolio.totalValue.toLocaleString('ru-RU', { {portfolio.totalValue?.toLocaleString('ru-RU', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
})} })}