diff --git a/.gitignore b/.gitignore index 5b0adb6..9088c65 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ dist/ data/ .agents/ issue.md -skills-lock.json \ No newline at end of file +skills-lock.json +# Exit-code artifacts written by verify-ci.js +*.exit diff --git a/__tests__/partial-release.test.ts b/__tests__/partial-release.test.ts index 26185fb..0fb34e0 100644 --- a/__tests__/partial-release.test.ts +++ b/__tests__/partial-release.test.ts @@ -30,6 +30,64 @@ function buildApp() { const ENDPOINT = `/api/jobs/${VALID_CONTRACT}/milestones/0/partial-release`; const VALID_BODY = { amount: "100", sourceAddress: VALID_ADDRESS }; +describe("POST /api/jobs/:contractId/milestones/:index/partial-release – CORS and security headers", () => { + it("rejects requests from unauthorized origins", async () => { + const res = await request(buildApp()) + .post(ENDPOINT) + .set("Origin", "http://malicious.com") + .send(VALID_BODY) + .expect(403); + + expect(res.body).toEqual({ + success: false, + error: "Origin not allowed by CORS policy", + }); + expect(res.headers["access-control-allow-origin"]).toBeUndefined(); + }); + + it("allows trusted origins and sets CORS response headers", async () => { + mockGetAccount.mockResolvedValue({ + accountId: () => VALID_ADDRESS, + sequenceNumber: () => "1", + incrementSequenceNumber: () => {}, + }); + mockPrepareTransaction.mockResolvedValue({ toXDR: () => "AAAAAQ==" }); + + const res = await request(buildApp()) + .post(ENDPOINT) + .set("Origin", "http://localhost:3000") + .send(VALID_BODY) + .expect(200); + + expect(res.headers["access-control-allow-origin"]).toBe("http://localhost:3000"); + expect(res.headers["access-control-allow-methods"]).toContain("POST"); + expect(res.headers["access-control-allow-methods"]).toContain("OPTIONS"); + expect(res.body.success).toBe(true); + }); + + it("sets required security headers on the response", async () => { + mockGetAccount.mockResolvedValue({ + accountId: () => VALID_ADDRESS, + sequenceNumber: () => "1", + incrementSequenceNumber: () => {}, + }); + mockPrepareTransaction.mockResolvedValue({ toXDR: () => "AAAAAQ==" }); + + const res = await request(buildApp()) + .post(ENDPOINT) + .set("Origin", "http://localhost:3000") + .send(VALID_BODY) + .expect(200); + + expect(res.headers["x-content-type-options"]).toBe("nosniff"); + expect(res.headers["x-frame-options"]).toBe("DENY"); + expect(res.headers["referrer-policy"]).toBe("no-referrer"); + expect(res.headers["x-xss-protection"]).toBe("0"); + expect(res.headers["content-security-policy"]).toBe("default-src 'none'"); + expect(res.headers["permissions-policy"]).toContain("camera=()"); + }); +}); + describe("POST /api/jobs/:contractId/milestones/:index/partial-release", () => { beforeEach(() => { mockGetAccount.mockReset(); diff --git a/__tests__/whitelist.test.ts b/__tests__/whitelist.test.ts index 4cb0913..35dad6e 100644 --- a/__tests__/whitelist.test.ts +++ b/__tests__/whitelist.test.ts @@ -507,6 +507,18 @@ describe("GET /api/jobs/:contractId/whitelist", () => { expect(res.body.details[0].field).toBe("contractId"); }); + it("returns 400 for a contractId with an invalid Stellar checksum", async () => { + const invalidChecksum = VALID_CONTRACT.slice(0, -1) + "A"; + const res = await request(buildApp()) + .get(`/api/jobs/${invalidChecksum}/whitelist`) + .expect(400); + + expect(res.body.success).toBe(false); + expect(res.body.error).toBe("ValidationError"); + expect(res.body.details[0].field).toBe("contractId"); + expect(res.body.details[0].message).toMatch(/valid Stellar contract address/i); + }); + it("returns 400 for an empty-looking contractId segment", async () => { const res = await request(buildApp()) .get("/api/jobs/INVALID/whitelist") @@ -540,6 +552,8 @@ describe("GET /api/jobs/:contractId/whitelist", () => { expect(typeof res.body.error).toBe("string"); expect(res.body.error.length).toBeGreaterThan(0); + expect(typeof res.body.message).toBe("string"); + expect(res.body.message.length).toBeGreaterThan(0); }); it("error details carry the field name for easy client-side parsing", async () => { diff --git a/jest.config.js b/jest.config.js index 90f9d3e..7a12553 100644 --- a/jest.config.js +++ b/jest.config.js @@ -8,16 +8,7 @@ export default { "^.+\\.ts$": ["ts-jest", { useESM: true }] }, testMatch: ["**/__tests__/**/*.test.ts"], - // Orphaned after merge damage on main: imports LedgerRangeTracker APIs that - // are no longer exported from ledger-range-tracker.ts. Ignore until restored. - testPathIgnorePatterns: [ - "/node_modules/", - "/__tests__/ledger-range-tracker-improvements\\.test\\.ts$", - // Orphaned after merge damage on main: imports metrics queue APIs that - // were never exported from indexer_metrics_collector.ts (#336 leftover). - "/__tests__/indexer-metrics-collector-concurrency\\.test\\.ts$", - "/__tests__/failover-recovery-backoff-retry\\.test\\.ts$", - ], + testPathIgnorePatterns: ["/node_modules/"], setupFilesAfterEnv: ["/jest.setup.ts"], moduleNameMapper: { "^(\\.{1,2}/.*)\\.js$": "$1" diff --git a/src/indexer/failover-recovery.ts b/src/indexer/failover-recovery.ts index f51ced2..b150713 100644 --- a/src/indexer/failover-recovery.ts +++ b/src/indexer/failover-recovery.ts @@ -1,6 +1,55 @@ import { getDb } from "./db.js"; import logger from "../utils/logger.js"; +/** + * Retries an async operation with exponentially increasing delays. + * + * A connection dropout to a Soroban RPC node is usually transient, so the + * caller gets `maxAttempts` tries with the pause doubling each time + * (`baseDelayMs`, `2x`, `4x`, ...). Only the gaps between attempts are + * delayed -- the first call is immediate and a successful attempt returns + * straight away -- so N attempts sleep at most N-1 times. + * + * The error from the final attempt is rethrown, so callers see the reason the + * operation actually gave up rather than a wrapper. + * + * @param operation The work to attempt. Re-invoked on each retry. + * @param maxAttempts Total attempts, including the first. Values below 1 are + * treated as 1. + * @param baseDelayMs Delay before the second attempt; doubles thereafter. + */ +export async function retryWithBackoff( + operation: () => Promise, + maxAttempts = 3, + baseDelayMs = 100, +): Promise { + const attempts = Math.max(1, maxAttempts); + let lastError: unknown; + + for (let attempt = 0; attempt < attempts; attempt++) { + try { + return await operation(); + } catch (err) { + lastError = err; + + // No pause after the final attempt -- it would delay the rejection + // without buying another try. + if (attempt === attempts - 1) break; + + const delayMs = baseDelayMs * 2 ** attempt; + logger.warn("Operation failed, retrying with backoff", { + attempt: attempt + 1, + maxAttempts: attempts, + delayMs, + error: err instanceof Error ? err.message : String(err), + }); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } + + throw lastError; +} + /** * Debug line for one RPC round against a failover node (#249). * diff --git a/src/middleware/job-contract-security.ts b/src/middleware/job-contract-security.ts index 8824628..b9a0dd0 100644 --- a/src/middleware/job-contract-security.ts +++ b/src/middleware/job-contract-security.ts @@ -241,6 +241,49 @@ export function byWalletCors( export const byWalletSecurityHeaders = jobContractSecurityHeaders; +/** Strict CORS gate for POST /api/jobs/:contractId/milestones/:index/partial-release. */ +export function partialReleaseCors( + req: Request, + res: Response, + next: NextFunction +): void { + const origin = req.header("Origin"); + const allowedOrigins = getAllowedOrigins(); + + if (!origin) { + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + return; + } + + if (allowedOrigins.includes(origin)) { + res.setHeader("Access-Control-Allow-Origin", origin); + res.setHeader("Vary", "Origin"); + res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); + res.setHeader( + "Access-Control-Allow-Headers", + "Content-Type, Authorization, X-API-Key" + ); + if (req.method === "OPTIONS") { + res.status(204).end(); + return; + } + next(); + return; + } + + res.status(403).json({ + success: false, + error: "Origin not allowed by CORS policy", + }); +} + +/** Security headers applied to partial-release responses. */ +export const partialReleaseSecurityHeaders = jobContractSecurityHeaders; + /** Strict CORS gate for POST /api/jobs/:contractId/milestones/:index/claim-auto-release. */ export function claimAutoReleaseCors( req: Request, diff --git a/src/middleware/validate.ts b/src/middleware/validate.ts index bc1e373..718cd1a 100644 --- a/src/middleware/validate.ts +++ b/src/middleware/validate.ts @@ -1,7 +1,5 @@ import type { NextFunction, Request, Response } from "express"; -import { ZodSchema, ZodError } from "zod"; -import { sendError } from "../utils/api-response.js"; -import { formatValidationError } from "../utils/validation.js"; +import { ZodError, ZodSchema } from "zod"; type Target = "params" | "body" | "query"; @@ -56,6 +54,7 @@ export function validate( next(); }; } + export function validateWithFields( schema: ZodSchema, target: Target = "params", diff --git a/src/routes/jobs.ts b/src/routes/jobs.ts index 1c14cce..3eaad55 100644 --- a/src/routes/jobs.ts +++ b/src/routes/jobs.ts @@ -33,6 +33,8 @@ import { timeRemainingSecurityHeaders, byWalletCors, byWalletSecurityHeaders, + partialReleaseCors, + partialReleaseSecurityHeaders, claimAutoReleaseCors, claimAutoReleaseSecurityHeaders, updateWhitelistCors, @@ -46,6 +48,7 @@ import type { RequestWithValidatedQuery } from "../middleware/validate.js"; import { createJobDraftValidation } from "../middleware/create-job-draft-validation.js"; import { contractIdParamsSchema, + whitelistParamsSchema, contractMilestoneParamsSchema, // Schema for building transaction requests buildTxBodySchema, @@ -576,7 +579,7 @@ router.get( logger.info("Fetching whitelisted tokens", { contractId: req.params.contractId }); next(); }, - validate(contractIdParamsSchema, "params", (req) => + validate(whitelistParamsSchema, "params", (req) => logger.warn("Invalid contractId provided", { contractId: req.params.contractId }), ), async (req: Request, res: Response) => { @@ -1116,8 +1119,15 @@ router.post( // --------------------------------------------------------------------------- // POST /api/jobs/:contractId/milestones/:index/partial-release // --------------------------------------------------------------------------- +router.options( + "/:contractId/milestones/:index/partial-release", + partialReleaseCors, +); + router.post( "/:contractId/milestones/:index/partial-release", + partialReleaseCors, + partialReleaseSecurityHeaders, partialReleaseRateLimit, validateWithFields(contractMilestoneParamsSchema, "params", (req) => logger.warn("Invalid params for partial-release", { params: req.params }), @@ -1157,8 +1167,6 @@ router.post( sourceAddress, }); - const contract = new Contract(contractId as string); - let account; try { account = await server.getAccount(sourceAddress as string); @@ -1204,17 +1212,6 @@ router.post( if (!requestPromise) { requestPromise = (async (): Promise => { const contract = new Contract(contractId as string); - - let account; - try { - account = await server.getAccount(sourceAddress as string); - } catch (err: any) { - const errMsg = String(err?.message || err); - const { status, message } = classifySimError(errMsg); - logger.error("Failed to get account for partial release", { sourceAddress, error: errMsg }); - throw { status, message }; - } - const amountNum = BigInt(amount); const tx = new TransactionBuilder(account, { diff --git a/src/routes/whitelist.test.ts b/src/routes/whitelist.test.ts index c7b5832..15f2130 100644 --- a/src/routes/whitelist.test.ts +++ b/src/routes/whitelist.test.ts @@ -48,7 +48,8 @@ describe("GET /api/jobs/:contractId/whitelist", () => { const res = await request(app).get("/api/jobs/INVALID_ID/whitelist"); expect(res.status).toBe(400); expect(res.body.success).toBe(false); - expect(res.body.error).toContain("valid Stellar contract address"); + expect(res.body.error).toBe("ValidationError"); + expect(res.body.details[0].message).toMatch(/valid Stellar contract address/i); }); it("returns 200 and empty tokens if contract is not initialized", async () => { diff --git a/src/schemas/jobs.ts b/src/schemas/jobs.ts index 489df59..9e03565 100644 --- a/src/schemas/jobs.ts +++ b/src/schemas/jobs.ts @@ -95,6 +95,11 @@ export const contractIdParamsSchema = z.object({ contractId: contractIdSchema, }); +/** Route params: /:contractId/whitelist */ +export const whitelistParamsSchema = z.object({ + contractId: contractIdSchema, +}); + /** Route params: /:contractId/milestones/:index */ export const contractMilestoneParamsSchema = z.object({ contractId: contractIdSchema, @@ -389,6 +394,7 @@ export type WhitelistUpdateRequestBody = export type ContractIdParams = z.infer; export type ContractMilestoneParams = z.infer; +export type WhitelistParams = z.infer; export type BuildTxBody = z.infer; export type SubmitBody = z.infer; export type PartialReleaseBody = z.infer; diff --git a/verify-ci.js b/verify-ci.js index 904f6c5..33df5a3 100644 --- a/verify-ci.js +++ b/verify-ci.js @@ -1,6 +1,6 @@ -const { spawnSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; const root = process.cwd(); const commands = [