From f73ac0d5138048bd5be86698c65352976aec207a Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Fri, 25 Sep 2026 04:31:05 +0000 Subject: [PATCH 1/4] refactor(api): standardize error envelope Every error response now uses a single envelope: { error: { code, message, details, correlationId } } - Add typed errors in lib/errors.ts (ValidationError, NotFoundError, ConflictError, UnauthorizedError, ContractError, RateLimitError, ServiceMisconfiguredError, ...) and an asyncHandler wrapper so rejected promises reach Express's error pipeline. - Add a central error handler plus catch-all 404 in app.ts. Legacy status-tagged errors are mapped to a code by status, and Soroban "Error(Contract, #N)" messages become CONTRACT_ERROR with details.contractCode. - Zod validation (middleware/validate.ts) and the rate limiter now emit the envelope; field-level issues go in details, retryAfter in details. - requireEnv throws ServiceMisconfiguredError instead of writing to res. - Routes throw typed errors instead of building responses by hand. - Mount correlationId middleware before express.json so body-parse errors also carry a correlationId. - Fix missing requireEnv import in compliance.ts and the malformed requireEnv call in simulate-settlement (both previously hung with 503 tests timing out). - Update tests, OpenAPI ErrorResponse schema, api-reference.md, error-codes.md, rate-limits.md and frontend callers that read body.error as a string. Co-Authored-By: Claude Opus 5.5 --- comebackhere-backend/src/app.ts | 11 +- comebackhere-backend/src/lib/env.ts | 16 +- comebackhere-backend/src/lib/errors.ts | 115 ++++++ .../src/middleware/errorHandler.ts | 91 +++++ .../src/middleware/rateLimiter.ts | 9 +- .../src/middleware/validate.ts | 7 +- comebackhere-backend/src/openapi.ts | 23 +- comebackhere-backend/src/routes/analytics.ts | 62 ++-- comebackhere-backend/src/routes/compliance.ts | 127 +++---- comebackhere-backend/src/routes/disputes.ts | 47 +-- .../src/routes/invoice-settings.ts | 55 ++- comebackhere-backend/src/routes/invoices.ts | 185 +++++----- .../src/routes/release-escrow.ts | 20 +- comebackhere-backend/src/routes/threshold.ts | 55 ++- comebackhere-backend/src/routes/treasury.ts | 348 ++++++++---------- .../src/tests/compliance-audit.test.ts | 2 +- .../src/tests/compliance.test.ts | 18 +- .../src/tests/correlationId.test.ts | 26 +- .../src/tests/error-envelope.test.ts | 102 +++++ .../src/tests/invoice-settings.test.ts | 26 +- .../src/tests/invoices.test.ts | 24 +- .../src/tests/mongo-hardening.test.ts | 2 +- .../src/tests/rateLimiter.test.ts | 7 +- .../src/tests/release-escrow.test.ts | 19 +- .../src/tests/treasury.test.ts | 30 +- docs/api-reference.md | 55 ++- docs/error-codes.md | 15 +- docs/rate-limits.md | 14 +- .../GraceWindowSettings.tsx | 4 +- .../TreasuryManagerPage.tsx | 2 +- 30 files changed, 891 insertions(+), 626 deletions(-) create mode 100644 comebackhere-backend/src/lib/errors.ts create mode 100644 comebackhere-backend/src/middleware/errorHandler.ts create mode 100644 comebackhere-backend/src/tests/error-envelope.test.ts diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index ccc2eb1..2fb96f5 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -11,14 +11,16 @@ import analyticsRouter from "./routes/analytics.js" import { startComplianceIndexer } from "./services/compliance-indexer.js" import { rateLimitMiddleware } from "./middleware/rateLimiter.js" import { correlationIdMiddleware } from "./middleware/correlationId.js" +import { errorHandler, notFoundHandler } from "./middleware/errorHandler.js" import { openapiSpec } from "./openapi.js" export function createApp() { const app = express() - app.use(express.json()) // Attach / propagate X-Request-Id before any other middleware so every log - // line and downstream call can reference the same correlation ID. + // line, downstream call and error envelope can reference the same + // correlation ID — including body-parsing errors. app.use(correlationIdMiddleware) + app.use(express.json()) app.use(rateLimitMiddleware) // ── Health ────────────────────────────────────────────────────────────────── @@ -42,6 +44,11 @@ export function createApp() { app.use("/api/treasury", thresholdRouter) app.use("/disputes", disputesRouter) app.use("/api/analytics", analyticsRouter) + + // ── Errors ────────────────────────────────────────────────────────────────── + // Everything below produces { error: { code, message, details, correlationId } } + app.use(notFoundHandler) + app.use(errorHandler) // Start indexing only when the application is actually created; tests omit // the required contract/RPC configuration and therefore remain side-effect free. startComplianceIndexer() diff --git a/comebackhere-backend/src/lib/env.ts b/comebackhere-backend/src/lib/env.ts index 3d4ca17..c63a719 100644 --- a/comebackhere-backend/src/lib/env.ts +++ b/comebackhere-backend/src/lib/env.ts @@ -1,4 +1,4 @@ -import type { Response } from "express" +import { ServiceMisconfiguredError } from "./errors.js" import { getNetworkPassphrase } from "./soroban.js" /** @@ -12,8 +12,6 @@ export type ContractEnv

> = { networkPassphrase: string } & { [Prop in keyof P]: string } -const MISSING_ENV_ERROR = "Service misconfiguration: missing required environment variables" - /** * Reads and validates the env vars a route needs from `process.env`. * @@ -22,20 +20,16 @@ const MISSING_ENV_ERROR = "Service misconfiguration: missing required environmen * additional property name to the env var it should be read from, e.g. * `{ treasuryContractId: "TREASURY_CONTRACT_ID" }`. * - * If any referenced var is unset, writes a 503 with the standard - * misconfiguration error to `res` and returns null. + * If any referenced var is unset, throws a {@link ServiceMisconfiguredError} + * (503 in the standard error envelope). */ -export function requireEnv

>( - res: Response, - vars: P, -): ContractEnv

| null { +export function requireEnv

>(vars: P): ContractEnv

{ const missing = [ !process.env.SOROBAN_RPC_URL ? "SOROBAN_RPC_URL" : null, ...Object.values(vars).filter((envName) => !process.env[envName]), ].filter(Boolean) if (missing.length > 0) { - res.status(503).json({ error: MISSING_ENV_ERROR }) - return null + throw new ServiceMisconfiguredError() } const values = Object.fromEntries( diff --git a/comebackhere-backend/src/lib/errors.ts b/comebackhere-backend/src/lib/errors.ts new file mode 100644 index 0000000..041846b --- /dev/null +++ b/comebackhere-backend/src/lib/errors.ts @@ -0,0 +1,115 @@ +import type { NextFunction, Request, RequestHandler, Response } from "express" + +/** + * Typed application errors. + * + * Routes throw one of these instead of building error responses by hand; the + * central handler in `middleware/errorHandler.ts` turns them into the standard + * envelope: + * + * { error: { code, message, details, correlationId } } + */ +export class AppError extends Error { + readonly status: number + readonly code: string + readonly details: unknown + + constructor(status: number, code: string, message: string, details: unknown = null) { + super(message) + this.name = new.target.name + this.status = status + this.code = code + this.details = details + } +} + +export interface FieldIssue { + field: string + message: string +} + +export class ValidationError extends AppError { + constructor(message: string, details: FieldIssue[] | null = null) { + super(400, "VALIDATION_ERROR", message, details) + } +} + +export class UnauthorizedError extends AppError { + constructor(message = "Unauthorized") { + super(401, "UNAUTHORIZED", message) + } +} + +export class ForbiddenError extends AppError { + constructor(message = "Forbidden") { + super(403, "FORBIDDEN", message) + } +} + +export class NotFoundError extends AppError { + constructor(message = "Resource not found") { + super(404, "NOT_FOUND", message) + } +} + +export class ConflictError extends AppError { + constructor(message: string, details: unknown = null) { + super(409, "CONFLICT", message, details) + } +} + +export class PayloadTooLargeError extends AppError { + constructor(message = "Request body exceeds the maximum allowed size", details: unknown = null) { + super(413, "PAYLOAD_TOO_LARGE", message, details) + } +} + +export class RateLimitError extends AppError { + constructor(retryAfter: number) { + super( + 429, + "RATE_LIMITED", + "Too many requests. Please retry after the indicated number of seconds.", + { retryAfter }, + ) + } +} + +export class ServiceMisconfiguredError extends AppError { + constructor(message = "Service misconfiguration: missing required environment variables") { + super(503, "SERVICE_MISCONFIGURED", message) + } +} + +/** + * A Soroban contract returned a numbered error (`Error(Contract, #N)`). + * `contractCode` is the numeric variant documented in docs/error-codes.md. + */ +export class ContractError extends AppError { + readonly contractCode: number + + constructor(contractCode: number, message: string, status = 422) { + super(status, "CONTRACT_ERROR", message, { contractCode }) + this.contractCode = contractCode + } +} + +const CONTRACT_ERROR_PATTERN = /Error\(Contract, #(\d+)\)/ + +/** Extracts the contract error code from a Soroban host error message, if any. */ +export function parseContractErrorCode(message: string): number | null { + const match = CONTRACT_ERROR_PATTERN.exec(message) + return match ? Number(match[1]) : null +} + +/** + * Wraps an async route handler so rejected promises reach the central error + * handler (Express 4 does not forward them on its own). + */ +export function asyncHandler( + fn: (req: Req, res: Response, next: NextFunction) => Promise, +): RequestHandler { + return (req, res, next) => { + fn(req as Req, res, next).catch(next) + } +} diff --git a/comebackhere-backend/src/middleware/errorHandler.ts b/comebackhere-backend/src/middleware/errorHandler.ts new file mode 100644 index 0000000..28a3b99 --- /dev/null +++ b/comebackhere-backend/src/middleware/errorHandler.ts @@ -0,0 +1,91 @@ +import type { NextFunction, Request, Response } from "express" +import { AppError, ContractError, NotFoundError, parseContractErrorCode } from "../lib/errors.js" + +/** + * Standard error envelope returned by every route: + * + * { error: { code, message, details, correlationId } } + */ +export interface ErrorEnvelope { + error: { + code: string + message: string + details: unknown + correlationId: string | null + } +} + +const CODE_BY_STATUS: Record = { + 400: "BAD_REQUEST", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 409: "CONFLICT", + 413: "PAYLOAD_TOO_LARGE", + 422: "UNPROCESSABLE_ENTITY", + 429: "RATE_LIMITED", + 500: "INTERNAL_ERROR", + 502: "BAD_GATEWAY", + 503: "SERVICE_UNAVAILABLE", + 504: "GATEWAY_TIMEOUT", +} + +/** + * Normalises anything thrown by a route into an {@link AppError}. + * + * Legacy helpers still throw plain `Error`s tagged with `status`; body-parser + * errors carry `status` and `type`. Soroban host errors that embed + * `Error(Contract, #N)` become a {@link ContractError} with that code. + */ +export function toAppError(err: unknown): AppError { + if (err instanceof AppError) return err + + const e = (err ?? {}) as { status?: unknown; statusCode?: unknown; type?: unknown; message?: unknown } + const rawStatus = typeof e.status === "number" ? e.status : typeof e.statusCode === "number" ? e.statusCode : 500 + const status = rawStatus >= 400 && rawStatus <= 599 ? rawStatus : 500 + const message = err instanceof Error ? err.message : typeof e.message === "string" ? e.message : String(err) + + if (e.type === "entity.parse.failed") { + return new AppError(400, "INVALID_JSON", "Request body is not valid JSON") + } + + const contractCode = parseContractErrorCode(message) + if (contractCode !== null) { + return new ContractError(contractCode, message, status === 500 ? 422 : status) + } + + return new AppError(status, CODE_BY_STATUS[status] ?? (status < 500 ? "BAD_REQUEST" : "INTERNAL_ERROR"), message) +} + +export function buildErrorEnvelope(err: AppError, correlationId: string | null): ErrorEnvelope { + return { + error: { + code: err.code, + message: err.message, + details: err.details ?? null, + correlationId, + }, + } +} + +/** Catch-all for unmatched routes — mounted after every router. */ +export function notFoundHandler(req: Request, _res: Response, next: NextFunction): void { + next(new NotFoundError(`Route ${req.method} ${req.path} not found`)) +} + +/** Central Express error handler — must be registered last. */ +export function errorHandler(err: unknown, _req: Request, res: Response, next: NextFunction): void { + if (res.headersSent) { + next(err) + return + } + + const appError = toAppError(err) + const correlationId = typeof res.locals.requestId === "string" ? res.locals.requestId : null + + if (appError.status >= 500) { + console.error(`[requestId=${correlationId}] ${appError.code}: ${appError.message}`) + } + + res.status(appError.status).json(buildErrorEnvelope(appError, correlationId)) +} diff --git a/comebackhere-backend/src/middleware/rateLimiter.ts b/comebackhere-backend/src/middleware/rateLimiter.ts index d51a2af..9fd3bc4 100644 --- a/comebackhere-backend/src/middleware/rateLimiter.ts +++ b/comebackhere-backend/src/middleware/rateLimiter.ts @@ -1,6 +1,7 @@ import { type Request, type Response, type NextFunction } from "express" import { RateLimiterRedis, RateLimiterMemory, type RateLimiterAbstract } from "rate-limiter-flexible" import Redis from "ioredis" +import { RateLimitError } from "../lib/errors.js" /** * Reads rate limit config from environment variables with sensible defaults. @@ -82,7 +83,8 @@ function setRateLimitHeaders( /** * Express middleware: enforces per-IP rate limiting. - * Returns 429 with a Retry-After header when the limit is exceeded. + * Returns 429 (standard error envelope, `details.retryAfter`) with a + * Retry-After header when the limit is exceeded. * On every response (success or 429) attaches: * X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset */ @@ -110,9 +112,6 @@ export function rateLimitMiddleware( const retrySecs = Math.ceil((rateLimiterRes?.msBeforeNext ?? 1000) / 1000) setRateLimitHeaders(res, points, 0, rateLimiterRes?.msBeforeNext ?? 1000) res.set("Retry-After", String(retrySecs)) - res.status(429).json({ - error: "Too many requests. Please retry after the indicated number of seconds.", - retryAfter: retrySecs, - }) + next(new RateLimitError(retrySecs)) }) } diff --git a/comebackhere-backend/src/middleware/validate.ts b/comebackhere-backend/src/middleware/validate.ts index b78c7d4..2cee7f0 100644 --- a/comebackhere-backend/src/middleware/validate.ts +++ b/comebackhere-backend/src/middleware/validate.ts @@ -1,19 +1,20 @@ import { z } from "zod" import type { Request, Response, NextFunction } from "express" +import { ValidationError } from "../lib/errors.js" type RequestPart = "body" | "params" | "query" function makeValidator(part: RequestPart) { return (schema: z.ZodTypeAny) => - (req: Request, res: Response, next: NextFunction) => { + (req: Request, _res: Response, next: NextFunction) => { const result = schema.safeParse(req[part]) if (!result.success) { const details = result.error.issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, })) - const error = details.map((d) => `${d.field}: ${d.message}`).join("; ") - res.status(400).json({ error, details }) + const message = details.map((d) => `${d.field}: ${d.message}`).join("; ") + next(new ValidationError(message, details)) return } if (part === "body") { diff --git a/comebackhere-backend/src/openapi.ts b/comebackhere-backend/src/openapi.ts index 625b4fa..4a8715c 100644 --- a/comebackhere-backend/src/openapi.ts +++ b/comebackhere-backend/src/openapi.ts @@ -33,8 +33,29 @@ const options: swaggerJsdoc.Options = { schemas: { ErrorResponse: { type: "object", + description: "Standard error envelope returned by every endpoint.", properties: { - error: { type: "string", example: "Human-readable description of the error." }, + error: { + type: "object", + properties: { + code: { type: "string", example: "VALIDATION_ERROR", description: "Stable machine-readable error code." }, + message: { type: "string", example: "settlement_id: Must be a positive integer" }, + details: { + nullable: true, + description: + "Extra context. For VALIDATION_ERROR: an array of { field, message }. " + + "For CONTRACT_ERROR: { contractCode }. For RATE_LIMITED: { retryAfter }. Otherwise usually null.", + example: [{ field: "settlement_id", message: "Must be a positive integer" }], + }, + correlationId: { + type: "string", + nullable: true, + description: "Same value as the X-Request-Id response header.", + example: "5f1c9a8e-2b7d-4c1e-9a3f-0d2e6b7c8a91", + }, + }, + required: ["code", "message", "details", "correlationId"], + }, }, required: ["error"], }, diff --git a/comebackhere-backend/src/routes/analytics.ts b/comebackhere-backend/src/routes/analytics.ts index 07fe21c..6d36618 100644 --- a/comebackhere-backend/src/routes/analytics.ts +++ b/comebackhere-backend/src/routes/analytics.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express" import { validateQuery } from "../middleware/validate.js" +import { asyncHandler } from "../lib/errors.js" import { analyticsQuerySchema } from "../schemas/index.js" const router = Router() @@ -37,39 +38,34 @@ interface AnalyticsData { * - Disputes contract state (open disputes) * - Compliance contract state (blocks) */ -router.get("/metrics", validateQuery(analyticsQuerySchema), async (req: Request, res: Response) => { - try { - // In production, this would: - // 1. Query the invoice contract for invoice counts by status - // 2. Query treasury contract for settled volumes by token - // 3. Query disputes contract for open dispute count - // 4. Query compliance contract for active blocks - // 5. Apply date filters if provided - // - // For now, return realistic mock data that can be seeded/tested - const analyticsData: AnalyticsData = { - invoices: { - pending: 24, - paid: 156, - cancelled: 12, - expired: 8, - refund_requested: 3, - }, - settled_volume: [ - { token: "USDC", volume: 184250.5 }, - { token: "XLM", volume: 52100.0 }, - { token: "EURC", volume: 12450.75 }, - ], - open_disputes: 7, - compliance_blocks: 3, - settlement_throughput: 89, // settled invoices in period - } - - res.json(analyticsData) - } catch (error) { - console.error("Error fetching analytics metrics:", error) - res.status(500).json({ error: "Failed to fetch analytics metrics" }) +router.get("/metrics", validateQuery(analyticsQuerySchema), asyncHandler(async (req: Request, res: Response) => { + // In production, this would: + // 1. Query the invoice contract for invoice counts by status + // 2. Query treasury contract for settled volumes by token + // 3. Query disputes contract for open dispute count + // 4. Query compliance contract for active blocks + // 5. Apply date filters if provided + // + // For now, return realistic mock data that can be seeded/tested + const analyticsData: AnalyticsData = { + invoices: { + pending: 24, + paid: 156, + cancelled: 12, + expired: 8, + refund_requested: 3, + }, + settled_volume: [ + { token: "USDC", volume: 184250.5 }, + { token: "XLM", volume: 52100.0 }, + { token: "EURC", volume: 12450.75 }, + ], + open_disputes: 7, + compliance_blocks: 3, + settlement_throughput: 89, // settled invoices in period } -}) + + res.json(analyticsData) +})) export default router diff --git a/comebackhere-backend/src/routes/compliance.ts b/comebackhere-backend/src/routes/compliance.ts index 67add3d..02e8013 100644 --- a/comebackhere-backend/src/routes/compliance.ts +++ b/comebackhere-backend/src/routes/compliance.ts @@ -8,6 +8,8 @@ import { SorobanRpc, } from "stellar-sdk" import { validateBody, validateQuery } from "../middleware/validate.js" +import { requireEnv } from "../lib/env.js" +import { asyncHandler, UnauthorizedError } from "../lib/errors.js" import { allowBodySchema, blockBodySchema, complianceAuditQuerySchema } from "../schemas/index.js" import { connectMongo, getComplianceAuditCollection } from "../db/mongo.js" @@ -47,32 +49,27 @@ const router = Router() * description: Database error * Returns the durable, normalized audit trail emitted by the compliance contract. */ -router.get("/audit", validateQuery(complianceAuditQuerySchema), async (req: Request, res: Response) => { - try { - const query = req.query as unknown as { - address?: string; event_type?: string; from_ledger?: number; to_ledger?: number; page: number; limit: number - } - const filter: Record = {} - if (query.address) filter.address = query.address - if (query.event_type) filter.event_type = query.event_type - if (query.from_ledger !== undefined || query.to_ledger !== undefined) { - filter.ledger = { - ...(query.from_ledger !== undefined ? { $gte: query.from_ledger } : {}), - ...(query.to_ledger !== undefined ? { $lte: query.to_ledger } : {}), - } +router.get("/audit", validateQuery(complianceAuditQuerySchema), asyncHandler(async (req: Request, res: Response) => { + const query = req.query as unknown as { + address?: string; event_type?: string; from_ledger?: number; to_ledger?: number; page: number; limit: number + } + const filter: Record = {} + if (query.address) filter.address = query.address + if (query.event_type) filter.event_type = query.event_type + if (query.from_ledger !== undefined || query.to_ledger !== undefined) { + filter.ledger = { + ...(query.from_ledger !== undefined ? { $gte: query.from_ledger } : {}), + ...(query.to_ledger !== undefined ? { $lte: query.to_ledger } : {}), } - const skip = (query.page - 1) * query.limit - const collection = getComplianceAuditCollection(await connectMongo()) - const [events, total] = await Promise.all([ - collection.find(filter).sort({ ledger: -1, _id: -1 }).skip(skip).limit(query.limit).toArray(), - collection.countDocuments(filter), - ]) - res.json({ events, page: query.page, limit: query.limit, total, has_more: skip + events.length < total }) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - res.status(status).json({ error: err instanceof Error ? err.message : String(err) }) } -}) + const skip = (query.page - 1) * query.limit + const collection = getComplianceAuditCollection(await connectMongo()) + const [events, total] = await Promise.all([ + collection.find(filter).sort({ ledger: -1, _id: -1 }).skip(skip).limit(query.limit).toArray(), + collection.countDocuments(filter), + ]) + res.json({ events, page: query.page, limit: query.limit, total, has_more: skip + events.length < total }) +})) // --------------------------------------------------------------------------- // Shared Soroban client type — mirrors invoices.ts convention @@ -181,43 +178,35 @@ export interface AllowBody { * Body: { address: string, until?: number } * Returns: { address, status, hash } */ -router.post("/allow", validateBody(allowBodySchema), async (req: Request, res: Response) => { +router.post("/allow", validateBody(allowBodySchema), asyncHandler(async (req: Request, res: Response) => { const adminKey = req.headers["x-admin-key"] if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - res.status(401).json({ error: "Unauthorized" }) - return + throw new UnauthorizedError() } const { address, until } = req.body as { address: string; until?: number } - const env = requireEnv(res, { + const env = requireEnv({ complianceContractId: "COMPLIANCE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return - try { - const client = buildSorobanClient(env.rpcUrl) - const operation = until ? "allow_address_until" : "allow_address" - const args = until - ? [nativeToScVal(address, { type: "address" }), nativeToScVal(until, { type: "u64" })] - : [nativeToScVal(address, { type: "address" })] - - const result = await callComplianceOp( - operation as "allow_address" | "allow_address_until", - args, - client, - env.complianceContractId, - env.signerSecret, - env.networkPassphrase - ) - res.status(200).json(result) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const client = buildSorobanClient(env.rpcUrl) + const operation = until ? "allow_address_until" : "allow_address" + const args = until + ? [nativeToScVal(address, { type: "address" }), nativeToScVal(until, { type: "u64" })] + : [nativeToScVal(address, { type: "address" })] + + const result = await callComplianceOp( + operation as "allow_address" | "allow_address_until", + args, + client, + env.complianceContractId, + env.signerSecret, + env.networkPassphrase + ) + res.status(200).json(result) +})) // --------------------------------------------------------------------------- // POST /compliance/block (#68) @@ -233,40 +222,32 @@ export interface BlockBody { * Body: { address: string } * Returns: { address, status, hash } */ -router.post("/block", validateBody(blockBodySchema), async (req: Request, res: Response) => { +router.post("/block", validateBody(blockBodySchema), asyncHandler(async (req: Request, res: Response) => { const adminKey = req.headers["x-admin-key"] if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - res.status(401).json({ error: "Unauthorized" }) - return + throw new UnauthorizedError() } const { address } = req.body as { address: string } - const env = requireEnv(res, { + const env = requireEnv({ complianceContractId: "COMPLIANCE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return // Audit log — admin identity + timestamp console.log(`[compliance] block_address admin="${adminKey}" address="${address}" ts="${new Date().toISOString()}"`) - try { - const client = buildSorobanClient(env.rpcUrl) - const result = await callComplianceOp( - "block_address", - [nativeToScVal(address, { type: "address" })], - client, - env.complianceContractId, - env.signerSecret, - env.networkPassphrase - ) - res.status(200).json(result) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const client = buildSorobanClient(env.rpcUrl) + const result = await callComplianceOp( + "block_address", + [nativeToScVal(address, { type: "address" })], + client, + env.complianceContractId, + env.signerSecret, + env.networkPassphrase + ) + res.status(200).json(result) +})) export default router diff --git a/comebackhere-backend/src/routes/disputes.ts b/comebackhere-backend/src/routes/disputes.ts index f992444..35a3fe8 100644 --- a/comebackhere-backend/src/routes/disputes.ts +++ b/comebackhere-backend/src/routes/disputes.ts @@ -1,5 +1,6 @@ import { Router, type Request, type Response } from "express" import { requireEnv } from "../lib/env.js" +import { asyncHandler, ConflictError } from "../lib/errors.js" import { validateBody } from "../middleware/validate.js" import { voteBodySchema, createDisputeSchema } from "../schemas/index.js" @@ -83,7 +84,7 @@ type VoteValue = "ResolvedClaimant" | "ResolvedCounterparty" * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/:id/vote", validateBody(voteBodySchema), async (req: Request, res: Response) => { +router.post("/:id/vote", validateBody(voteBodySchema), asyncHandler(async (req: Request, res: Response) => { const disputeId = req.params.id const { signer_address, vote, weight } = req.body as { signer_address: string @@ -99,13 +100,11 @@ router.post("/:id/vote", validateBody(voteBodySchema), async (req: Request, res: } if (state.outcome !== null) { - res.status(409).json({ error: "Dispute already resolved", outcome: state.outcome }) - return + throw new ConflictError("Dispute already resolved", { outcome: state.outcome }) } if (state.votes.has(signer_address)) { - res.status(409).json({ error: "Signer has already voted on this dispute" }) - return + throw new ConflictError("Signer has already voted on this dispute") } state.votes.set(signer_address, vote) @@ -136,7 +135,7 @@ router.post("/:id/vote", validateBody(voteBodySchema), async (req: Request, res: threshold, outcome: state.outcome, }) -}) +})) export interface CreateDisputeBody { /** Stellar public key of the party raising the dispute (claimant). */ @@ -206,32 +205,26 @@ export interface CreateDisputeBody { * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/", validateBody(createDisputeSchema), async (req: Request, res: Response) => { +router.post("/", validateBody(createDisputeSchema), asyncHandler(async (req: Request, res: Response) => { const body = req.body as CreateDisputeBody - if (!requireEnv(res, { settlementContractId: "SETTLEMENT_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY" })) return + requireEnv({ settlementContractId: "SETTLEMENT_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY" }) const settlementId = body.settlement_id const claimantAddress = body.claimant_address - try { - // In production this would call raise_dispute on the settlement contract via Soroban RPC. - // The contract transitions the settlement to OnHold atomically. Here we return the - // expected shape so downstream clients can integrate without a live node. - const disputeId = `${settlementId}-${Date.now()}` - - res.status(201).json({ - dispute_id: disputeId, - settlement_id: settlementId, - claimant_address: claimantAddress, - status: "Raised", - settlement_status: "OnHold", - }) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + // In production this would call raise_dispute on the settlement contract via Soroban RPC. + // The contract transitions the settlement to OnHold atomically. Here we return the + // expected shape so downstream clients can integrate without a live node. + const disputeId = `${settlementId}-${Date.now()}` + + res.status(201).json({ + dispute_id: disputeId, + settlement_id: settlementId, + claimant_address: claimantAddress, + status: "Raised", + settlement_status: "OnHold", + }) +})) export default router diff --git a/comebackhere-backend/src/routes/invoice-settings.ts b/comebackhere-backend/src/routes/invoice-settings.ts index ba5bb31..04edee1 100644 --- a/comebackhere-backend/src/routes/invoice-settings.ts +++ b/comebackhere-backend/src/routes/invoice-settings.ts @@ -7,6 +7,7 @@ import { type SorobanClient, } from "../lib/soroban.js" import { requireEnv } from "../lib/env.js" +import { asyncHandler } from "../lib/errors.js" import { validateBody } from "../middleware/validate.js" import { graceWindowSchema } from "../schemas/index.js" @@ -36,32 +37,25 @@ const router = Router() * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.get("/grace-window", async (_req: Request, res: Response) => { - const env = requireEnv(res, { +router.get("/grace-window", asyncHandler(async (_req: Request, res: Response) => { + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return - try { - const client = buildSorobanClient(env.rpcUrl) - const sourceAccount = Keypair.fromSecret(env.signerSecret).publicKey() - const retval = await simulateContractRead( - client, - env.invoiceContractId, - "get_grace_window", - [], - sourceAccount, - env.networkPassphrase, - ) - const seconds = Number(retval.u64()?.toString() ?? "86400") - res.json({ grace_window_seconds: seconds }) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const client = buildSorobanClient(env.rpcUrl) + const sourceAccount = Keypair.fromSecret(env.signerSecret).publicKey() + const retval = await simulateContractRead( + client, + env.invoiceContractId, + "get_grace_window", + [], + sourceAccount, + env.networkPassphrase, + ) + const seconds = Number(retval.u64()?.toString() ?? "86400") + res.json({ grace_window_seconds: seconds }) +})) /** * POST /api/invoice/grace-window @@ -142,23 +136,16 @@ export async function setGraceWindow( * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/grace-window", validateBody(graceWindowSchema), async (req: Request, res: Response) => { - const env = requireEnv(res, { +router.post("/grace-window", validateBody(graceWindowSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return const graceWindowSeconds = req.body.grace_window_seconds - try { - const result = await setGraceWindow(graceWindowSeconds, env) - res.json(result) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const result = await setGraceWindow(graceWindowSeconds, env) + res.json(result) +})) export default router diff --git a/comebackhere-backend/src/routes/invoices.ts b/comebackhere-backend/src/routes/invoices.ts index 645ef36..ce0f122 100644 --- a/comebackhere-backend/src/routes/invoices.ts +++ b/comebackhere-backend/src/routes/invoices.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from "express" import { Keypair, TransactionBuilder, BASE_FEE, Contract, nativeToScVal, SorobanRpc, xdr } from "stellar-sdk" import { connectMongo, getInvoicesCollection, type InvoiceRecord, type InvoiceStatus } from "../db/mongo.js" import { requireEnv } from "../lib/env.js" +import { asyncHandler, NotFoundError } from "../lib/errors.js" import { cacheGet, cacheSet } from "../lib/cache.js" import { validateBody, validateParams } from "../middleware/validate.js" import { createInvoiceSchema, invoiceIdParamSchema } from "../schemas/index.js" @@ -153,7 +154,7 @@ export async function createInvoice( * totalPages: * type: integer */ -router.get("/", async (req: Request, res: Response) => { +router.get("/", asyncHandler(async (req: Request, res: Response) => { const page = Math.max(1, parseInt(req.query.page as string, 10) || 1) const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20)) const statusFilter = req.query.status as string | undefined @@ -172,40 +173,35 @@ router.get("/", async (req: Request, res: Response) => { return } - try { - const db = await connectMongo() - const collection = getInvoicesCollection(db) - - const filter: Record = {} - if (status) filter.status = status - if (merchantFilter) filter.merchant_address = merchantFilter - - const total = await collection.countDocuments(filter) - const totalPages = Math.ceil(total / limit) - const skip = (page - 1) * limit - - const data = await collection - .find(filter) - .sort({ created_at: -1 }) - .skip(skip) - .limit(limit) - .toArray() - - const result = { - data, - total, - page, - limit, - totalPages, - } - - await cacheSet(cacheKey, result, 30) - res.json(result) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) - res.status(500).json({ error: message }) + const db = await connectMongo() + const collection = getInvoicesCollection(db) + + const filter: Record = {} + if (status) filter.status = status + if (merchantFilter) filter.merchant_address = merchantFilter + + const total = await collection.countDocuments(filter) + const totalPages = Math.ceil(total / limit) + const skip = (page - 1) * limit + + const data = await collection + .find(filter) + .sort({ created_at: -1 }) + .skip(skip) + .limit(limit) + .toArray() + + const result = { + data, + total, + page, + limit, + totalPages, } -}) + + await cacheSet(cacheKey, result, 30) + res.json(result) +})) /** * @openapi @@ -252,43 +248,35 @@ router.get("/", async (req: Request, res: Response) => { * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, res: Response) => { +router.get("/:id", validateParams(invoiceIdParamSchema), asyncHandler(async (req: Request, res: Response) => { const { id } = req.params - const env = requireEnv(res, { invoiceContractId: "INVOICE_CONTRACT_ID" }) - if (!env) return - - try { - const server = new SorobanRpc.Server(env.rpcUrl) - const contract = new Contract(env.invoiceContractId) - - // Build a read-only ledger entry query for the invoice - const ledgerKey = contract.getFootprint() - void ledgerKey // used below via getLedgerEntries - - // Query the contract's ledger entry directly - const entries = await server.getLedgerEntries( - xdr.LedgerKey.contractData( - new xdr.LedgerKeyContractData({ - contract: new Contract(env.invoiceContractId).address().toScAddress(), - key: nativeToScVal(BigInt(id), { type: "u64" }), - durability: xdr.ContractDataDurability.persistent(), - }) - ) - ) + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID" }) + + const server = new SorobanRpc.Server(env.rpcUrl) + const contract = new Contract(env.invoiceContractId) - if (!entries.entries.length) { - res.status(404).json({ error: "Invoice not found" }) - return - } + // Build a read-only ledger entry query for the invoice + const ledgerKey = contract.getFootprint() + void ledgerKey // used below via getLedgerEntries + + // Query the contract's ledger entry directly + const entries = await server.getLedgerEntries( + xdr.LedgerKey.contractData( + new xdr.LedgerKeyContractData({ + contract: new Contract(env.invoiceContractId).address().toScAddress(), + key: nativeToScVal(BigInt(id), { type: "u64" }), + durability: xdr.ContractDataDurability.persistent(), + }) + ) + ) - res.json({ invoice_id: id, status: "Pending" }) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) + if (!entries.entries.length) { + throw new NotFoundError("Invoice not found") } -}) + + res.json({ invoice_id: id, status: "Pending" }) +})) /** * @openapi @@ -362,45 +350,38 @@ router.get("/:id", validateParams(invoiceIdParamSchema), async (req: Request, re * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/", validateBody(createInvoiceSchema), async (req: Request, res: Response) => { - const env = requireEnv(res, { +router.post("/", validateBody(createInvoiceSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return - - try { - const client = buildSorobanClient(env.rpcUrl) - const result = await createInvoice( - req.body as CreateInvoiceBody, - client, - env.invoiceContractId, - env.signerSecret, - env.networkPassphrase - ) - const db = await connectMongo() - const collection = getInvoicesCollection(db) - const body = req.body as CreateInvoiceBody - const now = new Date() - await collection.insertOne({ - invoice_id: result.invoice_id, - merchant_address: body.merchant_address, - token: body.token, - amount: body.amount, - due_date: body.due_date, - reference: body.reference, - status: "Pending", - created_at: now, - updated_at: now, - }) - - res.status(201).json(result) - } catch (err: unknown) { - const status = (err as any)?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const client = buildSorobanClient(env.rpcUrl) + const result = await createInvoice( + req.body as CreateInvoiceBody, + client, + env.invoiceContractId, + env.signerSecret, + env.networkPassphrase + ) + + const db = await connectMongo() + const collection = getInvoicesCollection(db) + const body = req.body as CreateInvoiceBody + const now = new Date() + await collection.insertOne({ + invoice_id: result.invoice_id, + merchant_address: body.merchant_address, + token: body.token, + amount: body.amount, + due_date: body.due_date, + reference: body.reference, + status: "Pending", + created_at: now, + updated_at: now, + }) + + res.status(201).json(result) +})) export default router diff --git a/comebackhere-backend/src/routes/release-escrow.ts b/comebackhere-backend/src/routes/release-escrow.ts index 1b8d37e..1fef979 100644 --- a/comebackhere-backend/src/routes/release-escrow.ts +++ b/comebackhere-backend/src/routes/release-escrow.ts @@ -6,6 +6,7 @@ import { type SorobanClient, } from "../lib/soroban.js" import { requireEnv } from "../lib/env.js" +import { asyncHandler, ContractError, parseContractErrorCode, UnauthorizedError } from "../lib/errors.js" import { validateBody, validateParams } from "../middleware/validate.js" import { releaseEscrowIdParamSchema } from "../schemas/index.js" @@ -70,22 +71,20 @@ export async function releaseEscrow( * 503 required environment variables missing * 5xx unexpected Soroban / network error */ -router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), async (req: Request, res: Response) => { +router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), asyncHandler(async (req: Request, res: Response) => { // Admin-only authorization const adminKey = req.headers["x-admin-key"] if (!adminKey || adminKey !== process.env.ADMIN_KEY) { - res.status(401).json({ error: "Unauthorized" }) - return + throw new UnauthorizedError() } const { id } = req.params const invoiceId = parseInt(id, 10) - const env = requireEnv(res, { + const env = requireEnv({ invoiceContractId: "INVOICE_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return try { const result = await releaseEscrow(invoiceId, env) @@ -94,14 +93,11 @@ router.post("/:id/release-escrow", validateParams(releaseEscrowIdParamSchema), a const message = err instanceof Error ? err.message : String(err) // Contract error Unauthorized = 1 → 403 - if (message.includes("Error(Contract, #1)") || message.toUpperCase().includes("UNAUTHORIZED")) { - res.status(403).json({ error: "Forbidden: caller is not authorised to release this escrow", code: 1 }) - return + if (parseContractErrorCode(message) === 1 || message.toUpperCase().includes("UNAUTHORIZED")) { + throw new ContractError(1, "Forbidden: caller is not authorised to release this escrow", 403) } - - const status = (err as { status?: number })?.status ?? 500 - res.status(status).json({ error: message }) + throw err } -}) +})) export default router diff --git a/comebackhere-backend/src/routes/threshold.ts b/comebackhere-backend/src/routes/threshold.ts index 76c4d74..c408ef2 100644 --- a/comebackhere-backend/src/routes/threshold.ts +++ b/comebackhere-backend/src/routes/threshold.ts @@ -7,6 +7,7 @@ import { type SorobanClient, } from "../lib/soroban.js" import { requireEnv } from "../lib/env.js" +import { asyncHandler } from "../lib/errors.js" import { validateBody } from "../middleware/validate.js" import { thresholdSchema } from "../schemas/index.js" @@ -16,32 +17,25 @@ const router = Router() * GET /api/treasury/threshold * Returns the current approval threshold from the treasury contract. */ -router.get("/threshold", async (_req: Request, res: Response) => { - const env = requireEnv(res, { +router.get("/threshold", asyncHandler(async (_req: Request, res: Response) => { + const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return - try { - const client = buildSorobanClient(env.rpcUrl) - const sourceAccount = Keypair.fromSecret(env.signerSecret).publicKey() - const retval = await simulateContractRead( - client, - env.treasuryContractId, - "get_threshold", - [], - sourceAccount, - env.networkPassphrase, - ) - const threshold = Number(retval.u64()?.toString() ?? "0") - res.json({ threshold }) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const client = buildSorobanClient(env.rpcUrl) + const sourceAccount = Keypair.fromSecret(env.signerSecret).publicKey() + const retval = await simulateContractRead( + client, + env.treasuryContractId, + "get_threshold", + [], + sourceAccount, + env.networkPassphrase, + ) + const threshold = Number(retval.u64()?.toString() ?? "0") + res.json({ threshold }) +})) /** * POST /api/treasury/threshold @@ -76,23 +70,16 @@ export async function setThreshold( return { threshold, tx_hash: txHash } } -router.post("/threshold", validateBody(thresholdSchema), async (req: Request, res: Response) => { - const env = requireEnv(res, { +router.post("/threshold", validateBody(thresholdSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return const threshold = req.body.threshold - try { - const result = await setThreshold(threshold, env) - res.json(result) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const result = await setThreshold(threshold, env) + res.json(result) +})) export default router diff --git a/comebackhere-backend/src/routes/treasury.ts b/comebackhere-backend/src/routes/treasury.ts index 1c67a4f..95c0c1d 100644 --- a/comebackhere-backend/src/routes/treasury.ts +++ b/comebackhere-backend/src/routes/treasury.ts @@ -9,6 +9,7 @@ import { type SorobanClient, } from "../lib/soroban.js" import { requireEnv } from "../lib/env.js" +import { asyncHandler, NotFoundError } from "../lib/errors.js" import { connectMongo, getSettlementsCollection } from "../db/mongo.js" import { validateBody } from "../middleware/validate.js" import { @@ -72,31 +73,26 @@ export function invalidateBalanceCache(): void { * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.get("/pending-settlements", async (_req: Request, res: Response) => { - try { - const database = await connectMongo() - const settlements = getSettlementsCollection(database) - const records = await settlements - .find({ status: "Pending" }) - .sort({ id: 1 }) - .toArray() - - res.json( - records.map((s) => ({ - id: s.id, - merchant_address: s.merchant_address, - amount: s.amount, - approvals: s.approvals, - approval_weight: s.approval_weight, - status: s.status, - hold_reason: s.hold_reason, - })), - ) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) - res.status(500).json({ error: message }) - } -}) +router.get("/pending-settlements", asyncHandler(async (_req: Request, res: Response) => { + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const records = await settlements + .find({ status: "Pending" }) + .sort({ id: 1 }) + .toArray() + + res.json( + records.map((s) => ({ + id: s.id, + merchant_address: s.merchant_address, + amount: s.amount, + approvals: s.approvals, + approval_weight: s.approval_weight, + status: s.status, + hold_reason: s.hold_reason, + })), + ) +})) /** * @openapi @@ -136,54 +132,47 @@ router.get("/pending-settlements", async (_req: Request, res: Response) => { * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/approve-settlement", validateBody(settlementIdSchema), async (req: Request, res: Response) => { - const env = requireEnv(res, { +router.post("/approve-settlement", validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", usdcContractId: "USDC_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return const settlementId = req.body.settlement_id - try { - const client = buildSorobanClient(env.rpcUrl) - const keypair = Keypair.fromSecret(env.signerSecret) - - const txHash = await submitContractCall( - client, - env.treasuryContractId, - "approve_settlement", - [ - nativeToScVal(keypair.publicKey(), { type: "address" }), - nativeToScVal(BigInt(settlementId), { type: "u64" }), - ], - env.signerSecret, - env.networkPassphrase, - ) + const client = buildSorobanClient(env.rpcUrl) + const keypair = Keypair.fromSecret(env.signerSecret) - const database = await connectMongo() - const settlements = getSettlementsCollection(database) - const record = await settlements.findOne({ id: settlementId }) - - res.json( - record ?? { - id: settlementId, - merchant_address: "", - amount: "0", - approvals: [keypair.publicKey()], - approval_weight: 1, - status: "Pending", - hold_reason: null, - tx_hash: txHash, - }, - ) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const txHash = await submitContractCall( + client, + env.treasuryContractId, + "approve_settlement", + [ + nativeToScVal(keypair.publicKey(), { type: "address" }), + nativeToScVal(BigInt(settlementId), { type: "u64" }), + ], + env.signerSecret, + env.networkPassphrase, + ) + + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const record = await settlements.findOne({ id: settlementId }) + + res.json( + record ?? { + id: settlementId, + merchant_address: "", + amount: "0", + approvals: [keypair.publicKey()], + approval_weight: 1, + status: "Pending", + hold_reason: null, + tx_hash: txHash, + }, + ) +})) export interface ExecuteSettlementBody { settlement_id: number @@ -350,30 +339,23 @@ export async function executeSettlementWithBalanceCheck( * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/execute-settlement", validateBody(executeSettlementSchema), async (req: Request, res: Response) => { - const env = requireEnv(res, { +router.post("/execute-settlement", validateBody(executeSettlementSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", usdcContractId: "USDC_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return const { settlement_id: settlementId, token_contract } = req.body as { settlement_id: number; token_contract?: string } - try { - const result = await executeSettlementWithBalanceCheck( - { settlement_id: settlementId, token_contract }, - env, - ) - // #212 — balance changed; evict the cache so the next GET /balances is fresh - invalidateBalanceCache() - res.json(result) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const result = await executeSettlementWithBalanceCheck( + { settlement_id: settlementId, token_contract }, + env, + ) + // #212 — balance changed; evict the cache so the next GET /balances is fresh + invalidateBalanceCache() + res.json(result) +})) export interface SimulateSettlementBody { settlement_id: number @@ -504,21 +486,17 @@ export async function simulateSettlement( * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/simulate-settlement", validateBody(settlementIdSchema), async (req: Request, res: Response) => { - const env = requireEnv(res) - if (!env) return +router.post("/simulate-settlement", validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { + const env = requireEnv({ + treasuryContractId: "TREASURY_CONTRACT_ID", + signerSecret: "SIGNER_SECRET_KEY", + }) const settlementId = req.body.settlement_id - try { - const result = await simulateSettlement({ settlement_id: settlementId }, env) - res.json(result) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const result = await simulateSettlement({ settlement_id: settlementId }, env) + res.json(result) +})) /** * @openapi @@ -545,31 +523,26 @@ router.post("/simulate-settlement", validateBody(settlementIdSchema), async (req * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.get("/on-hold-settlements", async (_req: Request, res: Response) => { - try { - const database = await connectMongo() - const settlements = getSettlementsCollection(database) - const records = await settlements - .find({ status: "OnHold" }) - .sort({ id: 1 }) - .toArray() - - res.json( - records.map((s) => ({ - id: s.id, - merchant_address: s.merchant_address, - amount: s.amount, - approvals: s.approvals, - approval_weight: s.approval_weight, - status: s.status, - hold_reason: s.hold_reason, - })), - ) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) - res.status(500).json({ error: message }) - } -}) +router.get("/on-hold-settlements", asyncHandler(async (_req: Request, res: Response) => { + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const records = await settlements + .find({ status: "OnHold" }) + .sort({ id: 1 }) + .toArray() + + res.json( + records.map((s) => ({ + id: s.id, + merchant_address: s.merchant_address, + amount: s.amount, + approvals: s.approvals, + approval_weight: s.approval_weight, + status: s.status, + hold_reason: s.hold_reason, + })), + ) +})) /** * @openapi @@ -609,37 +582,31 @@ router.get("/on-hold-settlements", async (_req: Request, res: Response) => { * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/release-hold", validateBody(settlementIdSchema), async (req: Request, res: Response) => { +router.post("/release-hold", validateBody(settlementIdSchema), asyncHandler(async (req: Request, res: Response) => { const settlementId = req.body.settlement_id - try { - const database = await connectMongo() - const settlements = getSettlementsCollection(database) - const record = await settlements.findOneAndUpdate( - { id: settlementId, status: "OnHold" }, - { $set: { status: "Pending", hold_reason: null, updated_at: new Date() } }, - { returnDocument: "after" }, - ) + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const record = await settlements.findOneAndUpdate( + { id: settlementId, status: "OnHold" }, + { $set: { status: "Pending", hold_reason: null, updated_at: new Date() } }, + { returnDocument: "after" }, + ) - if (!record) { - res.status(404).json({ error: `Settlement #${settlementId} not found or not on hold` }) - return - } - - res.json({ - id: record.id, - merchant_address: record.merchant_address, - amount: record.amount, - approvals: record.approvals, - approval_weight: record.approval_weight, - status: record.status, - hold_reason: record.hold_reason, - }) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) - res.status(500).json({ error: message }) + if (!record) { + throw new NotFoundError(`Settlement #${settlementId} not found or not on hold`) } -}) + + res.json({ + id: record.id, + merchant_address: record.merchant_address, + amount: record.amount, + approvals: record.approvals, + approval_weight: record.approval_weight, + status: record.status, + hold_reason: record.hold_reason, + }) +})) /** * @openapi @@ -704,50 +671,43 @@ router.post("/release-hold", validateBody(settlementIdSchema), async (req: Reque * schema: * $ref: '#/components/schemas/ErrorResponse' */ -router.post("/escalate-hold", validateBody(escalateHoldSchema), async (req: Request, res: Response) => { +router.post("/escalate-hold", validateBody(escalateHoldSchema), asyncHandler(async (req: Request, res: Response) => { const settlementId = req.body.settlement_id - try { - const database = await connectMongo() - const settlements = getSettlementsCollection(database) - const record = await settlements.findOneAndUpdate( - { id: settlementId, status: "OnHold" }, - { $set: { hold_reason: "AdminHold", updated_at: new Date() } }, - { returnDocument: "after" }, - ) + const database = await connectMongo() + const settlements = getSettlementsCollection(database) + const record = await settlements.findOneAndUpdate( + { id: settlementId, status: "OnHold" }, + { $set: { hold_reason: "AdminHold", updated_at: new Date() } }, + { returnDocument: "after" }, + ) - if (!record) { - res.status(404).json({ error: `Settlement #${settlementId} not found or not on hold` }) - return - } - - res.json({ - id: record.id, - merchant_address: record.merchant_address, - amount: record.amount, - approvals: record.approvals, - approval_weight: record.approval_weight, - status: record.status, - hold_reason: record.hold_reason, - }) - } catch (err: unknown) { - const message = err instanceof Error ? err.message : String(err) - res.status(500).json({ error: message }) + if (!record) { + throw new NotFoundError(`Settlement #${settlementId} not found or not on hold`) } -}) + + res.json({ + id: record.id, + merchant_address: record.merchant_address, + amount: record.amount, + approvals: record.approvals, + approval_weight: record.approval_weight, + status: record.status, + hold_reason: record.hold_reason, + }) +})) /** * GET /api/treasury/balances * Returns token balances held by the treasury contract. * Results are cached for up to 5 seconds to reduce Soroban RPC load (#212). */ -router.get("/balances", async (_req: Request, res: Response) => { - const env = requireEnv(res, { +router.get("/balances", asyncHandler(async (_req: Request, res: Response) => { + const env = requireEnv({ treasuryContractId: "TREASURY_CONTRACT_ID", usdcContractId: "USDC_CONTRACT_ID", signerSecret: "SIGNER_SECRET_KEY", }) - if (!env) return // #212 — serve from cache when available const cached = getBalanceCache() @@ -756,27 +716,21 @@ router.get("/balances", async (_req: Request, res: Response) => { return } - try { - const client = buildSorobanClient(env.rpcUrl) - const keypair = Keypair.fromSecret(env.signerSecret) - const sourceAccount = keypair.publicKey() - - const balance = await getTokenBalance( - client, - env.usdcContractId, - env.treasuryContractId, - sourceAccount, - env.networkPassphrase, - ) + const client = buildSorobanClient(env.rpcUrl) + const keypair = Keypair.fromSecret(env.signerSecret) + const sourceAccount = keypair.publicKey() - const data = [{ token: env.usdcContractId, balance: balance.toString() }] - setBalanceCache(data) - res.json(data) - } catch (err: unknown) { - const status = (err as { status?: number })?.status ?? 500 - const message = err instanceof Error ? err.message : String(err) - res.status(status).json({ error: message }) - } -}) + const balance = await getTokenBalance( + client, + env.usdcContractId, + env.treasuryContractId, + sourceAccount, + env.networkPassphrase, + ) + + const data = [{ token: env.usdcContractId, balance: balance.toString() }] + setBalanceCache(data) + res.json(data) +})) export default router diff --git a/comebackhere-backend/src/tests/compliance-audit.test.ts b/comebackhere-backend/src/tests/compliance-audit.test.ts index d7f9eae..bcaa2d0 100644 --- a/comebackhere-backend/src/tests/compliance-audit.test.ts +++ b/comebackhere-backend/src/tests/compliance-audit.test.ts @@ -46,7 +46,7 @@ describe("GET /compliance/audit", () => { it("rejects an invalid ledger range", async () => { const response = await request(createApp()).get("/compliance/audit").query({ from_ledger: 20, to_ledger: 10 }) expect(response.status).toBe(400) - expect(response.body.error).toMatch(/from_ledger/i) + expect(response.body.error.message).toMatch(/from_ledger/i) }) it("rejects a limit above the public maximum", async () => { diff --git a/comebackhere-backend/src/tests/compliance.test.ts b/comebackhere-backend/src/tests/compliance.test.ts index 6ac64a8..533e283 100644 --- a/comebackhere-backend/src/tests/compliance.test.ts +++ b/comebackhere-backend/src/tests/compliance.test.ts @@ -232,7 +232,7 @@ describe("POST /compliance/allow", () => { .post("/compliance/allow") .send({ address: VALID_ADDRESS }) expect(res.status).toBe(401) - expect(res.body.error).toMatch(/unauthorized/i) + expect(res.body.error.message).toMatch(/unauthorized/i) }) it("401 when x-admin-key header is wrong", async () => { @@ -249,7 +249,7 @@ describe("POST /compliance/allow", () => { .set("x-admin-key", ADMIN_KEY) .send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/address/) + expect(res.body.error.message).toMatch(/address/) }) it("400 when address is not a valid Stellar public key", async () => { @@ -258,7 +258,7 @@ describe("POST /compliance/allow", () => { .set("x-admin-key", ADMIN_KEY) .send({ address: "NOT_A_STELLAR_KEY" }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/address/) + expect(res.body.error.message).toMatch(/address/) }) it("400 when until is provided but is not a positive integer", async () => { @@ -267,7 +267,7 @@ describe("POST /compliance/allow", () => { .set("x-admin-key", ADMIN_KEY) .send({ address: VALID_ADDRESS, until: -1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/until/) + expect(res.body.error.message).toMatch(/until/) }) it("503 when required env vars are missing", async () => { @@ -277,7 +277,7 @@ describe("POST /compliance/allow", () => { .set("x-admin-key", ADMIN_KEY) .send({ address: VALID_ADDRESS }) expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/i) + expect(res.body.error.message).toMatch(/misconfiguration/i) }) }) @@ -309,7 +309,7 @@ describe("POST /compliance/block", () => { .post("/compliance/block") .send({ address: VALID_ADDRESS }) expect(res.status).toBe(401) - expect(res.body.error).toMatch(/unauthorized/i) + expect(res.body.error.message).toMatch(/unauthorized/i) }) it("401 when x-admin-key header is wrong", async () => { @@ -326,7 +326,7 @@ describe("POST /compliance/block", () => { .set("x-admin-key", ADMIN_KEY) .send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/address/) + expect(res.body.error.message).toMatch(/address/) }) it("400 when address has invalid format (e.g. G... but not a real key)", async () => { @@ -335,7 +335,7 @@ describe("POST /compliance/block", () => { .set("x-admin-key", ADMIN_KEY) .send({ address: "GNOTAVALIDADDRESSATALL" }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/address/) + expect(res.body.error.message).toMatch(/address/) }) it("503 when required env vars are missing", async () => { @@ -345,6 +345,6 @@ describe("POST /compliance/block", () => { .set("x-admin-key", ADMIN_KEY) .send({ address: VALID_ADDRESS }) expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/i) + expect(res.body.error.message).toMatch(/misconfiguration/i) }) }) diff --git a/comebackhere-backend/src/tests/correlationId.test.ts b/comebackhere-backend/src/tests/correlationId.test.ts index 3c4b23e..897b487 100644 --- a/comebackhere-backend/src/tests/correlationId.test.ts +++ b/comebackhere-backend/src/tests/correlationId.test.ts @@ -2,20 +2,17 @@ import { describe, it, expect } from "vitest" import request from "supertest" import { createApp } from "../app.js" -// Attach a simple probe route so we can test the middleware in isolation -// without hitting any real route logic. +// /health has no route logic of its own, so it exercises the middleware in +// isolation. Unknown routes fall through to the central error handler, which +// echoes res.locals.requestId as the envelope's correlationId. function createTestApp() { - const app = createApp() - app.get("/probe", (_req, res) => { - res.status(200).json({ requestId: res.locals.requestId }) - }) - return app + return createApp() } describe("correlationIdMiddleware", () => { it("adds an X-Request-Id header to the response", async () => { const app = createTestApp() - const res = await request(app).get("/probe") + const res = await request(app).get("/health") expect(res.headers["x-request-id"]).toBeDefined() expect(typeof res.headers["x-request-id"]).toBe("string") expect(res.headers["x-request-id"].length).toBeGreaterThan(0) @@ -23,7 +20,7 @@ describe("correlationIdMiddleware", () => { it("generates a new UUID when the client does not supply X-Request-Id", async () => { const app = createTestApp() - const res = await request(app).get("/probe") + const res = await request(app).get("/health") // UUID v4 pattern expect(res.headers["x-request-id"]).toMatch( /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i, @@ -33,7 +30,7 @@ describe("correlationIdMiddleware", () => { it("preserves a client-supplied X-Request-Id rather than overwriting it", async () => { const clientId = "my-trace-id-12345" const app = createTestApp() - const res = await request(app).get("/probe").set("X-Request-Id", clientId) + const res = await request(app).get("/health").set("X-Request-Id", clientId) expect(res.headers["x-request-id"]).toBe(clientId) }) @@ -41,16 +38,17 @@ describe("correlationIdMiddleware", () => { it("stores the request ID in res.locals.requestId for downstream handlers", async () => { const clientId = "locals-check-id" const app = createTestApp() - const res = await request(app).get("/probe").set("X-Request-Id", clientId) + const res = await request(app).get("/does-not-exist").set("X-Request-Id", clientId) - expect(res.body.requestId).toBe(clientId) + expect(res.status).toBe(404) + expect(res.body.error.correlationId).toBe(clientId) }) it("generates a different ID for each request when no client ID is supplied", async () => { const app = createTestApp() const [res1, res2] = await Promise.all([ - request(app).get("/probe"), - request(app).get("/probe"), + request(app).get("/health"), + request(app).get("/health"), ]) expect(res1.headers["x-request-id"]).not.toBe(res2.headers["x-request-id"]) diff --git a/comebackhere-backend/src/tests/error-envelope.test.ts b/comebackhere-backend/src/tests/error-envelope.test.ts new file mode 100644 index 0000000..a7ff89d --- /dev/null +++ b/comebackhere-backend/src/tests/error-envelope.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest" +import request from "supertest" +import { createApp } from "../app.js" +import { toAppError } from "../middleware/errorHandler.js" +import { AppError, ContractError, NotFoundError, ValidationError } from "../lib/errors.js" + +function expectEnvelope(body: any) { + expect(Object.keys(body)).toEqual(["error"]) + expect(Object.keys(body.error).sort()).toEqual(["code", "correlationId", "details", "message"]) + expect(typeof body.error.code).toBe("string") + expect(typeof body.error.message).toBe("string") +} + +describe("standard error envelope", () => { + const app = createApp() + + it("wraps zod validation failures with field-level details", async () => { + const res = await request(app) + .post("/api/treasury/threshold") + .set("X-Request-Id", "req-validation") + .send({ threshold: -1 }) + + expect(res.status).toBe(400) + expectEnvelope(res.body) + expect(res.body.error.code).toBe("VALIDATION_ERROR") + expect(res.body.error.correlationId).toBe("req-validation") + expect(res.body.error.details).toEqual([{ field: "threshold", message: "Must be a positive integer" }]) + }) + + it("returns 404 NOT_FOUND for unknown routes", async () => { + const res = await request(app).get("/nope") + expect(res.status).toBe(404) + expectEnvelope(res.body) + expect(res.body.error.code).toBe("NOT_FOUND") + expect(res.body.error.correlationId).toBe(res.headers["x-request-id"]) + }) + + it("returns 400 INVALID_JSON for malformed JSON bodies", async () => { + const res = await request(app) + .post("/api/treasury/threshold") + .set("Content-Type", "application/json") + .send("{not json") + + expect(res.status).toBe(400) + expectEnvelope(res.body) + expect(res.body.error.code).toBe("INVALID_JSON") + expect(res.body.error.correlationId).toBe(res.headers["x-request-id"]) + }) + + describe("missing env vars", () => { + let backup: string | undefined + beforeEach(() => { + backup = process.env.SOROBAN_RPC_URL + delete process.env.SOROBAN_RPC_URL + }) + afterEach(() => { + if (backup !== undefined) process.env.SOROBAN_RPC_URL = backup + }) + + it("returns 503 SERVICE_MISCONFIGURED", async () => { + const res = await request(app).get("/api/treasury/threshold") + expect(res.status).toBe(503) + expectEnvelope(res.body) + expect(res.body.error.code).toBe("SERVICE_MISCONFIGURED") + expect(res.body.error.details).toBeNull() + }) + }) +}) + +describe("toAppError", () => { + it("passes typed errors through unchanged", () => { + const err = new NotFoundError("gone") + expect(toAppError(err)).toBe(err) + expect(new ValidationError("bad").status).toBe(400) + }) + + it("maps legacy status-tagged errors to a code by status", () => { + const err = toAppError(Object.assign(new Error("Transaction confirmation timeout"), { status: 504 })) + expect(err).toBeInstanceOf(AppError) + expect(err.status).toBe(504) + expect(err.code).toBe("GATEWAY_TIMEOUT") + expect(err.message).toBe("Transaction confirmation timeout") + }) + + it("extracts the contract code from Soroban host errors", () => { + const err = toAppError( + Object.assign(new Error("Soroban simulation failed: HostError: Error(Contract, #7) INSUFFICIENT_BALANCE"), { + status: 422, + }), + ) + expect(err).toBeInstanceOf(ContractError) + expect(err.status).toBe(422) + expect(err.code).toBe("CONTRACT_ERROR") + expect(err.details).toEqual({ contractCode: 7 }) + }) + + it("defaults unknown errors to 500 INTERNAL_ERROR", () => { + const err = toAppError("boom") + expect(err.status).toBe(500) + expect(err.code).toBe("INTERNAL_ERROR") + }) +}) diff --git a/comebackhere-backend/src/tests/invoice-settings.test.ts b/comebackhere-backend/src/tests/invoice-settings.test.ts index 1549f38..522aebd 100644 --- a/comebackhere-backend/src/tests/invoice-settings.test.ts +++ b/comebackhere-backend/src/tests/invoice-settings.test.ts @@ -49,7 +49,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: -1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("400 when grace_window_seconds is zero", async () => { @@ -58,7 +58,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: 0 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("400 when grace_window_seconds is a float", async () => { @@ -67,7 +67,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: 1.5 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("400 when grace_window_seconds is a string", async () => { @@ -76,7 +76,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: "86400" }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("400 when grace_window_seconds is missing", async () => { @@ -85,7 +85,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) // ── Upper-bound validation ─────────────────────────────────────────────────── @@ -96,8 +96,8 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: MAX_GRACE_WINDOW_SECONDS + 1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/must not exceed/) - expect(res.body.error).toContain(String(MAX_GRACE_WINDOW_SECONDS)) + expect(res.body.error.message).toMatch(/must not exceed/) + expect(res.body.error.message).toContain(String(MAX_GRACE_WINDOW_SECONDS)) }) it("400 when grace_window_seconds is a very large number (e.g., MAX_SAFE_INTEGER)", async () => { @@ -106,7 +106,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: Number.MAX_SAFE_INTEGER }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/must not exceed/) + expect(res.body.error.message).toMatch(/must not exceed/) }) // ── Valid boundary values ─────────────────────────────────────────────────── @@ -121,7 +121,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { // Should reach the env-check layer, not a validation layer expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/) + expect(res.body.error.message).toMatch(/misconfiguration/) }) it("reaches the Soroban layer (503 env error) with grace_window_seconds = 86400 (1 day)", async () => { @@ -131,7 +131,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: 86_400 }) expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/) + expect(res.body.error.message).toMatch(/misconfiguration/) }) it("reaches the Soroban layer (503 env error) with grace_window_seconds at the 30-day max", async () => { @@ -141,7 +141,7 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: MAX_GRACE_WINDOW_SECONDS }) expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/) + expect(res.body.error.message).toMatch(/misconfiguration/) }) // ── Error message is consumable by GraceWindowSettings ───────────────────── @@ -152,8 +152,8 @@ describe("POST /api/invoice/grace-window — boundary validation", () => { .send({ grace_window_seconds: -100 }) expect(res.body).toHaveProperty("error") - expect(typeof res.body.error).toBe("string") - expect(res.body.error.length).toBeGreaterThan(0) + expect(typeof res.body.error.message).toBe("string") + expect(res.body.error.message.length).toBeGreaterThan(0) }) }) diff --git a/comebackhere-backend/src/tests/invoices.test.ts b/comebackhere-backend/src/tests/invoices.test.ts index c131bdb..9b6cf26 100644 --- a/comebackhere-backend/src/tests/invoices.test.ts +++ b/comebackhere-backend/src/tests/invoices.test.ts @@ -79,46 +79,46 @@ describe("POST /invoices — HTTP layer", () => { const { merchant_address: _, ...body } = VALID_BODY const res = await request(app).post("/invoices").send(body) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/merchant_address/) + expect(res.body.error.message).toMatch(/merchant_address/) }) it("400 when merchant_address is not a valid Stellar key", async () => { const res = await request(app).post("/invoices").send({ ...VALID_BODY, merchant_address: "NOTAKEY" }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/merchant_address/) + expect(res.body.error.message).toMatch(/merchant_address/) }) it("400 when token is missing", async () => { const { token: _, ...body } = VALID_BODY const res = await request(app).post("/invoices").send(body) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/token/) + expect(res.body.error.message).toMatch(/token/) }) it("400 when amount is missing", async () => { const { amount: _, ...body } = VALID_BODY const res = await request(app).post("/invoices").send(body) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/amount/) + expect(res.body.error.message).toMatch(/amount/) }) it("400 when amount is zero or negative", async () => { const res = await request(app).post("/invoices").send({ ...VALID_BODY, amount: -1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/amount/) + expect(res.body.error.message).toMatch(/amount/) }) it("400 when due_date is missing", async () => { const { due_date: _, ...body } = VALID_BODY const res = await request(app).post("/invoices").send(body) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/due_date/) + expect(res.body.error.message).toMatch(/due_date/) }) it("400 when due_date is in the past", async () => { const res = await request(app).post("/invoices").send({ ...VALID_BODY, due_date: 1000 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/due_date/) + expect(res.body.error.message).toMatch(/due_date/) }) }) @@ -126,7 +126,7 @@ describe("POST /invoices — HTTP layer", () => { delete process.env.SOROBAN_RPC_URL const res = await request(app).post("/invoices").send(VALID_BODY) expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/) + expect(res.body.error.message).toMatch(/misconfiguration/) }) }) @@ -313,19 +313,19 @@ describe("GET /invoices — pagination", () => { it("400 when limit is not a positive integer", async () => { const res = await request(app).get("/invoices?limit=abc") expect(res.status).toBe(400) - expect(res.body.error).toMatch(/limit/) + expect(res.body.error.message).toMatch(/limit/) }) it("400 when limit is zero", async () => { const res = await request(app).get("/invoices?limit=0") expect(res.status).toBe(400) - expect(res.body.error).toMatch(/limit/) + expect(res.body.error.message).toMatch(/limit/) }) it("400 when offset is negative", async () => { const res = await request(app).get("/invoices?offset=-1") expect(res.status).toBe(400) - expect(res.body.error).toMatch(/offset/) + expect(res.body.error.message).toMatch(/offset/) }) it("returns empty data array with correct total when no invoices match", async () => { @@ -366,6 +366,6 @@ describe("GET /invoices — pagination", () => { const res = await request(app).get("/invoices") expect(res.status).toBe(500) - expect(res.body.error).toMatch(/db unavailable/) + expect(res.body.error.message).toMatch(/db unavailable/) }) }) \ No newline at end of file diff --git a/comebackhere-backend/src/tests/mongo-hardening.test.ts b/comebackhere-backend/src/tests/mongo-hardening.test.ts index 3218e0e..5b9f3b8 100644 --- a/comebackhere-backend/src/tests/mongo-hardening.test.ts +++ b/comebackhere-backend/src/tests/mongo-hardening.test.ts @@ -201,6 +201,6 @@ describe("GET /api/treasury/pending-settlements — Mongo outage returns 5xx", ( // whether the route catches the status property from the thrown error. expect(res.status).toBeGreaterThanOrEqual(500) expect(res.body).toHaveProperty("error") - expect(res.body.error).toMatch(/mongodb/i) + expect(res.body.error.message).toMatch(/mongodb/i) }, 10_000) }) diff --git a/comebackhere-backend/src/tests/rateLimiter.test.ts b/comebackhere-backend/src/tests/rateLimiter.test.ts index 14561ee..d9aff51 100644 --- a/comebackhere-backend/src/tests/rateLimiter.test.ts +++ b/comebackhere-backend/src/tests/rateLimiter.test.ts @@ -66,7 +66,7 @@ describe("Rate limiting — POST /invoices", () => { // Third request must be rate-limited const res = await request(app).post("/invoices").send(VALID_BODY) expect(res.status).toBe(429) - expect(res.body.error).toMatch(/too many requests/i) + expect(res.body.error.message).toMatch(/too many requests/i) expect(res.headers["retry-after"]).toBeDefined() expect(Number(res.headers["retry-after"])).toBeGreaterThan(0) }) @@ -78,8 +78,9 @@ describe("Rate limiting — POST /invoices", () => { const res = await request(app).post("/invoices").send(VALID_BODY) expect(res.status).toBe(429) - expect(typeof res.body.retryAfter).toBe("number") - expect(res.body.retryAfter).toBeGreaterThan(0) + expect(res.body.error.code).toBe("RATE_LIMITED") + expect(typeof res.body.error.details.retryAfter).toBe("number") + expect(res.body.error.details.retryAfter).toBeGreaterThan(0) }) it("includes X-RateLimit-Limit on normal (non-429) responses", async () => { diff --git a/comebackhere-backend/src/tests/release-escrow.test.ts b/comebackhere-backend/src/tests/release-escrow.test.ts index f65c838..83f1e03 100644 --- a/comebackhere-backend/src/tests/release-escrow.test.ts +++ b/comebackhere-backend/src/tests/release-escrow.test.ts @@ -79,7 +79,7 @@ describe("POST /invoices/:id/release-escrow — HTTP layer", () => { it("401 when x-admin-key header is missing", async () => { const res = await request(app).post("/invoices/1/release-escrow").send() expect(res.status).toBe(401) - expect(res.body.error).toMatch(/Unauthorized/) + expect(res.body.error.message).toMatch(/Unauthorized/) }) it("401 when x-admin-key header is wrong", async () => { @@ -88,7 +88,7 @@ describe("POST /invoices/:id/release-escrow — HTTP layer", () => { .set("x-admin-key", "wrong-key") .send() expect(res.status).toBe(401) - expect(res.body.error).toMatch(/Unauthorized/) + expect(res.body.error.message).toMatch(/Unauthorized/) }) it("400 when id is not a positive integer", async () => { @@ -97,7 +97,7 @@ describe("POST /invoices/:id/release-escrow — HTTP layer", () => { .set("x-admin-key", ADMIN_KEY) .send() expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("400 when id is zero", async () => { @@ -106,7 +106,7 @@ describe("POST /invoices/:id/release-escrow — HTTP layer", () => { .set("x-admin-key", ADMIN_KEY) .send() expect(res.status).toBe(400) - expect(res.body.error).toMatch(/positive integer/) + expect(res.body.error.message).toMatch(/positive integer/) }) it("503 when required env vars are missing", async () => { @@ -116,7 +116,7 @@ describe("POST /invoices/:id/release-escrow — HTTP layer", () => { .set("x-admin-key", ADMIN_KEY) .send() expect(res.status).toBe(503) - expect(res.body.error).toMatch(/misconfiguration/) + expect(res.body.error.message).toMatch(/misconfiguration/) }) }) @@ -179,7 +179,7 @@ describe("POST /invoices/:id/release-escrow — authorization", () => { .send() expect(res.status).toBe(401) - expect(res.body.error).toMatch(/Unauthorized/) + expect(res.body.error.message).toMatch(/Unauthorized/) }) it("401 — unauthorized caller with wrong x-admin-key is rejected (unauthorized-caller-rejection)", async () => { @@ -190,7 +190,7 @@ describe("POST /invoices/:id/release-escrow — authorization", () => { .send() expect(res.status).toBe(401) - expect(res.body.error).toMatch(/Unauthorized/) + expect(res.body.error.message).toMatch(/Unauthorized/) }) it("403 — on-chain Unauthorized contract error maps to 403 Forbidden", async () => { @@ -213,8 +213,9 @@ describe("POST /invoices/:id/release-escrow — authorization", () => { .send() expect(res.status).toBe(403) - expect(res.body.error).toMatch(/authoris/) - expect(res.body.code).toBe(1) + expect(res.body.error.message).toMatch(/authoris/) + expect(res.body.error.code).toBe("CONTRACT_ERROR") + expect(res.body.error.details).toEqual({ contractCode: 1 }) }) }) diff --git a/comebackhere-backend/src/tests/treasury.test.ts b/comebackhere-backend/src/tests/treasury.test.ts index a72e175..cfae6f8 100644 --- a/comebackhere-backend/src/tests/treasury.test.ts +++ b/comebackhere-backend/src/tests/treasury.test.ts @@ -72,7 +72,7 @@ describe("POST /api/treasury/execute-settlement", () => { it("400 when settlement_id is missing", async () => { const res = await request(app).post("/api/treasury/execute-settlement").send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("503 when required env vars are missing", async () => { @@ -173,7 +173,7 @@ describe("invoice grace window routes", () => { .post("/api/invoice/grace-window") .send({ grace_window_seconds: -1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/grace_window_seconds/) + expect(res.body.error.message).toMatch(/grace_window_seconds/) }) }) @@ -442,7 +442,7 @@ describe("POST /api/treasury/simulate-settlement", () => { it("400 when settlement_id is missing", async () => { const res = await request(app).post("/api/treasury/simulate-settlement").send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("503 when required env vars are missing", async () => { @@ -481,7 +481,7 @@ describe("POST /api/treasury/execute-settlement — HTTP layer additional cases" .post("/api/treasury/execute-settlement") .send({ settlement_id: 1.5 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("400 when settlement_id is zero", async () => { @@ -489,7 +489,7 @@ describe("POST /api/treasury/execute-settlement — HTTP layer additional cases" .post("/api/treasury/execute-settlement") .send({ settlement_id: 0 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) }) @@ -571,7 +571,7 @@ describe("GET /api/treasury/on-hold-settlements", () => { const res = await request(app).get("/api/treasury/on-hold-settlements") expect(res.status).toBe(500) - expect(res.body.error).toMatch(/mongo down/) + expect(res.body.error.message).toMatch(/mongo down/) }) }) @@ -598,7 +598,7 @@ describe("POST /api/treasury/release-hold", () => { it("400 when settlement_id is missing", async () => { const res = await request(app).post("/api/treasury/release-hold").send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("400 when settlement_id is zero", async () => { @@ -606,7 +606,7 @@ describe("POST /api/treasury/release-hold", () => { .post("/api/treasury/release-hold") .send({ settlement_id: 0 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("404 when settlement is not found or not on hold", async () => { @@ -621,7 +621,7 @@ describe("POST /api/treasury/release-hold", () => { .post("/api/treasury/release-hold") .send({ settlement_id: 999 }) expect(res.status).toBe(404) - expect(res.body.error).toMatch(/not found or not on hold/i) + expect(res.body.error.message).toMatch(/not found or not on hold/i) }) it("200 and returns updated record when successfully released", async () => { @@ -686,7 +686,7 @@ describe("POST /api/treasury/release-hold", () => { .post("/api/treasury/release-hold") .send({ settlement_id: 1 }) expect(res.status).toBe(500) - expect(res.body.error).toMatch(/db failure/) + expect(res.body.error.message).toMatch(/db failure/) }) }) @@ -713,7 +713,7 @@ describe("POST /api/treasury/escalate-hold", () => { it("400 when settlement_id is missing", async () => { const res = await request(app).post("/api/treasury/escalate-hold").send({}) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("400 when settlement_id is not a positive integer", async () => { @@ -721,7 +721,7 @@ describe("POST /api/treasury/escalate-hold", () => { .post("/api/treasury/escalate-hold") .send({ settlement_id: -1 }) expect(res.status).toBe(400) - expect(res.body.error).toMatch(/settlement_id/) + expect(res.body.error.message).toMatch(/settlement_id/) }) it("404 when settlement is not found or not on hold", async () => { @@ -736,7 +736,7 @@ describe("POST /api/treasury/escalate-hold", () => { .post("/api/treasury/escalate-hold") .send({ settlement_id: 999 }) expect(res.status).toBe(404) - expect(res.body.error).toMatch(/not found or not on hold/i) + expect(res.body.error.message).toMatch(/not found or not on hold/i) }) it("200 and returns escalated record with hold_reason: AdminHold", async () => { @@ -806,7 +806,7 @@ describe("POST /api/treasury/escalate-hold", () => { .post("/api/treasury/escalate-hold") .send({ settlement_id: 70 }) expect(res.status).toBe(404) - expect(res.body.error).toMatch(/not found or not on hold/i) + expect(res.body.error.message).toMatch(/not found or not on hold/i) }) it("500 on database error", async () => { @@ -816,6 +816,6 @@ describe("POST /api/treasury/escalate-hold", () => { .post("/api/treasury/escalate-hold") .send({ settlement_id: 1 }) expect(res.status).toBe(500) - expect(res.body.error).toMatch(/connection lost/) + expect(res.body.error.message).toMatch(/connection lost/) }) }) \ No newline at end of file diff --git a/docs/api-reference.md b/docs/api-reference.md index a6018d1..543dae4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -12,6 +12,10 @@ All responses are JSON. > **Rate limits:** All endpoints are subject to per-IP rate limiting. See > [docs/rate-limits.md](./rate-limits.md) for default limits, configuration, > and the 429 response shape. +> +> **Errors:** Every error uses one envelope, +> `{ "error": { "code", "message", "details", "correlationId" } }`. See +> [Error response shape](#error-response-shape). --- @@ -120,7 +124,7 @@ Create a new invoice by submitting `create_invoice` to the Soroban RPC. | Status | Description | | ------ | -------------------------------------------------------------- | -| `400` | Validation error — see `error` field for detail | +| `400` | Validation error — see `error.details` for field-level detail | | `422` | Soroban simulation or transaction failure | | `503` | Missing required environment variables | | `504` | Transaction confirmation timeout | @@ -166,7 +170,7 @@ Raise a dispute linked to a settlement, transitioning it to `OnHold`. | Status | Description | | ------ | -------------------------------------------------------------- | -| `400` | Validation error — see `error` field for detail | +| `400` | Validation error — see `error.details` for field-level detail | | `503` | Missing required environment variables | | `500` | Unexpected server error | @@ -649,12 +653,55 @@ webhook delivery is skipped silently (no error). ## Error response shape -All error responses share this shape: +Every non-2xx response, from every endpoint, uses the same envelope: ```json -{ "error": "Human-readable description of the error." } +{ + "error": { + "code": "VALIDATION_ERROR", + "message": "settlement_id: Must be a positive integer", + "details": [{ "field": "settlement_id", "message": "Must be a positive integer" }], + "correlationId": "5f1c9a8e-2b7d-4c1e-9a3f-0d2e6b7c8a91" + } +} ``` +| Field | Type | Description | +| --------------- | -------------- | --------------------------------------------------------------------------------------------- | +| `code` | string | Stable, machine-readable error code (see below). Branch on this, not on `message`. | +| `message` | string | Human-readable description. May change between releases. | +| `details` | any \| null | Extra structured context; shape depends on `code`. `null` when there is nothing to add. | +| `correlationId` | string \| null | Same value as the `X-Request-Id` response header. Quote it when contacting support. | + +Clients may send their own `X-Request-Id` header; it is echoed back as both the +header and `correlationId`. Otherwise the server generates a UUID v4. + +### Error codes + +| HTTP | `code` | When | `details` | +| ---- | ----------------------- | ---------------------------------------------------------------- | -------------------------------------- | +| 400 | `VALIDATION_ERROR` | Body, path or query parameters failed schema validation | `[{ field, message }]`, one per issue | +| 400 | `INVALID_JSON` | Request body is not valid JSON | `null` | +| 401 | `UNAUTHORIZED` | Missing or invalid `x-admin-key` | `null` | +| 403 | `FORBIDDEN` | Caller lacks permission | `null` | +| 404 | `NOT_FOUND` | Resource or route does not exist | `null` | +| 409 | `CONFLICT` | Request conflicts with current state (e.g. dispute already resolved) | Endpoint-specific, e.g. `{ outcome }` | +| 4xx/5xx | `CONTRACT_ERROR` | A Soroban contract returned `Error(Contract, #N)` | `{ contractCode: N }` — see [error-codes.md](./error-codes.md) | +| 422 | `UNPROCESSABLE_ENTITY` | Soroban simulation / submission failed without a contract code | `null` | +| 429 | `RATE_LIMITED` | Per-IP rate limit exceeded | `{ retryAfter }` (seconds) | +| 500 | `INTERNAL_ERROR` | Unexpected server error | `null` | +| 503 | `SERVICE_MISCONFIGURED` | Required environment variables are missing | `null` | +| 503 | `SERVICE_UNAVAILABLE` | A dependency (e.g. MongoDB) is unreachable | `null` | +| 504 | `GATEWAY_TIMEOUT` | Timed out waiting for Soroban transaction confirmation | `null` | + +### Server implementation + +Routes do not build error responses by hand. They throw a typed error from +`comebackhere-backend/src/lib/errors.ts` (`ValidationError`, `NotFoundError`, +`ConflictError`, `UnauthorizedError`, `ContractError`, …) and the central +handler in `src/middleware/errorHandler.ts` renders the envelope. Async +handlers are wrapped in `asyncHandler` so rejected promises reach it. + ## Environment variables | Variable | Description | diff --git a/docs/error-codes.md b/docs/error-codes.md index 6e1e4df..0f707aa 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -126,16 +126,23 @@ Defined in `COMEBACKHERE-contracts/contracts/treasury/src/lib.rs`. ## Error shape in API responses -Backend endpoints return errors as JSON: +Backend endpoints return errors in the standard envelope (see +[api-reference.md § Error response shape](./api-reference.md#error-response-shape)). +When a contract rejects a call, `code` is `CONTRACT_ERROR` and +`details.contractCode` holds the numeric value from the tables above: ```json { - "error": "Human-readable message", - "code": 6 + "error": { + "code": "CONTRACT_ERROR", + "message": "Soroban simulation failed: HostError: Error(Contract, #6) ...", + "details": { "contractCode": 6 }, + "correlationId": "5f1c9a8e-2b7d-4c1e-9a3f-0d2e6b7c8a91" + } } ``` -`code` corresponds directly to the numeric values in the tables above. When `code` is `null` or absent the error originates from the RPC layer rather than the contract. +Any other `code` means the error originates from validation, configuration, or the RPC layer rather than the contract. --- diff --git a/docs/rate-limits.md b/docs/rate-limits.md index fc76185..167c819 100644 --- a/docs/rate-limits.md +++ b/docs/rate-limits.md @@ -74,15 +74,21 @@ shape: ```json { - "error": "Too many requests. Please retry after the indicated number of seconds.", - "retryAfter": 12 + "error": { + "code": "RATE_LIMITED", + "message": "Too many requests. Please retry after the indicated number of seconds.", + "details": { "retryAfter": 12 }, + "correlationId": "5f1c9a8e-2b7d-4c1e-9a3f-0d2e6b7c8a91" + } } ``` | Field | Type | Description | | --- | --- | --- | -| `error` | string | Human-readable message. | -| `retryAfter` | number | Seconds to wait before retrying. | +| `error.code` | string | Always `RATE_LIMITED`. | +| `error.message` | string | Human-readable message. | +| `error.details.retryAfter` | number | Seconds to wait before retrying. | +| `error.correlationId` | string | Same as the `X-Request-Id` response header. | The response also includes a `Retry-After` header with the same integer value, plus `X-RateLimit-Limit`, `X-RateLimit-Remaining: 0`, and `X-RateLimit-Reset`. diff --git a/frontend/src/components/GraceWindowSettings/GraceWindowSettings.tsx b/frontend/src/components/GraceWindowSettings/GraceWindowSettings.tsx index 1689129..53cf5a7 100644 --- a/frontend/src/components/GraceWindowSettings/GraceWindowSettings.tsx +++ b/frontend/src/components/GraceWindowSettings/GraceWindowSettings.tsx @@ -81,7 +81,7 @@ export default function GraceWindowSettings() { const res = await fetch(`${API_BASE}/invoice/grace-window`) if (!res.ok) { const body = await res.json().catch(() => ({})) - throw new Error(body.error ?? `HTTP ${res.status}`) + throw new Error(body.error?.message ?? `HTTP ${res.status}`) } const data: { grace_window_seconds: number } = await res.json() setCurrentSeconds(data.grace_window_seconds) @@ -135,7 +135,7 @@ export default function GraceWindowSettings() { }) const body = await res.json().catch(() => ({})) if (!res.ok) { - throw new Error(body.error ?? `HTTP ${res.status}`) + throw new Error(body.error?.message ?? `HTTP ${res.status}`) } setCurrentSeconds(body.grace_window_seconds) setSuccess( diff --git a/frontend/src/components/TreasuryManagerPage/TreasuryManagerPage.tsx b/frontend/src/components/TreasuryManagerPage/TreasuryManagerPage.tsx index 9b044b7..fe1021c 100644 --- a/frontend/src/components/TreasuryManagerPage/TreasuryManagerPage.tsx +++ b/frontend/src/components/TreasuryManagerPage/TreasuryManagerPage.tsx @@ -32,7 +32,7 @@ async function postTreasuryAction( body: JSON.stringify(body), }) const data = await res.json() - if (!res.ok) return { success: false, error: data.error ?? `HTTP ${res.status}` } + if (!res.ok) return { success: false, error: data.error?.message ?? `HTTP ${res.status}` } return { success: true, hash: data.tx_hash } } From f5c3dc10ae77bba0b0559c279e87b985a38c9097 Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Fri, 25 Sep 2026 04:32:08 +0000 Subject: [PATCH 2/4] fix(api): add security headers and body limit - Add helmet for a baseline of security headers (HSTS, nosniff, Referrer-Policy, frame options, ...) and drop X-Powered-By. - Use a strict CSP (default-src 'none', frame-ancestors 'none') for the JSON API, with a narrowly relaxed policy under /api-docs so Swagger UI can still load its same-origin scripts, inline styles and data: images. - Cap express.json() at 100kb. Oversized bodies return 413 in the standard envelope (code PAYLOAD_TOO_LARGE, details.limitBytes) instead of Express's default HTML page. - Add tests for the 413 path, header presence, and Swagger UI assets. Co-Authored-By: Claude Opus 5.5 --- comebackhere-backend/package.json | 1 + comebackhere-backend/src/app.ts | 44 +++++++++- .../src/middleware/errorHandler.ts | 13 ++- .../src/tests/security-headers.test.ts | 85 +++++++++++++++++++ docs/api-reference.md | 11 ++- 5 files changed, 150 insertions(+), 4 deletions(-) create mode 100644 comebackhere-backend/src/tests/security-headers.test.ts diff --git a/comebackhere-backend/package.json b/comebackhere-backend/package.json index 1cf13e6..7b4b7a1 100644 --- a/comebackhere-backend/package.json +++ b/comebackhere-backend/package.json @@ -12,6 +12,7 @@ }, "dependencies": { "express": "^4.18.2", + "helmet": "^8.3.0", "ioredis": "^5.3.2", "mongodb": "^6.12.0", "rate-limiter-flexible": "^2.4.2", diff --git a/comebackhere-backend/src/app.ts b/comebackhere-backend/src/app.ts index 2fb96f5..3e445cb 100644 --- a/comebackhere-backend/src/app.ts +++ b/comebackhere-backend/src/app.ts @@ -1,4 +1,5 @@ import express from "express" +import helmet from "helmet" import swaggerUi from "swagger-ui-express" import invoicesRouter from "./routes/invoices.js" import complianceRouter from "./routes/compliance.js" @@ -14,13 +15,54 @@ import { correlationIdMiddleware } from "./middleware/correlationId.js" import { errorHandler, notFoundHandler } from "./middleware/errorHandler.js" import { openapiSpec } from "./openapi.js" +/** Maximum accepted JSON body size; larger requests get a 413 envelope. */ +export const JSON_BODY_LIMIT = "100kb" + +// This is a JSON API, so by default nothing may be loaded, framed or executed. +const apiHelmet = helmet({ + contentSecurityPolicy: { + useDefaults: false, + directives: { + defaultSrc: ["'none'"], + frameAncestors: ["'none'"], + baseUri: ["'none'"], + formAction: ["'none'"], + }, + }, +}) + +// Swagger UI serves its JS/CSS from same-origin files but also uses inline +//