From d4ad36164825b69f4f09c353c7e79fdbd678b6af Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Wed, 2 Sep 2026 14:07:23 +0000 Subject: [PATCH 1/3] fix(api): standardize error response envelope --- backend/src/config/swagger.ts | 31 ++-- backend/src/controllers/stream.controller.ts | 163 ++++++------------- backend/src/controllers/stream/cancel.ts | 23 +-- backend/src/controllers/user.controller.ts | 25 +-- backend/src/middleware/error.middleware.ts | 37 ++--- backend/src/routes/v1/streams/withdraw.ts | 22 +-- backend/src/types/api-error.ts | 33 ++++ backend/swagger/flowfi.openapi.json | 41 +++-- backend/tests/cancel.controller.test.ts | 1 + backend/tests/error.middleware.test.ts | 8 +- backend/tests/stream.controller.test.ts | 16 +- backend/tests/user.controller.test.ts | 20 ++- backend/tests/withdraw.handler.test.ts | 1 + docs/api/flowfi.postman_collection.json | 2 +- 14 files changed, 193 insertions(+), 230 deletions(-) create mode 100644 backend/src/types/api-error.ts diff --git a/backend/src/config/swagger.ts b/backend/src/config/swagger.ts index 19c8f0b9..6be58260 100644 --- a/backend/src/config/swagger.ts +++ b/backend/src/config/swagger.ts @@ -481,27 +481,20 @@ See [Sandbox Mode Documentation](../docs/SANDBOX_MODE.md) for details.`, type: 'object', properties: { error: { - type: 'string', - description: 'Error message', - example: 'Resource not found', - }, - code: { - type: 'string', - description: 'Error code', - example: 'NOT_FOUND', - }, - message: { - type: 'string', - nullable: true, - description: 'Human-readable detail (present on many error responses)', - }, - details: { - type: 'array', - nullable: true, - description: 'Structured validation issues (zod) when the error is a 400', - items: { type: 'object' }, + type: 'object', + required: ['code', 'message'], + properties: { + code: { type: 'string', example: 'NOT_FOUND' }, + message: { type: 'string', example: 'Resource not found' }, + details: { + type: 'array', + description: 'Structured validation issues when applicable', + items: { type: 'object' }, + }, + }, }, }, + required: ['error'], }, }, }, diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 1d273a79..1a745060 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -18,6 +18,7 @@ import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE, } from "../routes/v1/events.routes.js"; +import { sendApiError } from "../types/api-error.js"; const DEFAULT_STREAM_PAGE_SIZE = 20; const MAX_STREAM_PAGE_SIZE = 100; @@ -112,45 +113,38 @@ export const createStream = async (req: Request, res: Response) => { try { const callerPublicKey = (req as AuthenticatedRequest).user?.publicKey; if (!callerPublicKey) { - return res.status(401).json({ error: 'Unauthorized', message: 'Authentication required' }); + return sendApiError(res, 401, 'UNAUTHORIZED', 'Authentication required'); } const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body; // Issue #809: validate identity fields before any DB write. if (typeof sender !== 'string' || sender.length === 0) { - return res.status(400).json({ error: 'Invalid sender: must be a non-empty string' }); + return sendApiError(res, 400, 'INVALID_SENDER', 'Invalid sender: must be a non-empty string'); } if (typeof recipient !== 'string' || recipient.length === 0) { - return res.status(400).json({ error: 'Invalid recipient: must be a non-empty string' }); + return sendApiError(res, 400, 'INVALID_RECIPIENT', 'Invalid recipient: must be a non-empty string'); } if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) { - return res.status(400).json({ error: 'Invalid tokenAddress: must be a non-empty string' }); + return sendApiError(res, 400, 'INVALID_TOKEN_ADDRESS', 'Invalid tokenAddress: must be a non-empty string'); } // Issue #809: the authenticated wallet may only create/modify streams it owns. // Without this, any logged-in wallet could POST an arbitrary `sender` and have // it persisted, or flip another owner's cancelled stream back to active. if (sender !== callerPublicKey) { - return res.status(403).json({ - error: 'Forbidden', - message: 'sender must match the authenticated wallet', - }); + return sendApiError(res, 403, 'FORBIDDEN', 'sender must match the authenticated wallet'); } const parsedStreamId = parseStreamId(streamId); const parsedStartTime = Number.parseInt(startTime, 10); if (parsedStreamId === null) { - return res - .status(400) - .json({ error: "Invalid streamId: must be a valid integer" }); + return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId: must be a valid integer'); } if (!Number.isFinite(parsedStartTime) || parsedStartTime < 0) { - return res - .status(400) - .json({ error: "Invalid startTime: must be a non-negative integer" }); + return sendApiError(res, 400, 'INVALID_START_TIME', 'Invalid startTime: must be a non-negative integer'); } // Presence/format validation happens here, before any BigInt coercion, @@ -169,21 +163,17 @@ export const createStream = async (req: Request, res: Response) => { ); } catch (validationError) { if (validationError instanceof StreamValidationError) { - return res.status(400).json({ error: validationError.message }); + return sendApiError(res, 400, 'INVALID_REQUEST', validationError.message); } throw validationError; } if (parsedRatePerSecond <= 0n) { - return res - .status(400) - .json({ error: "Invalid ratePerSecond: must be greater than zero" }); + return sendApiError(res, 400, 'INVALID_RATE', 'Invalid ratePerSecond: must be greater than zero'); } if (parsedDepositedAmount <= 0n) { - return res - .status(400) - .json({ error: "Invalid depositedAmount: must be greater than zero" }); + return sendApiError(res, 400, 'INVALID_DEPOSITED_AMOUNT', 'Invalid depositedAmount: must be greater than zero'); } const endTime = @@ -195,10 +185,7 @@ export const createStream = async (req: Request, res: Response) => { // overwriting someone else's (e.g. cancelled) stream. const existing = await prisma.stream.findUnique({ where: { streamId: parsedStreamId } }); if (existing && existing.sender !== callerPublicKey) { - return res.status(403).json({ - error: 'Forbidden', - message: 'Cannot modify a stream owned by another wallet', - }); + return sendApiError(res, 403, 'FORBIDDEN', 'Cannot modify a stream owned by another wallet'); } const stream = await prisma.stream.upsert({ @@ -229,12 +216,10 @@ export const createStream = async (req: Request, res: Response) => { error instanceof TypeError ) { logger.error("Numeric parsing error in createStream:", error); - return res - .status(400) - .json({ error: "Invalid numeric values in request body" }); + return sendApiError(res, 400, 'INVALID_NUMERIC_VALUES', 'Invalid numeric values in request body'); } logger.error("Error creating/upserting stream:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'A technical error occurred. Please try again later.'); } }; @@ -263,10 +248,7 @@ export const listStreams = async (req: Request, res: Response) => { if (typeof status === "string") { const validStatuses = ["active", "cancelled", "completed", "paused"]; if (!validStatuses.includes(status)) { - return res.status(400).json({ - error: "Invalid status parameter", - message: `status must be one of: ${validStatuses.join(", ")}`, - }); + return sendApiError(res, 400, "INVALID_STATUS", `status must be one of: ${validStatuses.join(", ")}`); } // Map status to database conditions @@ -345,7 +327,7 @@ export const listStreams = async (req: Request, res: Response) => { }); } catch (error) { logger.error("Error listing streams:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -359,7 +341,7 @@ export const getStream = async (req: Request, res: Response) => { : req.params.streamId; const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: "Invalid streamId parameter" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId parameter"); } const stream = await prisma.stream.findUnique({ @@ -377,7 +359,7 @@ export const getStream = async (req: Request, res: Response) => { // Fallback: try live RPC const chainStream = await getStreamFromChain(parsedStreamId); if (!chainStream) { - return res.status(404).json({ error: "Stream not found" }); + return sendApiError(res, 404, "NOT_FOUND", "Stream not found"); } return res.status(200).json({ ...chainStream, source: "chain" }); } @@ -395,7 +377,7 @@ export const getStream = async (req: Request, res: Response) => { return res.status(200).json(stream); } catch (error) { logger.error("Error fetching stream:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -409,7 +391,7 @@ export const getStreamEvents = async (req: Request, res: Response) => { : req.params.streamId; const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: "Invalid streamId parameter" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId parameter"); } const rawLimit = req.query["limit"]; @@ -456,10 +438,7 @@ export const getStreamEvents = async (req: Request, res: Response) => { "ADMIN_TRANSFERRED", ]; if (!validEventTypes.includes(eventType)) { - return res.status(400).json({ - error: "Invalid eventType parameter", - message: `eventType must be one of: ${validEventTypes.join(", ")}`, - }); + return sendApiError(res, 400, "INVALID_EVENT_TYPE", `eventType must be one of: ${validEventTypes.join(", ")}`); } whereClause.eventType = eventType; } @@ -485,7 +464,7 @@ export const getStreamEvents = async (req: Request, res: Response) => { return res.status(200).json({ data: events, total, hasMore }); } catch (error) { logger.error("Error fetching stream events:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -499,7 +478,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { : req.params.streamId; const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: "Invalid streamId parameter" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId parameter"); } const atQuery = req.query.at as string | undefined; @@ -508,10 +487,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { if (atQuery !== undefined) { requestedAt = Number.parseInt(atQuery, 10); if (!Number.isFinite(requestedAt) || requestedAt < 0) { - return res.status(400).json({ - error: "Invalid query parameter", - message: "'at' must be a non-negative Unix timestamp in seconds", - }); + return sendApiError(res, 400, "INVALID_QUERY_PARAMETER", "'at' must be a non-negative Unix timestamp in seconds"); } } @@ -545,7 +521,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { source: "chain", }); } - return res.status(404).json({ error: "Stream not found" }); + return sendApiError(res, 404, "NOT_FOUND", "Stream not found"); } // If DB data is stale, use live RPC @@ -571,7 +547,7 @@ export const getStreamClaimableAmount = async (req: Request, res: Response) => { return res.status(200).json(result); } catch (error) { logger.error("Error calculating stream claimable amount:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -587,7 +563,7 @@ export const getUserStreamSummary = async ( ? req.params.address[0] : (req.params.address ?? "").trim(); if (!address) { - return res.status(400).json({ error: "Address is required" }); + return sendApiError(res, 400, "INVALID_ADDRESS", "Address is required"); } const nowMs = Date.now(); @@ -691,7 +667,7 @@ export const getUserStreamSummary = async ( return res.status(200).json(summary); } catch (error) { logger.error("Error fetching user stream summary:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -712,42 +688,38 @@ export const topUpStreamHandler = async (req: Request, res: Response) => { : req.params.streamId, ); if (streamId === null) { - return res.status(400).json({ error: "Invalid streamId" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId"); } const parsed = topUpBodySchema.safeParse(req.body); if (!parsed.success) { - return res - .status(400) - .json({ error: "Validation error", details: parsed.error.issues }); + return sendApiError(res, 400, "VALIDATION_ERROR", "Request validation failed", parsed.error.issues); } const amount = BigInt(parsed.data.amount); if (amount <= 0n) { - return res.status(400).json({ error: "amount must be a positive integer" }); + return sendApiError(res, 400, "INVALID_AMOUNT", "amount must be a positive integer"); } const callerAddress = (req as AuthenticatedRequest).user?.publicKey; if (!callerAddress) { - return res.status(401).json({ error: "Unauthorized" }); + return sendApiError(res, 401, "UNAUTHORIZED", "Authentication required"); } try { const stream = await prisma.stream.findUnique({ where: { streamId } }); if (!stream) { - return res.status(404).json({ error: "Stream not found" }); + return sendApiError(res, 404, "NOT_FOUND", "Stream not found"); } if (stream.sender !== callerAddress) { - return res - .status(403) - .json({ error: "Only the stream sender may top up this stream" }); + return sendApiError(res, 403, "FORBIDDEN", "Only the stream sender may top up this stream"); } if (!stream.isActive) { - return res.status(409).json({ error: 'Conflict', message: 'Cannot top up an inactive stream' }); + return sendApiError(res, 409, "CONFLICT", "Cannot top up an inactive stream"); } if (stream.isPaused) { - return res.status(409).json({ error: 'Conflict', message: 'Cannot top up a paused stream' }); + return sendApiError(res, 409, "CONFLICT", "Cannot top up a paused stream"); } const txHash = await topUpStream(streamId, amount, callerAddress); @@ -772,7 +744,7 @@ export const topUpStreamHandler = async (req: Request, res: Response) => { .json({ streamId, txHash, depositedAmount: updatedStream!.depositedAmount }); } catch (error: any) { logger.error(`[topUp] stream=${streamId} error:`, error); - return res.status(400).json({ error: 'Failed to top up stream on chain', message: error.message ?? 'Unknown error' }); + return sendApiError(res, 400, "TOP_UP_FAILED", "Failed to top up stream on chain"); } }; @@ -785,9 +757,7 @@ export const pauseStream = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; if (!authReq.user) { - return res - .status(401) - .json({ error: "Unauthorized", message: "Authentication required" }); + return sendApiError(res, 401, "UNAUTHORIZED", "Authentication required"); } const streamIdParam = Array.isArray(req.params.streamId) @@ -795,7 +765,7 @@ export const pauseStream = async (req: Request, res: Response) => { : req.params.streamId; const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: "Invalid streamId parameter" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId parameter"); } // Fetch the stream from database @@ -804,31 +774,22 @@ export const pauseStream = async (req: Request, res: Response) => { }); if (!stream) { - return res.status(404).json({ error: "Stream not found" }); + return sendApiError(res, 404, "NOT_FOUND", "Stream not found"); } // Verify the caller is the stream sender if (stream.sender !== authReq.user.publicKey) { - return res.status(403).json({ - error: "Forbidden", - message: "Only the stream sender can pause the stream", - }); + return sendApiError(res, 403, "FORBIDDEN", "Only the stream sender can pause the stream"); } // Check if stream is already paused if (stream.isPaused) { - return res.status(409).json({ - error: "Conflict", - message: "Stream is already paused", - }); + return sendApiError(res, 409, "CONFLICT", "Stream is already paused"); } // Check if stream is still active if (!stream.isActive) { - return res.status(409).json({ - error: "Conflict", - message: "Cannot pause an inactive stream", - }); + return sendApiError(res, 409, "CONFLICT", "Cannot pause an inactive stream"); } try { @@ -853,17 +814,11 @@ export const pauseStream = async (req: Request, res: Response) => { `Soroban pause failed for stream ${parsedStreamId}:`, sorobanError, ); - return res.status(400).json({ - error: "Failed to pause stream on chain", - message: - sorobanError instanceof Error - ? sorobanError.message - : "Unknown error", - }); + return sendApiError(res, 400, "PAUSE_FAILED", "Failed to pause stream on chain"); } } catch (error) { logger.error("Error pausing stream:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; @@ -876,9 +831,7 @@ export const resumeStream = async (req: Request, res: Response) => { const authReq = req as AuthenticatedRequest; if (!authReq.user) { - return res - .status(401) - .json({ error: "Unauthorized", message: "Authentication required" }); + return sendApiError(res, 401, "UNAUTHORIZED", "Authentication required"); } const streamIdParam = Array.isArray(req.params.streamId) @@ -886,7 +839,7 @@ export const resumeStream = async (req: Request, res: Response) => { : req.params.streamId; const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: "Invalid streamId parameter" }); + return sendApiError(res, 400, "INVALID_STREAM_ID", "Invalid streamId parameter"); } // Fetch the stream from database @@ -895,23 +848,17 @@ export const resumeStream = async (req: Request, res: Response) => { }); if (!stream) { - return res.status(404).json({ error: "Stream not found" }); + return sendApiError(res, 404, "NOT_FOUND", "Stream not found"); } // Verify the caller is the stream sender if (stream.sender !== authReq.user.publicKey) { - return res.status(403).json({ - error: "Forbidden", - message: "Only the stream sender can resume the stream", - }); + return sendApiError(res, 403, "FORBIDDEN", "Only the stream sender can resume the stream"); } // Check if stream is paused if (!stream.isPaused) { - return res.status(409).json({ - error: "Conflict", - message: "Stream is not paused", - }); + return sendApiError(res, 409, "CONFLICT", "Stream is not paused"); } try { @@ -936,16 +883,10 @@ export const resumeStream = async (req: Request, res: Response) => { `Soroban resume failed for stream ${parsedStreamId}:`, sorobanError, ); - return res.status(400).json({ - error: "Failed to resume stream on chain", - message: - sorobanError instanceof Error - ? sorobanError.message - : "Unknown error", - }); + return sendApiError(res, 400, "RESUME_FAILED", "Failed to resume stream on chain"); } } catch (error) { logger.error("Error resuming stream:", error); - return res.status(500).json({ error: "Internal server error" }); + return sendApiError(res, 500, "INTERNAL_SERVER_ERROR", "A technical error occurred. Please try again later."); } }; diff --git a/backend/src/controllers/stream/cancel.ts b/backend/src/controllers/stream/cancel.ts index eb16d62a..bd063619 100644 --- a/backend/src/controllers/stream/cancel.ts +++ b/backend/src/controllers/stream/cancel.ts @@ -5,6 +5,7 @@ import * as sorobanService from '../../services/sorobanService.js'; import type { AuthenticatedRequest } from '../../types/auth.types.js'; import * as streamRepository from '../../repositories/stream.repository.js'; import { parseStreamId } from '../../lib/stream-id.js'; +import { sendApiError } from '../../types/api-error.js'; /** * @openapi @@ -53,12 +54,12 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon const streamId = Array.isArray(streamIdParam) ? streamIdParam[0] : streamIdParam; if (!streamId) { - return res.status(400).json({ error: 'Missing streamId parameter' }); + return sendApiError(res, 400, 'MISSING_STREAM_ID', 'Missing streamId parameter'); } const parsedStreamId = parseStreamId(streamId); if (parsedStreamId === null) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId parameter'); } // 1. Fetch stream from DB @@ -67,30 +68,24 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon }); if (!stream) { - return res.status(404).json({ error: 'Stream not found' }); + return sendApiError(res, 404, 'NOT_FOUND', 'Stream not found'); } // 2. Validate caller is sender if (stream.sender !== callerAddress) { - return res.status(403).json({ - error: 'Forbidden', - message: 'Only the sender can cancel the stream' - }); + return sendApiError(res, 403, 'FORBIDDEN', 'Only the sender can cancel the stream'); } // 3. Check status if (!stream.isActive) { - return res.status(409).json({ - error: 'Conflict', - message: 'Stream is already cancelled or completed' - }); + return sendApiError(res, 409, 'CONFLICT', 'Stream is already cancelled or completed'); } // 4. Call Soroban service to cancel on-chain const secretKey = process.env.KEEPER_SECRET_KEY; if (!secretKey) { logger.error('[CancelStream] KEEPER_SECRET_KEY not configured'); - return res.status(500).json({ error: 'Internal server error', message: 'Backend not configured for on-chain calls' }); + return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'Backend not configured for on-chain calls'); } const txHash = await sorobanService.cancelStream(parsedStreamId, secretKey); @@ -107,8 +102,8 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon } catch (error) { logger.error('Error cancelling stream:', error); if (error instanceof Error && error.message.includes('Simulation failed')) { - return res.status(400).json({ error: 'Transaction simulation failed', message: error.message }); + return sendApiError(res, 400, 'TRANSACTION_SIMULATION_FAILED', error.message); } - return res.status(500).json({ error: 'Internal server error' }); + return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'A technical error occurred. Please try again later.'); } }; diff --git a/backend/src/controllers/user.controller.ts b/backend/src/controllers/user.controller.ts index 439db5d8..19ec63ec 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -11,6 +11,7 @@ import { MAX_EVENTS_PAGE_SIZE, } from "../routes/v1/events.routes.js"; import * as exportService from "../services/export.service.js"; +import { sendApiError } from "../types/api-error.js"; /** * Public shape of a Stream, used when embedding streams inside a public @@ -104,12 +105,10 @@ export const getUser = async ( try { const { publicKey } = req.params; if (typeof publicKey !== "string") { - return res.status(400).json({ error: "Invalid publicKey parameter" }); + return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid publicKey parameter"); } if (!STELLAR_PUBLIC_KEY_REGEX.test(publicKey)) { - return res - .status(400) - .json({ error: "Invalid Stellar public key format" }); + return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key format"); } const user = await prisma.user.findUnique({ @@ -118,7 +117,7 @@ export const getUser = async ( }); if (!user) { - return res.status(404).json({ error: "User not found" }); + return sendApiError(res, 404, "NOT_FOUND", "User not found"); } return res.status(200).json(user); @@ -138,12 +137,10 @@ export const getUserEvents = async ( try { const { publicKey } = req.params; if (typeof publicKey !== "string") { - return res.status(400).json({ error: "Invalid publicKey parameter" }); + return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid publicKey parameter"); } if (!STELLAR_PUBLIC_KEY_REGEX.test(publicKey)) { - return res - .status(400) - .json({ error: "Invalid Stellar public key format" }); + return sendApiError(res, 400, "INVALID_PUBLIC_KEY", "Invalid Stellar public key format"); } const rawLimit = req.query["limit"]; @@ -255,7 +252,7 @@ export const exportTransactions = async ( : addressParam; if (!address || !STELLAR_PUBLIC_KEY_REGEX.test(address)) { - return res.status(400).json({ error: "Invalid Stellar address" }); + return sendApiError(res, 400, "INVALID_ADDRESS", "Invalid Stellar address"); } const format = (req.query.format as string) || "csv"; @@ -270,15 +267,11 @@ export const exportTransactions = async ( const tokenAddress = (req.query.tokenAddress as string | null) || null; if (!["csv", "json"].includes(format)) { - return res - .status(400) - .json({ error: "Invalid format. Must be csv or json" }); + return sendApiError(res, 400, "INVALID_FORMAT", "Invalid format. Must be csv or json"); } if (!["incoming", "outgoing", "all"].includes(direction)) { - return res.status(400).json({ - error: "Invalid direction. Must be incoming, outgoing, or all", - }); + return sendApiError(res, 400, "INVALID_DIRECTION", "Invalid direction. Must be incoming, outgoing, or all"); } const options: exportService.ExportOptions = { diff --git a/backend/src/middleware/error.middleware.ts b/backend/src/middleware/error.middleware.ts index af90505b..02404380 100644 --- a/backend/src/middleware/error.middleware.ts +++ b/backend/src/middleware/error.middleware.ts @@ -2,6 +2,7 @@ import type { Request, Response, NextFunction } from 'express'; import { Prisma } from '../generated/prisma/index.js'; import { ZodError, type ZodIssue } from 'zod'; import logger from '../logger.js'; +import { ApiError, sendApiError } from '../types/api-error.js'; /** * Global error handler middleware @@ -18,15 +19,16 @@ export const errorHandler = ( return next(err); } - // Handle Zod Validation Errors if (err instanceof ZodError) { - return res.status(400).json({ - error: 'Validation Error', - details: err.issues.map((e: ZodIssue) => ({ - path: e.path.join('.'), - message: e.message - })) - }); + return sendApiError(res, 400, 'VALIDATION_ERROR', 'Request validation failed', err.issues.map((e: ZodIssue) => ({ + path: e.path.join('.'), + message: e.message, + code: e.code, + }))); + } + + if (err instanceof ApiError) { + return sendApiError(res, err.statusCode, err.code, err.message, err.details); } // Handle Prisma Errors @@ -34,27 +36,18 @@ export const errorHandler = ( // Unique constraint violation if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2002') { const target = ((err as Prisma.PrismaClientKnownRequestError).meta?.target as string[])?.join(', ') || 'field'; - return res.status(409).json({ - error: 'Conflict Error', - message: `Record with this ${target} already exists.` - }); + return sendApiError(res, 409, 'CONFLICT', `Record with this ${target} already exists.`); } // Record not found if ((err as Prisma.PrismaClientKnownRequestError).code === 'P2025') { - return res.status(404).json({ - error: 'Not Found', - message: (err as Prisma.PrismaClientKnownRequestError).message || 'The requested record was not found.' - }); + return sendApiError(res, 404, 'NOT_FOUND', 'The requested record was not found.'); } } // Default Error const statusCode = (err instanceof Error && (err as any).status) || (err instanceof Error && (err as any).statusCode) || 500; - const message = err instanceof Error ? err.message : 'Internal Server Error'; - - return res.status(statusCode).json({ - error: statusCode === 500 ? 'Internal Server Error' : 'Error', - message: statusCode === 500 ? 'A technical error occurred. Please try again later.' : message - }); + const message = statusCode === 500 ? 'A technical error occurred. Please try again later.' : (err instanceof Error ? err.message : 'Request failed'); + const code = statusCode === 500 ? 'INTERNAL_SERVER_ERROR' : 'REQUEST_ERROR'; + return sendApiError(res, statusCode, code, message); }; diff --git a/backend/src/routes/v1/streams/withdraw.ts b/backend/src/routes/v1/streams/withdraw.ts index 45801099..bce2e76f 100644 --- a/backend/src/routes/v1/streams/withdraw.ts +++ b/backend/src/routes/v1/streams/withdraw.ts @@ -5,6 +5,7 @@ import { claimableAmountService } from '../../../services/claimable.service.js'; import { withdraw as sorobanWithdraw } from '../../../services/sorobanService.js'; import type { AuthenticatedRequest } from '../../../types/auth.types.js'; import { parseStreamId } from '../../../lib/stream-id.js'; +import { sendApiError } from '../../../types/api-error.js'; /** * @openapi @@ -51,7 +52,7 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) const parsedStreamId = parseStreamId(streamIdParam); if (parsedStreamId === null) { - return res.status(400).json({ error: 'Invalid streamId parameter' }); + return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId parameter'); } const stream = await prisma.stream.findUnique({ @@ -74,24 +75,18 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) }); if (!stream) { - return res.status(404).json({ error: 'Stream not found' }); + return sendApiError(res, 404, 'NOT_FOUND', 'Stream not found'); } // Verify the caller is the stream recipient if (stream.recipient !== req.user.publicKey) { - return res.status(403).json({ - error: 'Forbidden', - message: 'Only the stream recipient can withdraw from the stream', - }); + return sendApiError(res, 403, 'FORBIDDEN', 'Only the stream recipient can withdraw from the stream'); } const claimable = claimableAmountService.getClaimableAmount(stream); if (!claimable.actionable) { - return res.status(409).json({ - error: 'Conflict', - message: 'No claimable balance is currently available', - }); + return sendApiError(res, 409, 'CONFLICT', 'No claimable balance is currently available'); } try { @@ -167,13 +162,10 @@ export const withdrawHandler = async (req: AuthenticatedRequest, res: Response) }); } catch (sorobanError) { logger.error(`Soroban withdraw failed for stream ${parsedStreamId}:`, sorobanError); - return res.status(400).json({ - error: 'Failed to withdraw from stream on chain', - message: sorobanError instanceof Error ? sorobanError.message : 'Unknown error', - }); + return sendApiError(res, 400, 'WITHDRAWAL_FAILED', 'Failed to withdraw from stream on chain'); } } catch (error) { logger.error('Error withdrawing from stream:', error); - return res.status(500).json({ error: 'Internal server error' }); + return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'A technical error occurred. Please try again later.'); } }; diff --git a/backend/src/types/api-error.ts b/backend/src/types/api-error.ts new file mode 100644 index 00000000..d7cf3334 --- /dev/null +++ b/backend/src/types/api-error.ts @@ -0,0 +1,33 @@ +import type { Response } from "express"; + +export interface ApiErrorBody { + code: string; + message: string; + details?: unknown; +} + +export class ApiError extends Error { + readonly statusCode: number; + readonly code: string; + readonly details?: unknown; + + constructor(statusCode: number, code: string, message: string, details?: unknown) { + super(message); + this.name = "ApiError"; + this.statusCode = statusCode; + this.code = code; + this.details = details; + } +} + +export function sendApiError( + res: Response, + statusCode: number, + code: string, + message: string, + details?: unknown, +) { + const error: ApiErrorBody = { code, message }; + if (details !== undefined) error.details = details; + return res.status(statusCode).json({ error }); +} \ No newline at end of file diff --git a/backend/swagger/flowfi.openapi.json b/backend/swagger/flowfi.openapi.json index 245d7961..172465a0 100644 --- a/backend/swagger/flowfi.openapi.json +++ b/backend/swagger/flowfi.openapi.json @@ -828,29 +828,28 @@ "type": "object", "properties": { "error": { - "type": "string", - "description": "Error message", - "example": "Resource not found" - }, - "code": { - "type": "string", - "description": "Error code", - "example": "NOT_FOUND" - }, - "message": { - "type": "string", - "nullable": true, - "description": "Human-readable detail (present on many error responses)" - }, - "details": { - "type": "array", - "nullable": true, - "description": "Structured validation issues (zod) when the error is a 400", - "items": { - "type": "object" + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "string", + "example": "NOT_FOUND" + }, + "message": { + "type": "string", + "example": "Resource not found" + }, + "details": { + "type": "array", + "description": "Structured validation issues when applicable", + "items": { + "type": "object" + } + } } } - } + }, + "required": ["error"] } } }, diff --git a/backend/tests/cancel.controller.test.ts b/backend/tests/cancel.controller.test.ts index 5d88fbeb..56f325c2 100644 --- a/backend/tests/cancel.controller.test.ts +++ b/backend/tests/cancel.controller.test.ts @@ -53,6 +53,7 @@ describe('Cancel Stream Controller', () => { await cancelStreamHandler(req as AuthenticatedRequest, res as Response); expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: { code: 'NOT_FOUND', message: 'Stream not found' } }); }); it('should return 403 if caller is not sender', async () => { diff --git a/backend/tests/error.middleware.test.ts b/backend/tests/error.middleware.test.ts index a7f497fa..4e3eaff7 100644 --- a/backend/tests/error.middleware.test.ts +++ b/backend/tests/error.middleware.test.ts @@ -23,14 +23,18 @@ describe('Error Middleware', () => { const error = new ZodError([{ path: ['field'], message: 'invalid', code: 'custom' }]); errorHandler(error, req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Validation Error' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + error: expect.objectContaining({ code: 'VALIDATION_ERROR' }), + })); }); it('should handle Prisma P2002 error', () => { const error = new Prisma.PrismaClientKnownRequestError('Conflict', { code: 'P2002', clientVersion: '1.0', meta: { target: ['email'] } }); errorHandler(error, req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(409); - expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ error: 'Conflict Error' })); + expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ + error: expect.objectContaining({ code: 'CONFLICT' }), + })); }); it('should handle Prisma P2025 error', () => { diff --git a/backend/tests/stream.controller.test.ts b/backend/tests/stream.controller.test.ts index 4471655d..aedcee44 100644 --- a/backend/tests/stream.controller.test.ts +++ b/backend/tests/stream.controller.test.ts @@ -157,7 +157,9 @@ describe("Stream Controller", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('ratePerSecond') }) + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining('ratePerSecond') }), + }) ); }); @@ -167,7 +169,9 @@ describe("Stream Controller", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('depositedAmount') }) + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining('depositedAmount') }), + }) ); }); @@ -177,7 +181,9 @@ describe("Stream Controller", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('ratePerSecond') }) + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining('ratePerSecond') }), + }) ); }); @@ -187,7 +193,9 @@ describe("Stream Controller", () => { expect(res.status).toHaveBeenCalledWith(400); expect(res.status).not.toHaveBeenCalledWith(500); expect(res.json).toHaveBeenCalledWith( - expect.objectContaining({ error: expect.stringContaining('depositedAmount') }) + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining('depositedAmount') }), + }) ); }); }); diff --git a/backend/tests/user.controller.test.ts b/backend/tests/user.controller.test.ts index 6efeda19..a5ba173c 100644 --- a/backend/tests/user.controller.test.ts +++ b/backend/tests/user.controller.test.ts @@ -81,7 +81,9 @@ describe('User Controller', () => { await getUser(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid publicKey parameter' }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'INVALID_PUBLIC_KEY', message: 'Invalid publicKey parameter' }, + }); }); it('should return 400 if publicKey is malformed', async () => { @@ -90,7 +92,9 @@ describe('User Controller', () => { await getUser(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Stellar public key format' }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'INVALID_PUBLIC_KEY', message: 'Invalid Stellar public key format' }, + }); }); it('should return 404 if user not found', async () => { @@ -139,7 +143,9 @@ describe('User Controller', () => { await getUserEvents(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid publicKey parameter' }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'INVALID_PUBLIC_KEY', message: 'Invalid publicKey parameter' }, + }); }); it('should return 400 if publicKey is malformed', async () => { @@ -147,7 +153,9 @@ describe('User Controller', () => { await getUserEvents(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Stellar public key format' }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'INVALID_PUBLIC_KEY', message: 'Invalid Stellar public key format' }, + }); }); it('should return 400 if publicKey has wrong format (too short)', async () => { @@ -155,7 +163,9 @@ describe('User Controller', () => { await getUserEvents(req as Request, res as Response, next); expect(res.status).toHaveBeenCalledWith(400); - expect(res.json).toHaveBeenCalledWith({ error: 'Invalid Stellar public key format' }); + expect(res.json).toHaveBeenCalledWith({ + error: { code: 'INVALID_PUBLIC_KEY', message: 'Invalid Stellar public key format' }, + }); }); it('should return paginated events', async () => { diff --git a/backend/tests/withdraw.handler.test.ts b/backend/tests/withdraw.handler.test.ts index e4a55c62..2543664a 100644 --- a/backend/tests/withdraw.handler.test.ts +++ b/backend/tests/withdraw.handler.test.ts @@ -67,6 +67,7 @@ describe('Withdraw Handler', () => { (prisma.stream.findUnique as any).mockResolvedValue(null); await withdrawHandler(req as AuthenticatedRequest, res as Response); expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: { code: 'NOT_FOUND', message: 'Stream not found' } }); }); it('should return 403 if caller is not recipient', async () => { diff --git a/docs/api/flowfi.postman_collection.json b/docs/api/flowfi.postman_collection.json index f1aa3675..b5e3bcac 100644 --- a/docs/api/flowfi.postman_collection.json +++ b/docs/api/flowfi.postman_collection.json @@ -130,7 +130,7 @@ "code": 400, "_postman_previewlanguage": "json", "header": [{ "key": "Content-Type", "value": "application/json; charset=utf-8" }], - "body": "{\n \"message\": \"Validation failed\",\n \"errors\": [\n { \"code\": \"too_small\", \"path\": [\"sender\"], \"message\": \"Sender address is required\" }\n ]\n}" + "body": "{\n \"error\": {\n \"code\": \"VALIDATION_ERROR\",\n \"message\": \"Request validation failed\",\n \"details\": [\n { \"code\": \"too_small\", \"path\": [\"sender\"], \"message\": \"Sender address is required\" }\n ]\n }\n}" } ] }, From 0b56eb082b239d2f5a7ab0fdbaf4f5eda7eab555 Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Thu, 3 Sep 2026 13:17:01 +0000 Subject: [PATCH 2/3] fix(api): repair stream controller build --- backend/src/controllers/stream.controller.ts | 63 ++++++-------------- backend/src/controllers/stream/cancel.ts | 7 +-- 2 files changed, 22 insertions(+), 48 deletions(-) diff --git a/backend/src/controllers/stream.controller.ts b/backend/src/controllers/stream.controller.ts index 0844c07d..208d6edc 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -18,7 +18,8 @@ import { createStreamSchema } from "../validators/stream.validator.js"; import { DEFAULT_EVENTS_PAGE_SIZE, MAX_EVENTS_PAGE_SIZE, -} from "../routes/v1/events.routes.js"; +} from "../repositories/streamEvent.repository.js"; +import { findStreams } from "../repositories/stream.repository.js"; import { sendApiError } from "../types/api-error.js"; const DEFAULT_STREAM_PAGE_SIZE = 20; @@ -86,20 +87,23 @@ export const createStream = async (req: Request, res: Response) => { return sendApiError(res, 401, 'UNAUTHORIZED', 'Authentication required'); } - const { streamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime } = req.body; - - // Issue #809: validate identity fields before any DB write. - if (typeof sender !== 'string' || sender.length === 0) { - return sendApiError(res, 400, 'INVALID_SENDER', 'Invalid sender: must be a non-empty string'); - } - if (typeof recipient !== 'string' || recipient.length === 0) { - return sendApiError(res, 400, 'INVALID_RECIPIENT', 'Invalid recipient: must be a non-empty string'); - } - if (typeof tokenAddress !== 'string' || tokenAddress.length === 0) { - return sendApiError(res, 400, 'INVALID_TOKEN_ADDRESS', 'Invalid tokenAddress: must be a non-empty string'); + const parsed = createStreamSchema.safeParse(req.body); + if (!parsed.success) { + const message = parsed.error.issues + .map((issue) => `${issue.path.join('.') || 'request'}: ${issue.message}`) + .join('; '); + return sendApiError(res, 400, 'INVALID_REQUEST', message); } - const { streamId: parsedStreamId, sender, recipient, tokenAddress, ratePerSecond, depositedAmount, startTime: parsedStartTime } = parsed.data; + const { + streamId: parsedStreamId, + sender, + recipient, + tokenAddress, + ratePerSecond, + depositedAmount, + startTime: parsedStartTime, + } = parsed.data; // Issue #809: the authenticated wallet may only create/modify streams it owns. // Without this, any logged-in wallet could POST an arbitrary `sender` and have @@ -108,37 +112,8 @@ export const createStream = async (req: Request, res: Response) => { return sendApiError(res, 403, 'FORBIDDEN', 'sender must match the authenticated wallet'); } - const parsedStreamId = parseStreamId(streamId); - const parsedStartTime = Number.parseInt(startTime, 10); - - if (parsedStreamId === null) { - return sendApiError(res, 400, 'INVALID_STREAM_ID', 'Invalid streamId: must be a valid integer'); - } - - if (!Number.isFinite(parsedStartTime) || parsedStartTime < 0) { - return sendApiError(res, 400, 'INVALID_START_TIME', 'Invalid startTime: must be a non-negative integer'); - } - - // Presence/format validation happens here, before any BigInt coercion, - // so a malformed or missing numeric field always yields 400 rather than - // an uncaught SyntaxError/TypeError falling through to 500. - let parsedRatePerSecond: bigint; - let parsedDepositedAmount: bigint; - try { - parsedRatePerSecond = parseRequiredBigIntField( - "ratePerSecond", - ratePerSecond, - ); - parsedDepositedAmount = parseRequiredBigIntField( - "depositedAmount", - depositedAmount, - ); - } catch (validationError) { - if (validationError instanceof StreamValidationError) { - return sendApiError(res, 400, 'INVALID_REQUEST', validationError.message); - } - throw validationError; - } + const parsedRatePerSecond = BigInt(ratePerSecond); + const parsedDepositedAmount = BigInt(depositedAmount); if (parsedRatePerSecond <= 0n) { return sendApiError(res, 400, 'INVALID_RATE', 'Invalid ratePerSecond: must be greater than zero'); diff --git a/backend/src/controllers/stream/cancel.ts b/backend/src/controllers/stream/cancel.ts index dd8192d6..baefce72 100644 --- a/backend/src/controllers/stream/cancel.ts +++ b/backend/src/controllers/stream/cancel.ts @@ -82,10 +82,9 @@ export const cancelStreamHandler = async (req: AuthenticatedRequest, res: Respon } // 4. Call Soroban service to cancel on-chain - const secretKey = process.env.KEEPER_SECRET_KEY; - if (!secretKey) { - logger.error('[CancelStream] KEEPER_SECRET_KEY not configured'); - return sendApiError(res, 500, 'INTERNAL_SERVER_ERROR', 'Backend not configured for on-chain calls'); + const senderSecret = req.body?.senderSecret; + if (typeof senderSecret !== 'string' || senderSecret.length === 0) { + return sendApiError(res, 400, 'INVALID_REQUEST', 'senderSecret is required'); } const txHash = await sorobanService.cancelStream(parsedStreamId, senderSecret); From cd7837d164599630d93d422fd73d9da4fdbe9bc5 Mon Sep 17 00:00:00 2001 From: ponmileleke54-dev Date: Thu, 3 Sep 2026 13:53:00 +0000 Subject: [PATCH 3/3] test(api): align error envelope assertions --- backend/swagger/flowfi.openapi.json | 9 ++++++-- .../tests/integration/streams/cancel.test.ts | 9 +++++--- .../integration/streams/withdraw.test.ts | 14 ++++++++---- backend/tests/integration/top-up.test.ts | 4 ++-- backend/tests/stream.test.ts | 5 ++++- frontend/src/lib/api-types.generated.ts | 22 +++++++------------ 6 files changed, 37 insertions(+), 26 deletions(-) diff --git a/backend/swagger/flowfi.openapi.json b/backend/swagger/flowfi.openapi.json index 0dbed633..e527c96b 100644 --- a/backend/swagger/flowfi.openapi.json +++ b/backend/swagger/flowfi.openapi.json @@ -829,7 +829,10 @@ "properties": { "error": { "type": "object", - "required": ["code", "message"], + "required": [ + "code", + "message" + ], "properties": { "code": { "type": "string", @@ -849,7 +852,9 @@ } } }, - "required": ["error"] + "required": [ + "error" + ] } } }, diff --git a/backend/tests/integration/streams/cancel.test.ts b/backend/tests/integration/streams/cancel.test.ts index 43eb9656..e71ddff2 100644 --- a/backend/tests/integration/streams/cancel.test.ts +++ b/backend/tests/integration/streams/cancel.test.ts @@ -108,7 +108,7 @@ describe('POST /v1/streams/:streamId/cancel', () => { .set('Authorization', 'Bearer dummy_token'); expect(res.status).toBe(400); - expect(res.body.message).toContain('senderSecret'); + expect(res.body.error.message).toContain('senderSecret'); expect(sorobanService.cancelStream).not.toHaveBeenCalled(); }); @@ -127,7 +127,10 @@ describe('POST /v1/streams/:streamId/cancel', () => { .set('Authorization', 'Bearer dummy_token'); expect(res.status).toBe(403); - expect(res.body.error).toBe('Forbidden'); + expect(res.body.error).toMatchObject({ + code: 'FORBIDDEN', + message: 'Only the sender can cancel the stream', + }); expect(sorobanService.cancelStream).not.toHaveBeenCalled(); }); @@ -158,7 +161,7 @@ describe('POST /v1/streams/:streamId/cancel', () => { .set('Authorization', 'Bearer dummy_token'); expect(res.status).toBe(409); - expect(res.body.message).toContain('already cancelled'); + expect(res.body.error.message).toContain('already cancelled'); }); it('handles concurrent cancel requests correctly', async () => { diff --git a/backend/tests/integration/streams/withdraw.test.ts b/backend/tests/integration/streams/withdraw.test.ts index 9887f4f0..2d45e372 100644 --- a/backend/tests/integration/streams/withdraw.test.ts +++ b/backend/tests/integration/streams/withdraw.test.ts @@ -150,7 +150,10 @@ describe('POST /api/v1/streams/:streamId/withdraw', () => { .set('Authorization', `Bearer ${token}`); expect(response.status).toBe(403); - expect(response.body.error).toBe('Forbidden'); + expect(response.body.error).toMatchObject({ + code: 'FORBIDDEN', + message: 'Only the stream recipient can withdraw from the stream', + }); }); it('returns 404 if stream not found', async () => { @@ -164,7 +167,10 @@ describe('POST /api/v1/streams/:streamId/withdraw', () => { .set('Authorization', `Bearer ${token}`); expect(response.status).toBe(404); - expect(response.body.error).toBe('Stream not found'); + expect(response.body.error).toMatchObject({ + code: 'NOT_FOUND', + message: 'Stream not found', + }); }); it('returns 409 if no claimable balance available', async () => { @@ -192,7 +198,7 @@ describe('POST /api/v1/streams/:streamId/withdraw', () => { .set('Authorization', `Bearer ${token}`); expect(response.status).toBe(409); - expect(response.body.message).toBe('No claimable balance is currently available'); + expect(response.body.error.message).toBe('No claimable balance is currently available'); }); it('does not double-count withdrawnAmount when the same claim window is withdrawn twice in a row', async () => { @@ -291,7 +297,7 @@ describe('POST /api/v1/streams/:streamId/withdraw', () => { // lastUpdateTime, so the second call correctly finds nothing left to // claim in this window and is rejected. expect(second.status).toBe(409); - expect(second.body.message).toBe('No claimable balance is currently available'); + expect(second.body.error.message).toBe('No claimable balance is currently available'); } // The critical assertion: withdrawnAmount reflects only the ONE diff --git a/backend/tests/integration/top-up.test.ts b/backend/tests/integration/top-up.test.ts index 7e94814e..f19582a4 100644 --- a/backend/tests/integration/top-up.test.ts +++ b/backend/tests/integration/top-up.test.ts @@ -182,7 +182,7 @@ describe('POST /v1/streams/:streamId/top-up', () => { .send({ amount: '1000' }); expect(res.status).toBe(409); - expect(res.body.message).toMatch(/inactive stream/); + expect(res.body.error.message).toMatch(/inactive stream/); }); it('returns 409 when stream is paused', async () => { @@ -194,7 +194,7 @@ describe('POST /v1/streams/:streamId/top-up', () => { .send({ amount: '1000' }); expect(res.status).toBe(409); - expect(res.body.message).toMatch(/paused stream/); + expect(res.body.error.message).toMatch(/paused stream/); }); it('leaves DB unchanged when topUpStream fails on-chain', async () => { diff --git a/backend/tests/stream.test.ts b/backend/tests/stream.test.ts index 13410543..bbfa571a 100644 --- a/backend/tests/stream.test.ts +++ b/backend/tests/stream.test.ts @@ -159,7 +159,10 @@ describe('POST /v1/streams', () => { .set('Accept', 'application/json'); expect(response.status).toBe(403); - expect(response.body).toHaveProperty('error', 'Forbidden'); + expect(response.body.error).toMatchObject({ + code: 'FORBIDDEN', + message: 'sender must match the authenticated wallet', + }); expect(prisma.stream.upsert).not.toHaveBeenCalled(); }); diff --git a/frontend/src/lib/api-types.generated.ts b/frontend/src/lib/api-types.generated.ts index 348d6885..1dd9329d 100644 --- a/frontend/src/lib/api-types.generated.ts +++ b/frontend/src/lib/api-types.generated.ts @@ -2801,20 +2801,14 @@ export interface components { }; }; Error: { - /** - * @description Error message - * @example Resource not found - */ - error?: string; - /** - * @description Error code - * @example NOT_FOUND - */ - code?: string; - /** @description Human-readable detail (present on many error responses) */ - message?: string | null; - /** @description Structured validation issues (zod) when the error is a 400 */ - details?: Record[] | null; + error: { + /** @example NOT_FOUND */ + code: string; + /** @example Resource not found */ + message: string; + /** @description Structured validation issues when applicable */ + details?: Record[]; + }; }; }; responses: never;