diff --git a/app/api/remittance/build/route.ts b/app/api/remittance/build/route.ts index d33fb8c5..069cfbdd 100644 --- a/app/api/remittance/build/route.ts +++ b/app/api/remittance/build/route.ts @@ -22,6 +22,7 @@ import { NextRequest } from 'next/server'; import { requireAuth } from '@/lib/session'; import { jsonSuccess, jsonError } from '@/lib/api/types'; +import { validateMessage } from '@/utils/validation'; import { TransactionBuilder, Operation, @@ -40,6 +41,7 @@ interface BuildRemittanceRequest { currency?: string; recipientAddress: string; memo?: string; + message?: string; } interface BuildRemittanceResponse { @@ -53,7 +55,7 @@ interface BuildRemittanceResponse { // ── Validation ──────────────────────────────────────────────────────────────── -function validateBuildRequest(body: unknown): BuildRemittanceRequest { +export function validateBuildRequest(body: unknown): BuildRemittanceRequest { if (!body || typeof body !== 'object') { throw new Error('Request body must be a JSON object'); } @@ -90,11 +92,21 @@ function validateBuildRequest(body: unknown): BuildRemittanceRequest { } } + // Validate message (optional) — cap at 2000 characters + const message = typeof o.message === 'string' ? o.message.trim() : undefined; + if (message) { + const msgError = validateMessage(message); + if (msgError) { + throw new Error(msgError.message); + } + } + return { amount: o.amount, currency, recipientAddress, memo, + message, }; } diff --git a/app/api/v1/remittance/emergency/build/route.ts b/app/api/v1/remittance/emergency/build/route.ts index 1a58a4e5..38ccaf83 100644 --- a/app/api/v1/remittance/emergency/build/route.ts +++ b/app/api/v1/remittance/emergency/build/route.ts @@ -3,6 +3,7 @@ import { StellarTransactionBuilder } from '../../../../../../services/transactio import { getSession } from '../../../../../../lib/session'; import { PolicyService } from '../../../../../../services/policy-service'; import { EventStorageService } from '../../../../../../services/event-storage-service'; +import { validateMessage } from '../../../../../../utils/validation'; export const dynamic = 'force-dynamic'; @@ -17,7 +18,7 @@ export async function POST(req: NextRequest) { } const body = await req.json(); - const { destinationAccount, amount, assetCode, assetIssuer, memo } = body; + const { destinationAccount, amount, assetCode, assetIssuer, memo, message } = body; if (!destinationAccount || !amount) { return NextResponse.json( @@ -42,6 +43,17 @@ export async function POST(req: NextRequest) { ); } + // Validate message (optional) — cap at 2000 characters + if (message) { + const msgError = validateMessage(message); + if (msgError) { + return NextResponse.json( + { error: 'Bad Request', message: msgError.message }, + { status: 400 } + ); + } + } + const builder = new StellarTransactionBuilder(); const xdr = await builder.buildEmergencyTransfer({ sourceAccount: session.address, diff --git a/tests/unit/remittance/build-validation.test.ts b/tests/unit/remittance/build-validation.test.ts index 27f19cfb..cedb317a 100644 --- a/tests/unit/remittance/build-validation.test.ts +++ b/tests/unit/remittance/build-validation.test.ts @@ -12,6 +12,7 @@ */ import { describe, it, expect, vi } from 'vitest'; +import { validateMessage } from '@/utils/validation'; vi.mock('next/server', () => ({ NextRequest: class NextRequest { @@ -118,3 +119,42 @@ describe('validateBuildRequest – memo byte-length validation', () => { expect(result.memo).toBe(memo); }); }); + +describe('validateMessage', () => { + it('returns null for undefined message', () => { + expect(validateMessage(undefined)).toBeNull(); + }); + + it('returns null for empty string message', () => { + expect(validateMessage('')).toBeNull(); + }); + + it('accepts message within 2000 characters', () => { + const msg = 'A'.repeat(2000); + expect(validateMessage(msg)).toBeNull(); + }); + + it('rejects message exceeding 2000 characters', () => { + const msg = 'A'.repeat(2001); + const error = validateMessage(msg); + expect(error).not.toBeNull(); + expect(error!.code).toBe('INVALID_MESSAGE'); + expect(error!.message).toBe('Message must not exceed 2000 characters'); + expect(error!.field).toBe('message'); + }); + + it('accepts message exactly 2000 characters', () => { + const msg = 'B'.repeat(2000); + expect(validateMessage(msg)).toBeNull(); + }); + + it('handles multi-byte characters within 2000 chars', () => { + const msg = 'é'.repeat(1000); + expect(validateMessage(msg)).toBeNull(); + }); + + it('handles emoji within 2000 chars', () => { + const msg = '😀'.repeat(500); + expect(validateMessage(msg)).toBeNull(); + }); +}); diff --git a/types/emergency-transfer.ts b/types/emergency-transfer.ts index e524dfc2..4ddf61f7 100644 --- a/types/emergency-transfer.ts +++ b/types/emergency-transfer.ts @@ -4,6 +4,7 @@ export interface EmergencyTransferRequest { amount: string; // Amount in base units (e.g., stroops for XLM) recipientAddress: string; // Stellar public key (G...) memo?: string; // Optional custom memo (max 28 bytes) + message?: string; // Optional user-facing message (max 2000 chars) assetCode?: string; // Optional asset code (default: XLM) assetIssuer?: string; // Required if assetCode is not XLM } @@ -95,6 +96,7 @@ export enum EmergencyTransferErrorCode { INVALID_RECIPIENT = 'INVALID_RECIPIENT', INVALID_ASSET = 'INVALID_ASSET', INVALID_MEMO = 'INVALID_MEMO', + INVALID_MESSAGE = 'INVALID_MESSAGE', // Limit errors AMOUNT_EXCEEDS_LIMIT = 'AMOUNT_EXCEEDS_LIMIT', diff --git a/utils/validation.ts b/utils/validation.ts index 8327401f..d99eee9d 100644 --- a/utils/validation.ts +++ b/utils/validation.ts @@ -187,6 +187,26 @@ export function validateAssetIssuer( return validateRecipientAddress(assetIssuer); } +/** + * Validates a user-facing message is within 2000 characters + */ +export function validateMessage(message?: string): ValidationError | null { + // Message is optional + if (!message) { + return null; + } + + if (message.length > 2000) { + return { + code: EmergencyTransferErrorCode.INVALID_MESSAGE, + message: 'Message must not exceed 2000 characters', + field: 'message', + }; + } + + return null; +} + /** * Validates entire emergency transfer request */ @@ -219,5 +239,9 @@ export function validateEmergencyTransferRequest(request: { const memoError = validateMemo(request.memo); if (memoError) errors.push(memoError); + // Validate message + const messageError = validateMessage(request.message); + if (messageError) errors.push(messageError); + return errors; }