Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
255 lines
7.9 KiB
TypeScript
255 lines
7.9 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
import type { TbankAccount } from './tbank.client';
|
|
|
|
import { PrismaService } from '../../prisma/prisma.service';
|
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
|
import { TbankTokenService } from './tbank-token.service';
|
|
|
|
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
|
const MEMBERSHIP_REVISION_INCREMENT = 1;
|
|
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
|
|
|
|
export type TbankSyncAttempt = {
|
|
attemptId: string;
|
|
id: string;
|
|
revision: string;
|
|
userId: string;
|
|
};
|
|
|
|
@Injectable()
|
|
export class TbankAccessService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly tokens: TbankTokenService,
|
|
) {}
|
|
|
|
async reserveMembership(
|
|
userId: string,
|
|
revision: string,
|
|
processRunId: string,
|
|
active: ReadonlySet<string>,
|
|
force: boolean,
|
|
) {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const current = await tx.tbankConnection.updateMany({
|
|
where: { userId, revision, invalidToken: false },
|
|
data: { membershipRevision: { increment: 0 } },
|
|
});
|
|
if (!current.count) return null;
|
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
|
where: { userId },
|
|
});
|
|
if (
|
|
!force &&
|
|
!tbankSourceNeedsRefresh(
|
|
'membership',
|
|
connection.membershipAt,
|
|
new Date(),
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
const scope = { userId, revision, scopeKey: 'membership' };
|
|
const previous = await tx.tbankSyncWork.findUnique({
|
|
where: { userId_revision_scopeKey: scope },
|
|
});
|
|
if (
|
|
previous?.state === 'running' &&
|
|
previous.processRunId === processRunId &&
|
|
active.has(previous.attemptId)
|
|
) {
|
|
return { work: previous, launch: false };
|
|
}
|
|
const data = {
|
|
attemptId: randomUUID(),
|
|
processRunId,
|
|
state: 'running',
|
|
intent: { source: 'membership' },
|
|
progress: {},
|
|
priority: 0,
|
|
errorCode: null,
|
|
};
|
|
const work = await tx.tbankSyncWork.upsert({
|
|
where: { userId_revision_scopeKey: scope },
|
|
create: { ...scope, ...data, id: randomUUID() },
|
|
update: data,
|
|
});
|
|
return { work, launch: true };
|
|
});
|
|
}
|
|
|
|
membershipWork(userId: string, revision: string) {
|
|
return this.prisma.tbankSyncWork.findUnique({
|
|
where: {
|
|
userId_revision_scopeKey: { userId, revision, scopeKey: 'membership' },
|
|
},
|
|
select: { id: true, state: true, errorCode: true, updatedAt: true },
|
|
});
|
|
}
|
|
|
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
|
const result = await this.prisma.tbankSyncWork.updateMany({
|
|
where: {
|
|
...attempt,
|
|
state: 'running',
|
|
connection: { invalidToken: false },
|
|
},
|
|
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
|
});
|
|
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
|
}
|
|
|
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
|
const work = await this.prisma.tbankSyncWork.findFirst({
|
|
where: {
|
|
...attempt,
|
|
state: 'running',
|
|
connection: { invalidToken: false },
|
|
},
|
|
select: { connection: { select: { encryptedToken: true } } },
|
|
});
|
|
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
|
}
|
|
|
|
async publishMembership(
|
|
attempt: TbankSyncAttempt,
|
|
accounts: TbankAccount[],
|
|
fetchedAt: Date,
|
|
completeWork = false,
|
|
): Promise<boolean> {
|
|
const supported = supportedTbankAccounts(accounts);
|
|
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
|
|
throw new Error('Duplicate T-Bank account identity');
|
|
}
|
|
return this.prisma.$transaction(async (tx) => {
|
|
// Acquire the write fence before reading membership. Replacement and
|
|
// attempt supersession cannot interleave with the publication below.
|
|
const claimed = await tx.tbankSyncWork.updateMany({
|
|
where: {
|
|
...attempt,
|
|
state: 'running',
|
|
connection: { invalidToken: false },
|
|
},
|
|
data: { updatedAt: new Date() },
|
|
});
|
|
if (!claimed.count) return false;
|
|
const { userId, revision } = attempt;
|
|
const previous = await tx.tbankAccount.findMany({
|
|
where: { userId, revision },
|
|
orderBy: { upstreamOrder: 'asc' },
|
|
select: { accountId: true, type: true, name: true, openedOn: true },
|
|
});
|
|
const next = supported.map(({ id, ...account }) => ({
|
|
accountId: id,
|
|
...account,
|
|
}));
|
|
const changed =
|
|
previous.length !== next.length ||
|
|
previous.some((account, index) => {
|
|
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
|
|
const incoming = next[index];
|
|
return (
|
|
account.accountId !== incoming.accountId ||
|
|
account.type !== incoming.type ||
|
|
account.name !== incoming.name ||
|
|
account.openedOn !== incoming.openedOn
|
|
);
|
|
});
|
|
await tx.tbankAccount.deleteMany({
|
|
where: {
|
|
userId,
|
|
revision,
|
|
accountId: { notIn: supported.map(({ id }) => id) },
|
|
},
|
|
});
|
|
for (const [upstreamOrder, account] of next.entries()) {
|
|
await tx.tbankAccount.upsert({
|
|
where: {
|
|
userId_revision_accountId: {
|
|
userId,
|
|
revision,
|
|
accountId: account.accountId,
|
|
},
|
|
},
|
|
create: { userId, revision, ...account, upstreamOrder },
|
|
update: { ...account, upstreamOrder },
|
|
});
|
|
}
|
|
await tx.tbankConnection.update({
|
|
where: { userId_revision: { userId, revision } },
|
|
data: {
|
|
membershipAt: fetchedAt,
|
|
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
|
},
|
|
});
|
|
if (completeWork) {
|
|
await tx.tbankSyncWork.update({
|
|
where: { id: attempt.id },
|
|
data: { state: 'complete', progress: { accounts: supported.length } },
|
|
});
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const claimed = await tx.tbankSyncWork.updateMany({
|
|
where: { ...attempt, state: 'running' },
|
|
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
|
});
|
|
if (!claimed.count) return false;
|
|
await tx.tbankConnection.update({
|
|
where: {
|
|
userId_revision: {
|
|
userId: attempt.userId,
|
|
revision: attempt.revision,
|
|
},
|
|
},
|
|
data: { invalidToken: true },
|
|
});
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// This boundary neither decrypts credentials nor starts discovery/sync.
|
|
// Missing membership is distinct from a successfully observed empty list.
|
|
async local(userId: string, accountId?: string) {
|
|
const connection = await this.prisma.tbankConnection.findUnique({
|
|
where: { userId },
|
|
select: {
|
|
userId: true,
|
|
revision: true,
|
|
invalidToken: true,
|
|
membershipAt: true,
|
|
membershipRevision: true,
|
|
accounts: { orderBy: { upstreamOrder: 'asc' } },
|
|
},
|
|
});
|
|
if (!connection) {
|
|
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
|
}
|
|
if (connection.invalidToken) {
|
|
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
|
}
|
|
if (
|
|
connection.membershipAt &&
|
|
accountId &&
|
|
accountId !== 'all' &&
|
|
!connection.accounts.some((account) => account.accountId === accountId)
|
|
) {
|
|
throw new NotFoundException();
|
|
}
|
|
return connection;
|
|
}
|
|
}
|