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 33ffdf21..208d6edc 100644 --- a/backend/src/controllers/stream.controller.ts +++ b/backend/src/controllers/stream.controller.ts @@ -20,6 +20,7 @@ import { MAX_EVENTS_PAGE_SIZE, } 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; const MAX_STREAM_PAGE_SIZE = 100; @@ -83,44 +84,43 @@ 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'); } - // Validate request body using the Zod schema, which includes the MAX_I128 - // upper-bound check on ratePerSecond that the manual parsing omitted. const parsed = createStreamSchema.safeParse(req.body); if (!parsed.success) { - return res.status(400).json({ - error: 'Validation error', - details: parsed.error.issues, - }); + 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 // 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 parsedRatePerSecond = BigInt(ratePerSecond); const parsedDepositedAmount = BigInt(depositedAmount); 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 = @@ -132,10 +132,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({ @@ -166,12 +163,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.'); } }; @@ -195,10 +190,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(", ")}`); } } @@ -254,7 +246,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."); } }; @@ -268,7 +260,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({ @@ -286,7 +278,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" }); } @@ -304,7 +296,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."); } }; @@ -318,7 +310,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"]; @@ -365,10 +357,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; } @@ -394,7 +383,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."); } }; @@ -408,7 +397,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; @@ -417,10 +406,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"); } } @@ -454,7 +440,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 @@ -480,7 +466,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."); } }; @@ -496,7 +482,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(); @@ -600,7 +586,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."); } }; @@ -627,42 +613,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); @@ -687,7 +669,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"); } }; @@ -700,9 +682,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) @@ -710,7 +690,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 @@ -719,31 +699,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 { @@ -768,17 +739,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."); } }; @@ -791,9 +756,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) @@ -801,7 +764,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 @@ -810,23 +773,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 { @@ -851,16 +808,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 c4e9492f..baefce72 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,33 +68,23 @@ 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 - // Use the sender's secret for cryptographic authorization instead of the - // single keeper key. The senderSecret should be provided in the request body - // and correspond to the stream's sender wallet private key. const senderSecret = req.body?.senderSecret; - if (!senderSecret) { - logger.error('[CancelStream] senderSecret not provided in request body'); - return res.status(400).json({ error: 'Bad request', message: 'senderSecret is required in request body' }); + if (typeof senderSecret !== 'string' || senderSecret.length === 0) { + return sendApiError(res, 400, 'INVALID_REQUEST', 'senderSecret is required'); } const txHash = await sorobanService.cancelStream(parsedStreamId, senderSecret); @@ -110,8 +101,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 2dc16b0f..486119d1 100644 --- a/backend/src/controllers/user.controller.ts +++ b/backend/src/controllers/user.controller.ts @@ -13,6 +13,7 @@ import { resolveEventsPageSize, } from "../repositories/streamEvent.repository.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 @@ -106,12 +107,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({ @@ -120,7 +119,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); @@ -150,12 +149,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 { requested, types } = parseEventTypeFilter(req.query["type"]); @@ -258,7 +255,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"; @@ -273,15 +270,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 381f27a2..e527c96b 100644 --- a/backend/swagger/flowfi.openapi.json +++ b/backend/swagger/flowfi.openapi.json @@ -828,29 +828,33 @@ "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 969d6674..ce1a457e 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/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.controller.test.ts b/backend/tests/stream.controller.test.ts index d8356b84..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: 'Validation error' }) + 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: 'Validation error' }) + 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: 'Validation error' }) + 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: 'Validation error' }) + expect.objectContaining({ + error: expect.objectContaining({ message: expect.stringContaining('depositedAmount') }), + }) ); }); }); 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/backend/tests/user.controller.test.ts b/backend/tests/user.controller.test.ts index 5adb3a3e..ef02f1c5 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}" } ] }, 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;