Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,8 @@ INVOICE_CONTRACT_ID=C...
SIGNER_SECRET_KEY=S...
NETWORK_PASSPHRASE=Standalone Network ; February 2025
SOROBAN_RPC_URL=http://localhost:8000/soroban/rpc

# CORS — comma-separated allowlist of browser origins allowed to call the API.
# Each entry is a bare origin (scheme://host[:port]); no paths, trailing slash or "*".
# Requests from any other origin are rejected with 403.
CORS_ORIGINS=http://localhost:5173
5 changes: 5 additions & 0 deletions .env.mainnet.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,8 @@ USDC_CONTRACT_ID=C...
REDIS_URL=redis://localhost:6379
RATE_LIMIT_POINTS=60
RATE_LIMIT_DURATION=60

# CORS — comma-separated allowlist of browser origins allowed to call the API.
# Each entry is a bare origin (scheme://host[:port]); no paths, trailing slash or "*".
# List only production frontends here; never use a wildcard on mainnet.
CORS_ORIGINS=https://app.your-frontend.example
4 changes: 4 additions & 0 deletions .env.testnet.example
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ USDC_CONTRACT_ID=C...
REDIS_URL=redis://localhost:6379
RATE_LIMIT_POINTS=60
RATE_LIMIT_DURATION=60

# CORS — comma-separated allowlist of browser origins allowed to call the API.
# Each entry is a bare origin (scheme://host[:port]); no paths, trailing slash or "*".
CORS_ORIGINS=https://testnet.your-frontend.example
1 change: 1 addition & 0 deletions comebackhere-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"zod": "^4.4.3"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^4.17.21",
"@types/ioredis": "^5.0.0",
"@types/node": "^20.11.0",
Expand Down
66 changes: 63 additions & 3 deletions comebackhere-backend/src/app.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -11,15 +12,69 @@ 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 { createCorsMiddleware } from "./middleware/cors.js"
import { parseCorsOrigins } from "./lib/env.js"
import { openapiSpec } from "./openapi.js"
import { renderMetrics } from "./lib/metrics.js"

export function createApp() {
/** 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
// <style> blocks and data: images, and "Try it out" calls back into the API.
const swaggerHelmet = helmet({
contentSecurityPolicy: {
useDefaults: false,
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:"],
connectSrc: ["'self'"],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
baseUri: ["'self'"],
formAction: ["'self'"],
},
},
})

export interface CreateAppOptions {
/** Origins allowed to call the API cross-origin. Defaults to CORS_ORIGINS. */
corsOrigins?: readonly string[]
}

export function createApp(options: CreateAppOptions = {}) {
const corsOrigins = options.corsOrigins ?? parseCorsOrigins()
const app = express()
app.use(express.json())
app.disable("x-powered-by")
// 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((req, res, next) =>
req.path === "/api-docs" || req.path.startsWith("/api-docs/")
? swaggerHelmet(req, res, next)
: apiHelmet(req, res, next),
)
// CORS runs before body parsing and rate limiting so preflights are cheap
// and disallowed origins are rejected before any work is done.
app.use(createCorsMiddleware(corsOrigins))
app.use(express.json({ limit: JSON_BODY_LIMIT }))
app.use(rateLimitMiddleware)

// ── Health ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -48,6 +103,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()
Expand Down
70 changes: 59 additions & 11 deletions comebackhere-backend/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Response } from "express"
import { ServiceMisconfiguredError } from "./errors.js"
import { getNetworkPassphrase } from "./soroban.js"

/**
Expand All @@ -12,8 +12,6 @@ export type ContractEnv<P extends Record<string, string>> = {
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`.
*
Expand All @@ -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<P extends Record<string, string>>(
res: Response,
vars: P,
): ContractEnv<P> | null {
export function requireEnv<P extends Record<string, string>>(vars: P): ContractEnv<P> {
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(
Expand All @@ -48,3 +42,57 @@ export function requireEnv<P extends Record<string, string>>(
...values,
}
}

/**
* Parses and validates `CORS_ORIGINS`, a comma-separated allowlist of origins
* permitted to call the API from a browser, e.g.
* `http://localhost:5173,https://app.example.com`.
*
* Each entry must be a bare http(s) origin — scheme, host and optional port,
* with no path, query or trailing slash. Wildcards are rejected because the
* API has authenticated routes. Unset or empty means no cross-origin browser
* access is allowed (same-origin and non-browser clients are unaffected).
*
* Throws with every invalid entry listed so misconfiguration fails at startup.
*/
export function parseCorsOrigins(raw: string | undefined = process.env.CORS_ORIGINS): string[] {
const entries = (raw ?? "")
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry !== "")

const invalid: string[] = []
const origins = new Set<string>()
for (const entry of entries) {
let url: URL | null = null
try {
url = new URL(entry)
} catch {
url = null
}
const isBareOrigin =
url !== null &&
!entry.includes("*") &&
(url.protocol === "http:" || url.protocol === "https:") &&
url.pathname === "/" &&
!entry.endsWith("/") &&
url.search === "" &&
url.hash === "" &&
url.username === "" &&
url.password === ""
if (!isBareOrigin) {
invalid.push(entry)
continue
}
origins.add(url!.origin)
}

if (invalid.length > 0) {
throw new Error(
`Invalid CORS_ORIGINS entries: ${invalid.map((e) => JSON.stringify(e)).join(", ")}. ` +
"Each entry must be an http(s) origin such as https://app.example.com (no path, trailing slash or wildcard).",
)
}

return [...origins]
}
115 changes: 115 additions & 0 deletions comebackhere-backend/src/lib/errors.ts
Original file line number Diff line number Diff line change
@@ -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<Req extends Request = Request>(
fn: (req: Req, res: Response, next: NextFunction) => Promise<unknown>,
): RequestHandler {
return (req, res, next) => {
fn(req as Req, res, next).catch(next)
}
}
57 changes: 57 additions & 0 deletions comebackhere-backend/src/middleware/cors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import cors from "cors"
import type { RequestHandler } from "express"
import { AppError } from "../lib/errors.js"

/** Request headers the frontend sends and preflight must allow. */
export const CORS_ALLOWED_HEADERS = [
"Content-Type",
"Authorization",
"Idempotency-Key",
"X-Request-Id",
"X-Admin-Key",
]

/** Response headers browsers may read from cross-origin responses. */
export const CORS_EXPOSED_HEADERS = [
"X-Request-Id",
"X-RateLimit-Limit",
"X-RateLimit-Remaining",
"X-RateLimit-Reset",
"Retry-After",
]

export class CorsOriginError extends AppError {
constructor(origin: string) {
super(403, "CORS_ORIGIN_NOT_ALLOWED", `Origin ${origin} is not allowed to access this API`, { origin })
}
}

/**
* CORS middleware backed by an explicit origin allowlist (see
* `parseCorsOrigins` in lib/env.ts).
*
* - Requests without an `Origin` header (same-origin, curl, server-to-server)
* pass through untouched.
* - Allowlisted origins get the usual `Access-Control-*` headers, and
* preflight requests are answered with 204.
* - Any other origin — including preflight — is rejected with 403 in the
* standard error envelope.
*/
export function createCorsMiddleware(allowedOrigins: readonly string[]): RequestHandler {
const allowed = new Set(allowedOrigins)

return cors({
origin(origin, callback) {
if (!origin || allowed.has(origin)) {
callback(null, true)
return
}
callback(new CorsOriginError(origin))
},
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: CORS_ALLOWED_HEADERS,
exposedHeaders: CORS_EXPOSED_HEADERS,
maxAge: 600,
optionsSuccessStatus: 204,
})
}
Loading