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
11 changes: 11 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,13 @@ export async function createApp(env: BackendEnv, runtime: BackendRuntime) {
requestIdStorage.run(id, next);
});

// Global rate limiter — catch-all DoS protection for all endpoints (1000 req/min per IP)
const globalRateLimiter = createRateLimitMiddleware({
windowMs: 60 * 1000,
maxRequests: 1000,
});
app.use(globalRateLimiter);

// Rate limiting middleware — different limits per endpoint type
// Health/readiness probes: 300 req/min (high-frequency monitoring)
const healthRateLimiter = createRateLimitMiddleware({
Expand Down Expand Up @@ -160,6 +167,10 @@ export async function createApp(env: BackendEnv, runtime: BackendRuntime) {
);
const adminAuthMiddleware = requireApiKey(() => authKeyState.primary);

// API key authentication for external integration endpoints (webhooks, notifications)
app.use("/api/v1/webhooks", authMiddleware);
app.use("/api/v1/notifications", authMiddleware);

app.use(createHealthRouter(env, runtime));

// Public Prometheus scrape endpoint
Expand Down
67 changes: 66 additions & 1 deletion backend/src/modules/audit/audit.routes.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Router } from "express";
import { AuditService } from "./audit.service.js";
import type { Request, Response } from "express";
import { AuditService, generateMerkleProof, generateMerkleRoot, archiveEntries } from "./audit.service.js";
import {
getAuditController,
exportAuditCsvController,
verifyAuditController,
} from "./audit.controller.js";
import { success, error } from "../../shared/http/response.js";
import { ErrorCode } from "../../shared/http/errorCodes.js";

export function createAuditRouter(
rpcUrl: string,
Expand All @@ -22,5 +25,67 @@ export function createAuditRouter(
router.get("/verify", verifyAuditController(service));
}

router.get("/merkle-root", async (req: Request, res: Response) => {
const contractId = req.query["contractId"] as string | undefined;
if (!contractId) {
error(res, { message: "contractId query parameter is required", status: 400, code: ErrorCode.VALIDATION_ERROR });
return;
}
try {
const page = await service.getAuditTrail(contractId, 0, 10000);
const root = generateMerkleRoot(page.data);
success(res, { merkleRoot: root, entryCount: page.data.length });
} catch (err) {
error(res, { message: String(err), status: 500, code: ErrorCode.INTERNAL_ERROR });
}
});

router.get("/merkle-proof/:index", async (req: Request, res: Response) => {
const contractId = req.query["contractId"] as string | undefined;
const index = parseInt(req.params["index"] ?? "", 10);
if (!contractId) {
error(res, { message: "contractId query parameter is required", status: 400, code: ErrorCode.VALIDATION_ERROR });
return;
}
if (isNaN(index) || index < 0) {
error(res, { message: "index must be a non-negative integer", status: 400, code: ErrorCode.VALIDATION_ERROR });
return;
}
try {
const page = await service.getAuditTrail(contractId, 0, 10000);
const proof = generateMerkleProof(page.data, index);
success(res, proof);
} catch (err) {
error(res, { message: String(err), status: 500, code: ErrorCode.INTERNAL_ERROR });
}
});

const archiveHandler = async (req: Request, res: Response) => {
const contractId = req.query["contractId"] as string | undefined;
const beforeEntry = parseInt(req.query["beforeEntry"] as string ?? "0", 10);
if (!contractId) {
error(res, { message: "contractId query parameter is required", status: 400, code: ErrorCode.VALIDATION_ERROR });
return;
}
try {
const page = await service.getAuditTrail(contractId, 0, 10000);
const toArchive = beforeEntry > 0 ? page.data.slice(0, beforeEntry) : page.data;
if (toArchive.length === 0) {
error(res, { message: "No entries to archive", status: 400, code: ErrorCode.BAD_REQUEST });
return;
}
const result = archiveEntries(toArchive);
success(res, result);
} catch (err) {
error(res, { message: String(err), status: 500, code: ErrorCode.INTERNAL_ERROR });
}
};

if (adminAuthMiddleware) {
router.post("/archive", adminAuthMiddleware, archiveHandler);
} else {
router.post("/archive", archiveHandler);
}

return router;
}
83 changes: 83 additions & 0 deletions backend/src/modules/audit/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type {
AuditEntry,
AuditPage,
AuditVerificationResult,
MerkleProof,
ArchiveResult,
} from "./audit.types.js";
import { AUDIT_ACTION_DISCRIMINANT } from "./audit.types.js";

Expand Down Expand Up @@ -133,6 +135,87 @@ export function streamAuditCsv(
res.end();
}

function hashLeaf(entry: AuditEntry): string {
const data = `${entry.id}:${entry.action}:${entry.actor}:${entry.target}:${entry.timestamp}:${entry.hash}`;
return createHash("sha256").update(data).digest("hex");
}

function hashPair(left: string, right: string): string {
const sorted = left < right ? left + right : right + left;
return createHash("sha256").update(sorted).digest("hex");
}

function buildMerkleTree(leaves: string[]): string[][] {
if (leaves.length === 0) return [[]];
const levels: string[][] = [leaves];
let current = leaves;
while (current.length > 1) {
const next: string[] = [];
for (let i = 0; i < current.length; i += 2) {
const left = current[i]!;
const right = current[i + 1] ?? left;
next.push(hashPair(left, right));
}
levels.push(next);
current = next;
}
return levels;
}

export function generateMerkleRoot(entries: AuditEntry[]): string {
if (entries.length === 0) return "";
const leaves = entries.map(hashLeaf);
const tree = buildMerkleTree(leaves);
return tree[tree.length - 1]![0]!;
}

export function generateMerkleProof(
entries: AuditEntry[],
targetIndex: number,
): MerkleProof {
if (targetIndex < 0 || targetIndex >= entries.length) {
throw new Error(`Index ${targetIndex} out of range [0, ${entries.length})`);
}
const leaves = entries.map(hashLeaf);
const tree = buildMerkleTree(leaves);
const proof: string[] = [];
let idx = targetIndex;

for (let level = 0; level < tree.length - 1; level++) {
const layer = tree[level]!;
const siblingIdx = idx % 2 === 0 ? idx + 1 : idx - 1;
if (siblingIdx < layer.length) {
proof.push(layer[siblingIdx]!);
} else {
proof.push(layer[idx]!);
}
idx = Math.floor(idx / 2);
}

return {
entryId: entries[targetIndex]!.id,
root: tree[tree.length - 1]![0]!,
proof,
leafHash: leaves[targetIndex]!,
index: targetIndex,
totalLeaves: entries.length,
};
}

export function archiveEntries(entries: AuditEntry[]): ArchiveResult {
if (entries.length === 0) {
throw new Error("Cannot archive empty entries");
}
const merkleRoot = generateMerkleRoot(entries);
return {
archivedCount: entries.length,
merkleRoot,
archiveTimestamp: new Date().toISOString(),
fromEntryId: entries[0]!.id,
toEntryId: entries[entries.length - 1]!.id,
};
}

export class AuditService {
constructor(
private readonly rpcUrl: string,
Expand Down
17 changes: 17 additions & 0 deletions backend/src/modules/audit/audit.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,20 @@ export interface AuditPage {
limit: number;
verification?: AuditVerificationResult;
}

export interface MerkleProof {
entryId: string;
root: string;
proof: string[];
leafHash: string;
index: number;
totalLeaves: number;
}

export interface ArchiveResult {
archivedCount: number;
merkleRoot: string;
archiveTimestamp: string;
fromEntryId: string;
toEntryId: string;
}
60 changes: 59 additions & 1 deletion backend/src/modules/events/replay/replay-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Provides a user-friendly way to configure and execute replay operations.
*/

import type { ReplayOptions } from "./replay.types.js";
import type { ReplayOptions, ReplayOutputFormat } from "./replay.types.js";
import { EventReplayService } from "./replay.service.js";
import { loadEnv } from "../../../config/env.js";
import { fileURLToPath } from "node:url";
Expand All @@ -32,6 +32,8 @@ export function parseReplayArgs(args: string[]): ReplayOptions {
contractId: undefined as string | undefined,
rpcUrl: undefined as string | undefined,
outputDir: undefined as string | undefined,
eventTypes: undefined as string[] | undefined,
outputFormat: undefined as ReplayOutputFormat | undefined,
};

for (let i = 0; i < args.length; i++) {
Expand Down Expand Up @@ -132,6 +134,31 @@ export function parseReplayArgs(args: string[]): ReplayOptions {
options.clear = true;
break;

case "--type":
if (nextArg !== undefined && !nextArg.startsWith("-")) {
const types = nextArg.split(",").map((t) => t.trim().toUpperCase()).filter(Boolean);
if (types.length === 0) {
throw new Error(`--type requires at least one event type.`);
}
options.eventTypes = types;
i++;
} else {
throw new Error(`--type requires a comma-separated list of event types.`);
}
break;

case "--format":
if (nextArg !== undefined && !nextArg.startsWith("-")) {
if (nextArg !== "json" && nextArg !== "human") {
throw new Error(`Invalid format: ${nextArg}. Must be 'json' or 'human'.`);
}
options.outputFormat = nextArg as ReplayOutputFormat;
i++;
} else {
throw new Error(`--format requires 'json' or 'human'.`);
}
break;

case "--verbose":
case "-v":
options.verbose = true;
Expand Down Expand Up @@ -187,6 +214,8 @@ Options:
-w, --clear Wipe existing proposal and snapshot state before replay
-d, --dry-run Run without persisting state or processing events
-v, --verbose Enable verbose logging output
--type <types> Filter by event types (comma-separated, e.g. PROPOSAL_CREATED,SIGNER_ADDED)
--format <fmt> Output format: 'human' (default) or 'json'
-h, --help Show this help message

Examples:
Expand All @@ -205,6 +234,12 @@ Examples:
# Backfill from a specific contract on a different RPC
npm run replay -- --contract CDABC123... --rpc https://custom-rpc.example.com

# Filter by event type and output as JSON
npm run replay -- --type PROPOSAL_CREATED,PROPOSAL_EXECUTED --format json

# Replay only signer events in human-readable format
npm run replay -- --type SIGNER_ADDED,SIGNER_REMOVED --format human

Environment Variables:
CONTRACT_ID Contract ID for the VaultDAO contract
SOROBAN_RPC_URL Soroban RPC endpoint URL
Expand Down Expand Up @@ -276,6 +311,29 @@ export async function executeReplay(args: string[]): Promise<void> {
);
}

if (options.eventTypes) {
console.log(`[replay-cli] Filtering event types: ${options.eventTypes.join(", ")}`);
}
if (options.outputFormat) {
console.log(`[replay-cli] Output format: ${options.outputFormat}`);
}

const typeFilter = options.eventTypes
? new Set(options.eventTypes.map((t) => t.toUpperCase()))
: null;
const isJsonOutput = options.outputFormat === "json";

service.registerConsumer(async (event) => {
if (typeFilter && !typeFilter.has(event.type)) return;
if (isJsonOutput) {
console.log(JSON.stringify(event));
} else if (options.verbose) {
console.log(
`[${event.metadata.ledger}] ${event.type} | ${JSON.stringify(event.data)}`,
);
}
});

console.log("[replay-cli] Starting replay operation...");
console.log("");

Expand Down
6 changes: 6 additions & 0 deletions backend/src/modules/events/replay/replay.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export type ReplayBatchConsumer = (
/**
* Configuration options for the replay command.
*/
export type ReplayOutputFormat = "human" | "json";

export interface ReplayOptions {
/** Starting ledger for backfill (inclusive). Defaults to 0. */
readonly startLedger: number;
Expand All @@ -37,6 +39,10 @@ export interface ReplayOptions {
readonly verbose: boolean;
/** Clear existing state before replay. */
readonly clear: boolean;
/** Filter by event types (e.g. PROPOSAL_CREATED, SIGNER_ADDED). */
readonly eventTypes?: string[];
/** Output format: human-readable or JSON. Defaults to human. */
readonly outputFormat?: ReplayOutputFormat;
}

/**
Expand Down
Loading
Loading