Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
7dce937
feat: apply strict CORS and security headers to partial-release endpoint
DeePrincipal-dev-lang Aug 30, 2026
f5c5357
Merge branch 'main' into feat/partial-release-cors-security-headers
DeePrincipal-dev-lang Aug 30, 2026
680efe9
fix: restore legacy API compatibility layer for TypeScript compile gate
DeePrincipal-dev-lang Aug 31, 2026
a09add1
feat: Integrate Zod schema middleware in GET /api/jobs/:contractId (#31)
ChapmanOfWeb3 Aug 31, 2026
269c72e
feat: Integrate Zod schema middleware in GET /api/jobs/:contractId (#31)
ChapmanOfWeb3 Aug 31, 2026
6ee574e
feat: Integrate Zod schema middleware in GET /api/jobs/:contractId (#31)
ChapmanOfWeb3 Aug 31, 2026
3550103
feat: Integrate Zod schema middleware in GET /api/jobs/:contractId (#31)
ChapmanOfWeb3 Aug 31, 2026
eccd6ad
feat: Integrate Zod schema middleware in GET /api/jobs/:contractId (#31)
ChapmanOfWeb3 Aug 31, 2026
d7658b9
Merge branch 'refs/heads/pr/399' into work-399
godamongstmen897 Sep 1, 2026
6644dbc
Merge main into #399, keeping the CORS work and dropping the replays
godamongstmen897 Sep 1, 2026
050d521
Merge pull request #399 from DeePrincipal-dev-lang/feat/partial-relea…
godamongstmen897 Sep 1, 2026
21729d3
fix(ci): re-enable three skipped test suites
godamongstmen897 Sep 1, 2026
5b2f9fd
Merge pull request #402 from Goldii-locks/fix/reenable-skipped-indexe…
godamongstmen897 Sep 1, 2026
a8eb53c
Merge branch 'refs/heads/pr/401' into work-be-401
godamongstmen897 Sep 2, 2026
ae2477d
Merge PR #401: Zod schema middleware for the whitelist route (Chapman…
godamongstmen897 Sep 2, 2026
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,6 @@ dist/
data/
.agents/
issue.md
skills-lock.json
skills-lock.json
# Exit-code artifacts written by verify-ci.js
*.exit
58 changes: 58 additions & 0 deletions __tests__/partial-release.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions __tests__/whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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 () => {
Expand Down
11 changes: 1 addition & 10 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["<rootDir>/jest.setup.ts"],
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1"
Expand Down
49 changes: 49 additions & 0 deletions src/indexer/failover-recovery.ts
Original file line number Diff line number Diff line change
@@ -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<T>(
operation: () => Promise<T>,
maxAttempts = 3,
baseDelayMs = 100,
): Promise<T> {
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).
*
Expand Down
43 changes: 43 additions & 0 deletions src/middleware/job-contract-security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 2 additions & 3 deletions src/middleware/validate.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -56,6 +54,7 @@ export function validate(
next();
};
}

export function validateWithFields(
schema: ZodSchema,
target: Target = "params",
Expand Down
25 changes: 11 additions & 14 deletions src/routes/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
timeRemainingSecurityHeaders,
byWalletCors,
byWalletSecurityHeaders,
partialReleaseCors,
partialReleaseSecurityHeaders,
claimAutoReleaseCors,
claimAutoReleaseSecurityHeaders,
updateWhitelistCors,
Expand All @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -1157,8 +1167,6 @@ router.post(
sourceAddress,
});

const contract = new Contract(contractId as string);

let account;
try {
account = await server.getAccount(sourceAddress as string);
Expand Down Expand Up @@ -1204,17 +1212,6 @@ router.post(
if (!requestPromise) {
requestPromise = (async (): Promise<string> => {
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, {
Expand Down
3 changes: 2 additions & 1 deletion src/routes/whitelist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
6 changes: 6 additions & 0 deletions src/schemas/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -389,6 +394,7 @@ export type WhitelistUpdateRequestBody =

export type ContractIdParams = z.infer<typeof contractIdParamsSchema>;
export type ContractMilestoneParams = z.infer<typeof contractMilestoneParamsSchema>;
export type WhitelistParams = z.infer<typeof whitelistParamsSchema>;
export type BuildTxBody = z.infer<typeof buildTxBodySchema>;
export type SubmitBody = z.infer<typeof submitBodySchema>;
export type PartialReleaseBody = z.infer<typeof partialReleaseBodySchema>;
Expand Down
6 changes: 3 additions & 3 deletions verify-ci.js
Original file line number Diff line number Diff line change
@@ -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 = [
Expand Down
Loading