diff --git a/README.md b/README.md index 3fd390c..12aaf20 100644 --- a/README.md +++ b/README.md @@ -297,6 +297,30 @@ Upstream RPC calls (Stellar/Soroban) are protected by a built-in circuit breaker Live state is available at `GET /api/v1/circuit-breaker/status`. See [`docs/backend/circuit-breaker.md`](docs/backend/circuit-breaker.md) for full reference. +## New Features + +### 1. Authentication Middleware (#55) +All routes under `/api/v1/admin/*` are protected by JWT authentication. +- **Header**: `Authorization: Bearer ` +- **Validation**: Ensures token is valid and not expired. + +### 2. Event Idempotency (#67) +The `/api/v1/events` endpoint requires an `Idempotency-Key` header to prevent duplicate processing of the same smart contract event. +- **Header**: `Idempotency-Key: ` +- **Behavior**: If a key is seen again within 1 hour, the cached response is returned instead of re-processing. + +### 3. Smart-Contract Event Indexer (#70) +A pipeline for indexing escrow and dispute lifecycle updates from smart contracts. +- **Endpoint**: `POST /api/v1/events` +- **Supported Events**: `escrow:created`, `escrow:completed`, `dispute:initiated`, `dispute:resolved`. + +## Testing + +Run unit and integration tests to verify these features: +```bash +npm test +``` + ## License MIT diff --git a/src/middleware/auth.test.ts b/src/middleware/auth.test.ts new file mode 100644 index 0000000..d6fc3f2 --- /dev/null +++ b/src/middleware/auth.test.ts @@ -0,0 +1,42 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; +import { authenticateToken, AuthRequest } from './auth'; + +describe('authenticateToken', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: NextFunction = jest.fn(); + + beforeEach(() => { + mockRequest = { + headers: {}, + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + it('should return 401 if no Authorization header is present', () => { + authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction); + expect(mockResponse.status).toHaveBeenCalledWith(401); + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Authentication token required' }); + }); + + it('should return 403 for an invalid token', () => { + mockRequest.headers!['authorization'] = 'Bearer invalid-token'; + authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction); + expect(mockResponse.status).toHaveBeenCalledWith(403); + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Invalid authentication token' }); + }); + + it('should call next() for a valid token', () => { + const user = { id: 'user-123' }; + const token = jwt.sign(user, process.env.JWT_SECRET || 'tt-dev-secret-keep-it-safe'); + mockRequest.headers!['authorization'] = `Bearer ${token}`; + + authenticateToken(mockRequest as AuthRequest, mockResponse as Response, nextFunction); + expect(nextFunction).toHaveBeenCalled(); + expect((mockRequest as AuthRequest).user).toMatchObject(user); + }); +}); diff --git a/src/middleware/idempotency.test.ts b/src/middleware/idempotency.test.ts new file mode 100644 index 0000000..f3404c6 --- /dev/null +++ b/src/middleware/idempotency.test.ts @@ -0,0 +1,68 @@ +import { Request, Response, NextFunction } from 'express'; +import { idempotencyMiddleware, clearIdempotencyStore } from './idempotency'; + +describe('idempotencyMiddleware', () => { + let mockRequest: Partial; + let mockResponse: Partial; + let nextFunction: NextFunction; + + beforeEach(() => { + mockRequest = { + headers: {}, + }; + mockResponse = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + send: jest.fn() as any, + }; + nextFunction = jest.fn(); + clearIdempotencyStore(); + }); + + it('should return 400 if Idempotency-Key header is missing', () => { + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + expect(mockResponse.status).toHaveBeenCalledWith(400); + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Idempotency-Key header is required' }); + }); + + it('should allow operation with new Idempotency-Key', () => { + mockRequest.headers!['idempotency-key'] = 'unique-key-1'; + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + expect(nextFunction).toHaveBeenCalled(); + }); + + it('should return 409 if Idempotency-Key is still in processing status', () => { + mockRequest.headers!['idempotency-key'] = 'processing-key'; + + // First call sets it to processing + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + + // Second call with same key + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + expect(mockResponse.status).toHaveBeenCalledWith(409); + expect(mockResponse.json).toHaveBeenCalledWith({ error: 'Request is already being processed' }); + }); + + it('should return cached response for completed Idempotency-Key', () => { + mockRequest.headers!['idempotency-key'] = 'completed-key'; + + // First call sets it to processing + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + + const originalBody = { status: 'success', data: { id: 123 } }; + // Simulate completion + (mockResponse.send as any)(JSON.stringify(originalBody)); + + // Reset mock response to track new call + mockResponse.status = jest.fn().mockReturnThis(); + mockResponse.json = jest.fn(); + + // Replay request + idempotencyMiddleware(mockRequest as Request, mockResponse as Response, nextFunction); + expect(mockResponse.status).toHaveBeenCalledWith(200); + expect(mockResponse.json).toHaveBeenCalledWith({ + ...originalBody, + idempotencyHeader: 'replay-detected' + }); + }); +}); diff --git a/src/middleware/idempotency.ts b/src/middleware/idempotency.ts new file mode 100644 index 0000000..095b86b --- /dev/null +++ b/src/middleware/idempotency.ts @@ -0,0 +1,70 @@ +import { Request, Response, NextFunction } from 'express'; + +// Simple in-memory cache for idempotency keys. In a real application, +// this should be moved to a persistent store like Redis or a database. +const idempotencyStore = new Map(); + +const CACHE_TTL = 3600 * 1000; // 1 hour TTL for idempotency keys + +/** + * Middleware to enforce idempotency keys for protected operations + */ +export const idempotencyMiddleware = (req: Request, res: Response, next: NextFunction) => { + const idempotencyKey = req.headers['idempotency-key'] as string; + + if (!idempotencyKey) { + return res.status(400).json({ error: 'Idempotency-Key header is required' }); + } + + // Basic cleanup of old keys (can be optimized or moved to a separate job) + const now = Date.now(); + for (const [key, value] of idempotencyStore.entries()) { + if (now - value.timestamp > CACHE_TTL) { + idempotencyStore.delete(key); + } + } + + const existingEntry = idempotencyStore.get(idempotencyKey); + + if (existingEntry) { + if (existingEntry.status === 'processing') { + return res.status(409).json({ error: 'Request is already being processed' }); + } + + // If it's already completed, return its cached result + return res.status(existingEntry.status === 'completed' ? 200 : 409).json({ + ...existingEntry.result, + idempotencyHeader: 'replay-detected' + }); + } + + // Pre-register the key to prevent race conditions (basic version) + idempotencyStore.set(idempotencyKey, { + status: 'processing', + timestamp: Date.now() + }); + + // Proxy the original send method to capture the result + const originalSend = res.send; + res.send = function (body: any): Response { + idempotencyStore.set(idempotencyKey, { + status: 'completed', + timestamp: Date.now(), + result: typeof body === 'string' ? JSON.parse(body) : body + }); + return originalSend.apply(res, arguments as any); + }; + + next(); +}; + +/** + * Clean up the idempotency store manually (for testing or maintenance) + */ +export const clearIdempotencyStore = () => { + idempotencyStore.clear(); +}; diff --git a/src/services/indexer.test.ts b/src/services/indexer.test.ts new file mode 100644 index 0000000..02e0f89 --- /dev/null +++ b/src/services/indexer.test.ts @@ -0,0 +1,59 @@ +import { EventIndexerService, EventType, SmartContractEvent } from './indexer'; + +describe('EventIndexerService', () => { + let indexer: EventIndexerService; + + beforeEach(() => { + indexer = new EventIndexerService(); + }); + + const sampleEvent: SmartContractEvent = { + contractId: '0x123', + eventType: EventType.EscrowCreated, + payload: { amount: 100 }, + timestamp: new Date().toISOString() + }; + + it('should process and index an event', async () => { + const result = await indexer.processEvent(sampleEvent); + expect(result.status).toBe('indexed'); + expect(indexer.getEvents()).toHaveLength(1); + expect(indexer.getEvents()[0]).toMatchObject(sampleEvent); + }); + + it('should filter events by contractId', async () => { + await indexer.processEvent(sampleEvent); + await indexer.processEvent({ + ...sampleEvent, + contractId: '0x456', + eventType: EventType.DisputeInitiated + }); + + const result = indexer.getEventsByContractId('0x123'); + expect(result).toHaveLength(1); + expect(result[0].contractId).toBe('0x123'); + }); + + it('should throw error for invalid event data', async () => { + const invalidEvent = { ...sampleEvent, eventType: undefined } as any; + await expect(indexer.processEvent(invalidEvent)).rejects.toThrow('Invalid event data'); + }); + + it('should correctly process different event types', async () => { + const consoleSpy = jest.spyOn(console, 'log'); + + await indexer.processEvent({ + ...sampleEvent, + eventType: EventType.DisputeInitiated + }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Indexer] Dispute initiated')); + + await indexer.processEvent({ + ...sampleEvent, + eventType: EventType.DisputeResolved + }); + expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('[Indexer] Dispute resolved')); + + consoleSpy.mockRestore(); + }); +}); diff --git a/src/services/indexer.ts b/src/services/indexer.ts new file mode 100644 index 0000000..e0e029d --- /dev/null +++ b/src/services/indexer.ts @@ -0,0 +1,77 @@ +import { Response, Request } from 'express'; + +export enum EventType { + EscrowCreated = 'escrow:created', + EscrowCompleted = 'escrow:completed', + DisputeInitiated = 'dispute:initiated', + DisputeResolved = 'dispute:resolved', +} + +export interface SmartContractEvent { + contractId: string; + eventType: EventType; + idempotencyKey?: string; + payload: any; + timestamp: string; +} + +/** + * Service to index smart contract events + */ +export class EventIndexerService { + private indexedEvents: Map = new Map(); + + constructor() { + this.indexedEvents = new Map(); + } + + /** + * Process and index a smart contract event + */ + public async processEvent(event: SmartContractEvent): Promise<{ status: string; eventId: string }> { + // Pipeline logic: + // 1. Validation (ensure contractId, type exists) + // 2. Logic to handle specific event types (e.g., escrow or dispute updates) + // 3. Persist (in-memory for now) + + if (!event.contractId || !event.eventType) { + throw new Error('Invalid event data'); + } + + // Pipeline processing: + switch (event.eventType) { + case EventType.EscrowCreated: + console.log(`[Indexer] New escrow created for contract: ${event.contractId}`); + break; + case EventType.DisputeInitiated: + console.log(`[Indexer] Dispute initiated for contract: ${event.contractId}`); + break; + case EventType.DisputeResolved: + console.log(`[Indexer] Dispute resolved for contract: ${event.contractId}`); + break; + default: + console.log(`[Indexer] Processing generic event: ${event.eventType}`); + } + + const eventId = `ev-${Date.now()}-${Math.random().toString(36).substring(7)}`; + this.indexedEvents.set(eventId, event); + + return { status: 'indexed', eventId }; + } + + /** + * Fetch indexed events (for demonstration) + */ + public getEvents() { + return Array.from(this.indexedEvents.values()); + } + + /** + * Fetch specific event by contract ID + */ + public getEventsByContractId(contractId: string) { + return Array.from(this.indexedEvents.values()).filter(e => e.contractId === contractId); + } +} + +export const indexerService = new EventIndexerService();