@openmdm/core 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,314 @@
1
+ /**
2
+ * OpenMDM Webhook Delivery System
3
+ *
4
+ * Handles outbound webhook delivery with HMAC signing and retry logic.
5
+ */
6
+
7
+ import { createHmac, randomUUID } from 'crypto';
8
+ import type {
9
+ WebhookConfig,
10
+ WebhookEndpoint,
11
+ EventType,
12
+ MDMEvent,
13
+ } from './types';
14
+
15
+ // ============================================
16
+ // Types
17
+ // ============================================
18
+
19
+ export interface WebhookDeliveryResult {
20
+ endpointId: string;
21
+ success: boolean;
22
+ statusCode?: number;
23
+ error?: string;
24
+ retryCount: number;
25
+ deliveredAt?: Date;
26
+ }
27
+
28
+ export interface WebhookPayload<T = unknown> {
29
+ id: string;
30
+ event: EventType;
31
+ timestamp: string;
32
+ data: T;
33
+ }
34
+
35
+ export interface WebhookManager {
36
+ /**
37
+ * Deliver an event to all matching webhook endpoints
38
+ */
39
+ deliver<T>(event: MDMEvent<T>): Promise<WebhookDeliveryResult[]>;
40
+
41
+ /**
42
+ * Add a webhook endpoint at runtime
43
+ */
44
+ addEndpoint(endpoint: WebhookEndpoint): void;
45
+
46
+ /**
47
+ * Remove a webhook endpoint
48
+ */
49
+ removeEndpoint(endpointId: string): void;
50
+
51
+ /**
52
+ * Update a webhook endpoint
53
+ */
54
+ updateEndpoint(endpointId: string, updates: Partial<WebhookEndpoint>): void;
55
+
56
+ /**
57
+ * Get all configured endpoints
58
+ */
59
+ getEndpoints(): WebhookEndpoint[];
60
+
61
+ /**
62
+ * Test a webhook endpoint with a test payload
63
+ */
64
+ testEndpoint(endpointId: string): Promise<WebhookDeliveryResult>;
65
+ }
66
+
67
+ // ============================================
68
+ // Implementation
69
+ // ============================================
70
+
71
+ const DEFAULT_RETRY_CONFIG = {
72
+ maxRetries: 3,
73
+ initialDelay: 1000,
74
+ maxDelay: 30000,
75
+ };
76
+
77
+ /**
78
+ * Create a webhook manager instance
79
+ */
80
+ export function createWebhookManager(config: WebhookConfig): WebhookManager {
81
+ const endpoints = new Map<string, WebhookEndpoint>();
82
+ const retryConfig = { ...DEFAULT_RETRY_CONFIG, ...config.retry };
83
+
84
+ // Initialize with configured endpoints
85
+ if (config.endpoints) {
86
+ for (const endpoint of config.endpoints) {
87
+ endpoints.set(endpoint.id, endpoint);
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Sign a webhook payload with HMAC-SHA256
93
+ */
94
+ function signPayload(payload: string, secret: string): string {
95
+ return createHmac('sha256', secret).update(payload).digest('hex');
96
+ }
97
+
98
+ /**
99
+ * Calculate exponential backoff delay
100
+ */
101
+ function getBackoffDelay(retryCount: number): number {
102
+ const delay = retryConfig.initialDelay * Math.pow(2, retryCount);
103
+ return Math.min(delay, retryConfig.maxDelay);
104
+ }
105
+
106
+ /**
107
+ * Check if an endpoint should receive this event
108
+ */
109
+ function shouldDeliverToEndpoint(
110
+ endpoint: WebhookEndpoint,
111
+ eventType: EventType
112
+ ): boolean {
113
+ if (!endpoint.enabled) {
114
+ return false;
115
+ }
116
+
117
+ // Wildcard matches all events
118
+ if (endpoint.events.includes('*')) {
119
+ return true;
120
+ }
121
+
122
+ return endpoint.events.includes(eventType);
123
+ }
124
+
125
+ /**
126
+ * Deliver payload to a single endpoint with retry logic
127
+ */
128
+ async function deliverToEndpoint(
129
+ endpoint: WebhookEndpoint,
130
+ payload: WebhookPayload
131
+ ): Promise<WebhookDeliveryResult> {
132
+ const payloadString = JSON.stringify(payload);
133
+ let lastError: string | undefined;
134
+ let lastStatusCode: number | undefined;
135
+
136
+ for (let attempt = 0; attempt <= retryConfig.maxRetries; attempt++) {
137
+ try {
138
+ // Prepare headers
139
+ const headers: Record<string, string> = {
140
+ 'Content-Type': 'application/json',
141
+ 'X-OpenMDM-Event': payload.event,
142
+ 'X-OpenMDM-Delivery': payload.id,
143
+ 'X-OpenMDM-Timestamp': payload.timestamp,
144
+ ...endpoint.headers,
145
+ };
146
+
147
+ // Add signature if signing secret is configured
148
+ if (config.signingSecret) {
149
+ const signature = signPayload(payloadString, config.signingSecret);
150
+ headers['X-OpenMDM-Signature'] = `sha256=${signature}`;
151
+ }
152
+
153
+ // Make the request
154
+ const response = await fetch(endpoint.url, {
155
+ method: 'POST',
156
+ headers,
157
+ body: payloadString,
158
+ signal: AbortSignal.timeout(30000), // 30 second timeout
159
+ });
160
+
161
+ lastStatusCode = response.status;
162
+
163
+ // 2xx is success
164
+ if (response.ok) {
165
+ return {
166
+ endpointId: endpoint.id,
167
+ success: true,
168
+ statusCode: response.status,
169
+ retryCount: attempt,
170
+ deliveredAt: new Date(),
171
+ };
172
+ }
173
+
174
+ // 4xx errors (except 429) should not be retried
175
+ if (response.status >= 400 && response.status < 500 && response.status !== 429) {
176
+ return {
177
+ endpointId: endpoint.id,
178
+ success: false,
179
+ statusCode: response.status,
180
+ error: `HTTP ${response.status}: ${response.statusText}`,
181
+ retryCount: attempt,
182
+ };
183
+ }
184
+
185
+ // 5xx and 429 should be retried
186
+ lastError = `HTTP ${response.status}: ${response.statusText}`;
187
+ } catch (error) {
188
+ lastError = error instanceof Error ? error.message : String(error);
189
+ }
190
+
191
+ // Wait before retry (unless this was the last attempt)
192
+ if (attempt < retryConfig.maxRetries) {
193
+ const delay = getBackoffDelay(attempt);
194
+ await new Promise((resolve) => setTimeout(resolve, delay));
195
+ }
196
+ }
197
+
198
+ return {
199
+ endpointId: endpoint.id,
200
+ success: false,
201
+ statusCode: lastStatusCode,
202
+ error: lastError || 'Max retries exceeded',
203
+ retryCount: retryConfig.maxRetries,
204
+ };
205
+ }
206
+
207
+ return {
208
+ async deliver<T>(event: MDMEvent<T>): Promise<WebhookDeliveryResult[]> {
209
+ const matchingEndpoints = Array.from(endpoints.values()).filter((ep) =>
210
+ shouldDeliverToEndpoint(ep, event.type)
211
+ );
212
+
213
+ if (matchingEndpoints.length === 0) {
214
+ return [];
215
+ }
216
+
217
+ // Prepare webhook payload
218
+ const payload: WebhookPayload<T> = {
219
+ id: randomUUID(),
220
+ event: event.type,
221
+ timestamp: new Date().toISOString(),
222
+ data: event.payload,
223
+ };
224
+
225
+ // Deliver to all matching endpoints in parallel
226
+ const deliveryPromises = matchingEndpoints.map((endpoint) =>
227
+ deliverToEndpoint(endpoint, payload as WebhookPayload)
228
+ );
229
+
230
+ const results = await Promise.all(deliveryPromises);
231
+
232
+ // Log failures
233
+ for (const result of results) {
234
+ if (!result.success) {
235
+ console.error(
236
+ `[OpenMDM] Webhook delivery failed to endpoint ${result.endpointId}:`,
237
+ result.error
238
+ );
239
+ }
240
+ }
241
+
242
+ return results;
243
+ },
244
+
245
+ addEndpoint(endpoint: WebhookEndpoint): void {
246
+ endpoints.set(endpoint.id, endpoint);
247
+ },
248
+
249
+ removeEndpoint(endpointId: string): void {
250
+ endpoints.delete(endpointId);
251
+ },
252
+
253
+ updateEndpoint(endpointId: string, updates: Partial<WebhookEndpoint>): void {
254
+ const existing = endpoints.get(endpointId);
255
+ if (existing) {
256
+ endpoints.set(endpointId, { ...existing, ...updates });
257
+ }
258
+ },
259
+
260
+ getEndpoints(): WebhookEndpoint[] {
261
+ return Array.from(endpoints.values());
262
+ },
263
+
264
+ async testEndpoint(endpointId: string): Promise<WebhookDeliveryResult> {
265
+ const endpoint = endpoints.get(endpointId);
266
+ if (!endpoint) {
267
+ return {
268
+ endpointId,
269
+ success: false,
270
+ error: 'Endpoint not found',
271
+ retryCount: 0,
272
+ };
273
+ }
274
+
275
+ const testPayload: WebhookPayload = {
276
+ id: randomUUID(),
277
+ event: 'device.heartbeat',
278
+ timestamp: new Date().toISOString(),
279
+ data: {
280
+ test: true,
281
+ message: 'OpenMDM webhook test',
282
+ },
283
+ };
284
+
285
+ return deliverToEndpoint(endpoint, testPayload);
286
+ },
287
+ };
288
+ }
289
+
290
+ /**
291
+ * Verify a webhook signature from incoming requests
292
+ * (Utility for consumers to verify our webhooks)
293
+ */
294
+ export function verifyWebhookSignature(
295
+ payload: string,
296
+ signature: string,
297
+ secret: string
298
+ ): boolean {
299
+ const expectedSignature = `sha256=${createHmac('sha256', secret)
300
+ .update(payload)
301
+ .digest('hex')}`;
302
+
303
+ // Constant-time comparison
304
+ if (signature.length !== expectedSignature.length) {
305
+ return false;
306
+ }
307
+
308
+ let result = 0;
309
+ for (let i = 0; i < signature.length; i++) {
310
+ result |= signature.charCodeAt(i) ^ expectedSignature.charCodeAt(i);
311
+ }
312
+
313
+ return result === 0;
314
+ }