fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s

Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
This commit is contained in:
2026-09-27 14:09:05 +03:00
parent 5d3835e796
commit 4796d9d3f0
55 changed files with 1954 additions and 569 deletions
+3 -94
View File
@@ -5,24 +5,11 @@ import {
Injectable,
NotFoundException,
} from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import {
SupportedTbankAccount,
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account.policy';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = {
token: string;
generation: string;
};
export type TbankAccountContext = TbankConnectionContext & {
account: TbankAccount;
};
import type { TbankAccount } from './tbank.client';
export type TbankSyncAttempt = {
userId: string;
@@ -36,7 +23,6 @@ export class TbankAccessService {
constructor(
private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService,
private readonly tbank: TbankClient,
) {}
async reserveMembership(
@@ -251,81 +237,4 @@ export class TbankAccessService {
throw new NotFoundException();
return connection;
}
async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
}
async withSupportedAccounts<T>(
userId: string,
handler: (context: {
token: string;
generation: string;
accounts: SupportedTbankAccount[];
}) => Promise<T> | T,
): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) =>
handler({
token,
generation,
accounts: supportedTbankAccounts(
await this.tbank.getOpenAccounts(token),
),
}),
);
}
async withConnection<T>(
userId: string,
handler: (context: TbankConnectionContext) => Promise<T> | T,
): Promise<T> {
const connection = await this.connection(userId);
return this.tokens.translateInvalidToken(async () => handler(connection));
}
async withAccount<T>(
userId: string,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
return this.withConnection(userId, (connection) =>
this.withAccountForConnection(connection, accountId, handler),
);
}
async withAccountForConnection<T>(
connection: TbankConnectionContext,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
const { token, generation } = connection;
return this.tokens.translateInvalidToken(async () => {
const account = accessibleTbankAccount(
await this.tbank.getOpenAccounts(token),
accountId,
);
if (!account) throw new NotFoundException();
return handler({ token, generation, account });
});
}
private async connection(
userId: string,
): Promise<{ token: string; generation: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
return {
token: this.tokens.decrypt(connection.encryptedToken),
generation: createHash('sha256')
.update(connection.encryptedToken)
.digest('base64url'),
};
}
}