From 05b2b29878c7185012cd97e7c63e616356e4a92e Mon Sep 17 00:00:00 2001 From: Gabriel Cafe Date: Fri, 21 Aug 2026 09:54:02 -0300 Subject: [PATCH] fix: route splitRelease response through toPublicPayments EscrowController's fund/findOne/release/refund all wrap their result in toPublicEscrow before it reaches an HTTP client. splitRelease was the one exception, returning the raw Payment[] entities straight from the ORM with no mapping step. Payment doesn't carry anything as sensitive as Escrow.metadata today, so this wasn't an active leak, but it left the same controller inconsistent with the pattern every other endpoint follows -- any field later added to Payment for internal bookkeeping would leak by default here with nothing to catch it in review. Adds toPublicPayment/toPublicPayments as a thin passthrough today, matching toPublicEscrow's shape, and routes splitRelease through it. Fixes #91 --- src/escrow/escrow.controller.ts | 7 ++- src/escrow/payment-response.mapper.spec.ts | 52 ++++++++++++++++++++++ src/escrow/payment-response.mapper.ts | 18 ++++++++ 3 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 src/escrow/payment-response.mapper.spec.ts create mode 100644 src/escrow/payment-response.mapper.ts diff --git a/src/escrow/escrow.controller.ts b/src/escrow/escrow.controller.ts index 8387138..16683bb 100644 --- a/src/escrow/escrow.controller.ts +++ b/src/escrow/escrow.controller.ts @@ -5,6 +5,7 @@ import { FundEscrowDto } from './dto/fund-escrow.dto'; import { ReleaseEscrowDto } from './dto/release-escrow.dto'; import { SplitReleaseDto } from './dto/split-release.dto'; import { toPublicEscrow } from './escrow-response.mapper'; +import { toPublicPayments } from './payment-response.mapper'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; @ApiTags('escrow') @@ -37,8 +38,10 @@ export class EscrowController { @Idempotent('escrow.splitRelease') @Post(':id/split-release') - splitRelease(@Param('id') id: string, @Body() dto: SplitReleaseDto) { - return this.escrowService.splitRelease(id, dto.recipients); + async splitRelease(@Param('id') id: string, @Body() dto: SplitReleaseDto) { + return toPublicPayments( + await this.escrowService.splitRelease(id, dto.recipients), + ); } @Idempotent('escrow.refund') diff --git a/src/escrow/payment-response.mapper.spec.ts b/src/escrow/payment-response.mapper.spec.ts new file mode 100644 index 0000000..74d6a9b --- /dev/null +++ b/src/escrow/payment-response.mapper.spec.ts @@ -0,0 +1,52 @@ +import { Payment } from '../common/entities'; +import { AssetType, PaymentStatus } from '../common/enums'; +import { toPublicPayment, toPublicPayments } from './payment-response.mapper'; + +function makePayment(overrides: Partial = {}): Payment { + return { + id: 'pay_1', + escrow: null as unknown as Payment['escrow'], + escrowId: 'esc_1', + recipient: null, + recipientId: 'user_1', + recipientAddress: 'GRECIPIENT', + amount: '50.0000000', + asset: AssetType.USDC, + splitPercentage: '50.00', + status: PaymentStatus.PENDING, + txHash: null, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +describe('toPublicPayment', () => { + it('preserves every field unchanged', () => { + const payment = makePayment(); + + const publicPayment = toPublicPayment(payment); + + expect(publicPayment).toMatchObject({ + id: 'pay_1', + escrowId: 'esc_1', + recipientId: 'user_1', + amount: '50.0000000', + status: PaymentStatus.PENDING, + }); + }); +}); + +describe('toPublicPayments', () => { + it('maps every payment in the array', () => { + const payments = [ + makePayment({ id: 'pay_1' }), + makePayment({ id: 'pay_2' }), + ]; + + const publicPayments = toPublicPayments(payments); + + expect(publicPayments).toHaveLength(2); + expect(publicPayments.map((p) => p.id)).toEqual(['pay_1', 'pay_2']); + }); +}); diff --git a/src/escrow/payment-response.mapper.ts b/src/escrow/payment-response.mapper.ts new file mode 100644 index 0000000..76e39cf --- /dev/null +++ b/src/escrow/payment-response.mapper.ts @@ -0,0 +1,18 @@ +import { Payment } from '../common/entities'; + +export type PublicPayment = Payment; + +/** + * Thin passthrough today — Payment doesn't carry anything as sensitive as + * Escrow.metadata yet. Exists so splitRelease follows the same + * controller-boundary mapping pattern as every other escrow endpoint + * (see toPublicEscrow), so a future internal-only field added to Payment + * has somewhere to be stripped instead of leaking by default. + */ +export function toPublicPayment(payment: Payment): PublicPayment { + return payment; +} + +export function toPublicPayments(payments: Payment[]): PublicPayment[] { + return payments.map(toPublicPayment); +}