fix: trust tbank grpc root certificate
All checks were successful
All checks were successful
This commit is contained in:
parent
ea916dfec9
commit
8c2a6c9e3c
@ -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 свечей (с) |
|
||||||
|
|||||||
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal file
33
apps/backend/certs/russian-trusted-root-ca.pem
Normal 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-----
|
||||||
@ -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),
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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];
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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`
|
||||||
|
|||||||
@ -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 токенов и привязку каждого брокерского
|
||||||
каждого брокерского счёта к владельцу.
|
счёта к владельцу.
|
||||||
|
|||||||
@ -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,
|
||||||
})}
|
})}
|
||||||
|
|||||||
@ -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,
|
||||||
})}
|
})}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user