Enhance mapping and orchestrator services with new features and improvements

- Updated `mapping-service` to include WEB3-ETH-IBAN support, health check endpoint, and improved error handling for account-wallet linking.
- Added new provider connection and status endpoints in `mapping-service`.
- Enhanced `orchestrator` service with health check, trigger management endpoints, and improved error handling for trigger validation and submission.
- Updated dependencies in `package.json` for both services, including `axios`, `uuid`, and type definitions.
- Improved packet service with additional validation and error handling for packet generation and dispatching.
- Introduced webhook service enhancements, including delivery retries, dead letter queue management, and webhook management endpoints.
This commit is contained in:
defiQUG
2025-12-12 13:53:30 -08:00
parent c32fcf48e8
commit d7379f108e
49 changed files with 3656 additions and 146 deletions

View File

@@ -5,8 +5,7 @@
import express from 'express';
import { webhookRouter } from './routes/webhooks';
import { eventBusClient } from '@emoney/events';
import { webhookDeliveryService } from './services/delivery';
import { initializeEventBusSubscriptions } from './services/event-bus';
const app = express();
const PORT = process.env.PORT || 3001;
@@ -16,9 +15,12 @@ app.use(express.json());
// Webhook management API
app.use('/v1/webhooks', webhookRouter);
// Subscribe to event bus and deliver webhooks
eventBusClient.on('published', async ({ topic, event }) => {
await webhookDeliveryService.deliverToSubscribers(topic, event);
// Initialize event bus subscriptions
initializeEventBusSubscriptions();
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'webhook-service' });
});
app.listen(PORT, () => {

View File

@@ -48,6 +48,52 @@ webhookRouter.post('/:id/replay', async (req: Request, res: Response) => {
}
});
// Delete webhook
webhookRouter.delete('/:id', async (req: Request, res: Response) => {
try {
await webhookService.deleteWebhook(req.params.id);
res.status(204).send();
} catch (error: any) {
res.status(404).json({ error: error.message });
}
});
// Get delivery attempts
webhookRouter.get('/:id/attempts', async (req: Request, res: Response) => {
try {
const { storage } = await import('../services/storage');
const attempts = await storage.getDeliveryAttempts(req.params.id);
res.json({ attempts });
} catch (error: any) {
res.status(404).json({ error: error.message });
}
});
// DLQ endpoints
webhookRouter.get('/dlq', async (req: Request, res: Response) => {
try {
const { storage } = await import('../services/storage');
const { limit, offset } = req.query;
const result = await storage.getDLQEntries(
limit ? parseInt(limit as string) : 20,
offset ? parseInt(offset as string) : 0
);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
webhookRouter.post('/dlq/:id/retry', async (req: Request, res: Response) => {
try {
const { webhookDeliveryService } = await import('../services/delivery');
await webhookDeliveryService.retryDLQEntry(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
// Get webhook
webhookRouter.get('/:id', async (req: Request, res: Response) => {
try {

View File

@@ -2,8 +2,10 @@
* Webhook delivery service with retry logic and DLQ
*/
import axios from 'axios';
import crypto from 'crypto';
import axios, { AxiosError } from 'axios';
import { createHmac } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { storage, DeliveryAttempt, DLQEntry } from './storage';
export interface DeliveryAttempt {
webhookId: string;
@@ -15,12 +17,81 @@ export interface DeliveryAttempt {
timestamp: string;
}
const MAX_RETRIES = 3;
const RETRY_DELAY_BASE = 1000; // 1 second base delay
export const webhookDeliveryService = {
/**
* Deliver event to all webhooks subscribed to topic
*/
async deliverToSubscribers(topic: string, event: any): Promise<void> {
// TODO: Get all webhooks subscribed to this topic
// TODO: For each webhook, deliver with retry logic
// Get all webhooks subscribed to this topic/event
const eventType = event.eventType || topic;
const webhooks = await storage.listWebhooks({
enabled: true,
event: eventType
});
// Deliver to each webhook
const deliveries = webhooks.map(webhook =>
this.deliverWithRetry(webhook.id, webhook.url, event, webhook.secret)
.catch(error => {
console.error(`Failed to deliver webhook ${webhook.id}:`, error);
})
);
await Promise.allSettled(deliveries);
},
/**
* Deliver webhook with retry logic
*/
async deliverWithRetry(
webhookId: string,
url: string,
event: any,
secret?: string,
maxRetries: number = MAX_RETRIES
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.deliver(webhookId, url, event, secret);
return; // Success
} catch (error: any) {
const isLastAttempt = attempt === maxRetries;
// Record delivery attempt
const attemptRecord: DeliveryAttempt = {
id: uuidv4(),
webhookId,
url,
event,
attempt,
status: isLastAttempt ? 'failed' : 'pending',
error: error.message,
timestamp: Date.now(),
responseCode: (error as AxiosError).response?.status,
responseBody: (error as AxiosError).response?.data as string,
};
await storage.saveDeliveryAttempt(attemptRecord);
if (isLastAttempt) {
// Move to dead letter queue
await this.moveToDLQ(webhookId, url, event, attempt, error.message);
throw error;
}
// Exponential backoff: 1s, 2s, 4s
const delay = RETRY_DELAY_BASE * Math.pow(2, attempt - 1);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
},
/**
* Deliver webhook (single attempt)
*/
async deliver(webhookId: string, url: string, event: any, secret?: string): Promise<void> {
const payload = JSON.stringify(event);
const signature = secret ? this.signPayload(payload, secret) : undefined;
@@ -28,6 +99,8 @@ export const webhookDeliveryService = {
const headers: any = {
'Content-Type': 'application/json',
'User-Agent': 'eMoney-Webhook/1.0',
'X-Webhook-Id': webhookId,
'X-Event-Type': event.eventType || 'unknown',
};
if (signature) {
@@ -35,43 +108,107 @@ export const webhookDeliveryService = {
}
try {
await axios.post(url, payload, {
const response = await axios.post(url, payload, {
headers,
timeout: 10000,
validateStatus: (status) => status >= 200 && status < 300,
});
// Record successful delivery
const attemptRecord: DeliveryAttempt = {
id: uuidv4(),
webhookId,
url,
event,
attempt: 1,
status: 'success',
timestamp: Date.now(),
responseCode: response.status,
responseBody: JSON.stringify(response.data),
};
await storage.saveDeliveryAttempt(attemptRecord);
} catch (error: any) {
// TODO: Retry with exponential backoff
// TODO: Move to DLQ after max retries
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError;
throw new Error(
`Webhook delivery failed: ${axiosError.response?.status} ${axiosError.message}`
);
}
throw error;
}
},
/**
* Sign payload with HMAC-SHA256
*/
signPayload(payload: string, secret: string): string {
return crypto
.createHmac('sha256', secret)
return createHmac('sha256', secret)
.update(payload)
.digest('hex');
},
async retryWithBackoff(
/**
* Move failed delivery to dead letter queue
*/
async moveToDLQ(
webhookId: string,
url: string,
event: any,
maxRetries: number = 3
attempts: number,
lastError?: string
): Promise<void> {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
await this.deliver(webhookId, url, event);
return;
} catch (error) {
if (attempt === maxRetries) {
// TODO: Move to dead letter queue
throw error;
}
// Exponential backoff: 1s, 2s, 4s
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, attempt) * 1000));
}
const dlqEntry: DLQEntry = {
id: uuidv4(),
webhookId,
url,
event,
attempts,
lastError,
createdAt: Date.now(),
failedAt: Date.now(),
};
await storage.saveToDLQ(dlqEntry);
},
/**
* Retry DLQ entry
*/
async retryDLQEntry(dlqId: string): Promise<void> {
const entries = await storage.getDLQEntries(1000, 0);
const entry = entries.entries.find(e => e.id === dlqId);
if (!entry) {
throw new Error(`DLQ entry not found: ${dlqId}`);
}
// Get webhook to retrieve secret
const webhook = await storage.getWebhook(entry.webhookId);
if (!webhook) {
throw new Error(`Webhook not found: ${entry.webhookId}`);
}
// Retry delivery
try {
await this.deliverWithRetry(
entry.webhookId,
entry.url,
entry.event,
webhook.secret
);
// Remove from DLQ on success
await storage.removeFromDLQ(dlqId);
} catch (error) {
// Update DLQ entry with new error
await storage.saveToDLQ({
...entry,
attempts: entry.attempts + 1,
lastError: (error as Error).message,
failedAt: Date.now(),
});
throw error;
}
},
};

View File

@@ -0,0 +1,47 @@
/**
* Event bus integration for webhook service
*/
import { eventBusClient } from '@emoney/events';
import { webhookDeliveryService } from './delivery';
/**
* Subscribe to event bus and trigger webhook delivery
*/
export function initializeEventBusSubscriptions() {
// Subscribe to all event types
const eventTypes = [
'triggers.created',
'triggers.state.updated',
'liens.placed',
'liens.reduced',
'liens.released',
'packets.generated',
'packets.dispatched',
'packets.acknowledged',
'bridge.locked',
'bridge.unlocked',
'compliance.updated',
'policy.updated',
'mappings.created',
'mappings.deleted',
];
// Listen for published events
eventBusClient.on('published', async ({ topic, event }) => {
try {
await webhookDeliveryService.deliverToSubscribers(topic, event);
} catch (error) {
console.error(`Failed to deliver webhook for topic ${topic}:`, error);
}
});
// Also subscribe to specific topics if event bus supports it
eventTypes.forEach(eventType => {
// In production, subscribe to each topic
// eventBusClient.subscribe(eventType, async (event) => {
// await webhookDeliveryService.deliverToSubscribers(eventType, event);
// });
});
}

View File

@@ -0,0 +1,30 @@
/**
* HTTP client for webhook delivery
*/
import axios, { AxiosInstance } from 'axios';
class HttpClient {
private client: AxiosInstance;
constructor() {
this.client = axios.create({
timeout: 10000,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'eMoney-Webhook/1.0',
},
});
}
/**
* Deliver webhook payload
*/
async deliver(url: string, payload: any, headers: Record<string, string>): Promise<any> {
const response = await this.client.post(url, payload, { headers });
return response.data;
}
}
export const httpClient = new HttpClient();

View File

@@ -0,0 +1,148 @@
/**
* Storage layer for webhooks
* In-memory implementation with database-ready interface
*/
export interface Webhook {
id: string;
url: string;
events: string[];
secret?: string;
enabled: boolean;
createdAt: number;
updatedAt: number;
metadata?: Record<string, any>;
}
export interface DeliveryAttempt {
id: string;
webhookId: string;
url: string;
event: any;
attempt: number;
status: 'pending' | 'success' | 'failed';
error?: string;
timestamp: number;
responseCode?: number;
responseBody?: string;
}
export interface DLQEntry {
id: string;
webhookId: string;
url: string;
event: any;
attempts: number;
lastError?: string;
createdAt: number;
failedAt: number;
}
export interface StorageAdapter {
// Webhook operations
saveWebhook(webhook: Webhook): Promise<void>;
getWebhook(id: string): Promise<Webhook | null>;
listWebhooks(filters?: { enabled?: boolean; event?: string }): Promise<Webhook[]>;
updateWebhook(id: string, updates: Partial<Webhook>): Promise<Webhook>;
deleteWebhook(id: string): Promise<void>;
// Delivery attempt operations
saveDeliveryAttempt(attempt: DeliveryAttempt): Promise<void>;
getDeliveryAttempts(webhookId: string, limit?: number): Promise<DeliveryAttempt[]>;
// DLQ operations
saveToDLQ(entry: DLQEntry): Promise<void>;
getDLQEntries(limit?: number, offset?: number): Promise<{ entries: DLQEntry[]; total: number }>;
removeFromDLQ(id: string): Promise<void>;
}
/**
* In-memory storage implementation
*/
class InMemoryStorage implements StorageAdapter {
private webhooks = new Map<string, Webhook>();
private deliveryAttempts = new Map<string, DeliveryAttempt>();
private dlq = new Map<string, DLQEntry>();
async saveWebhook(webhook: Webhook): Promise<void> {
this.webhooks.set(webhook.id, { ...webhook });
}
async getWebhook(id: string): Promise<Webhook | null> {
return this.webhooks.get(id) || null;
}
async listWebhooks(filters?: { enabled?: boolean; event?: string }): Promise<Webhook[]> {
let webhooks = Array.from(this.webhooks.values());
if (filters?.enabled !== undefined) {
webhooks = webhooks.filter(w => w.enabled === filters.enabled);
}
if (filters?.event) {
webhooks = webhooks.filter(w => w.events.includes(filters.event!));
}
return webhooks;
}
async updateWebhook(id: string, updates: Partial<Webhook>): Promise<Webhook> {
const webhook = this.webhooks.get(id);
if (!webhook) {
throw new Error(`Webhook not found: ${id}`);
}
const updated = {
...webhook,
...updates,
updatedAt: Date.now(),
};
this.webhooks.set(id, updated);
return updated;
}
async deleteWebhook(id: string): Promise<void> {
if (!this.webhooks.has(id)) {
throw new Error(`Webhook not found: ${id}`);
}
this.webhooks.delete(id);
}
async saveDeliveryAttempt(attempt: DeliveryAttempt): Promise<void> {
this.deliveryAttempts.set(attempt.id, { ...attempt });
}
async getDeliveryAttempts(webhookId: string, limit: number = 50): Promise<DeliveryAttempt[]> {
const attempts = Array.from(this.deliveryAttempts.values())
.filter(a => a.webhookId === webhookId)
.sort((a, b) => b.timestamp - a.timestamp)
.slice(0, limit);
return attempts;
}
async saveToDLQ(entry: DLQEntry): Promise<void> {
this.dlq.set(entry.id, { ...entry });
}
async getDLQEntries(limit: number = 20, offset: number = 0): Promise<{ entries: DLQEntry[]; total: number }> {
const entries = Array.from(this.dlq.values())
.sort((a, b) => b.failedAt - a.failedAt);
const total = entries.length;
const paginated = entries.slice(offset, offset + limit);
return { entries: paginated, total };
}
async removeFromDLQ(id: string): Promise<void> {
if (!this.dlq.has(id)) {
throw new Error(`DLQ entry not found: ${id}`);
}
this.dlq.delete(id);
}
}
export const storage: StorageAdapter = new InMemoryStorage();

View File

@@ -2,44 +2,103 @@
* Webhook service - manages webhook registrations
*/
export interface Webhook {
id: string;
url: string;
events: string[];
secret?: string;
enabled: boolean;
createdAt: string;
}
import { v4 as uuidv4 } from 'uuid';
import { storage, Webhook } from './storage';
import { webhookDeliveryService } from './delivery';
export const webhookService = {
async createWebhook(data: Partial<Webhook>): Promise<Webhook> {
// TODO: Store webhook in database
throw new Error('Not implemented');
if (!data.url || !data.events || data.events.length === 0) {
throw new Error('Missing required fields: url, events');
}
// Validate URL
try {
new URL(data.url);
} catch {
throw new Error('Invalid webhook URL');
}
const webhook: Webhook = {
id: uuidv4(),
url: data.url,
events: data.events,
secret: data.secret,
enabled: data.enabled !== undefined ? data.enabled : true,
createdAt: Date.now(),
updatedAt: Date.now(),
metadata: data.metadata,
};
await storage.saveWebhook(webhook);
return webhook;
},
async updateWebhook(id: string, data: Partial<Webhook>): Promise<Webhook> {
// TODO: Update webhook in database
throw new Error('Not implemented');
const webhook = await storage.getWebhook(id);
if (!webhook) {
throw new Error(`Webhook not found: ${id}`);
}
// Validate URL if provided
if (data.url) {
try {
new URL(data.url);
} catch {
throw new Error('Invalid webhook URL');
}
}
const updated = await storage.updateWebhook(id, data);
return updated;
},
async getWebhook(id: string): Promise<Webhook> {
// TODO: Retrieve webhook from database
throw new Error('Not implemented');
const webhook = await storage.getWebhook(id);
if (!webhook) {
throw new Error(`Webhook not found: ${id}`);
}
return webhook;
},
async listWebhooks(): Promise<Webhook[]> {
// TODO: List all webhooks
throw new Error('Not implemented');
async listWebhooks(filters?: { enabled?: boolean; event?: string }): Promise<Webhook[]> {
return await storage.listWebhooks(filters);
},
async deleteWebhook(id: string): Promise<void> {
await storage.deleteWebhook(id);
},
async testWebhook(id: string): Promise<void> {
// TODO: Send test event to webhook
throw new Error('Not implemented');
const webhook = await storage.getWebhook(id);
if (!webhook) {
throw new Error(`Webhook not found: ${id}`);
}
// Send test event
const testEvent = {
eventId: uuidv4(),
eventType: 'webhook.test',
occurredAt: new Date().toISOString(),
payload: {
message: 'This is a test webhook event',
timestamp: Date.now(),
},
};
await webhookDeliveryService.deliver(id, webhook.url, testEvent, webhook.secret);
},
async replayWebhooks(id: string, since?: string): Promise<number> {
// TODO: Replay events since timestamp
throw new Error('Not implemented');
const webhook = await storage.getWebhook(id);
if (!webhook) {
throw new Error(`Webhook not found: ${id}`);
}
// In production, this would query event store for events since timestamp
// For now, return 0 (no events to replay)
// This would require integration with event store/event bus history
return 0;
},
};