@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +90 -0
- package/dist/index.js +1368 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +78 -0
- package/dist/schema.js +415 -0
- package/dist/schema.js.map +1 -0
- package/dist/types.d.ts +899 -0
- package/dist/types.js +49 -0
- package/dist/types.js.map +1 -0
- package/package.json +67 -0
- package/src/index.ts +1145 -0
- package/src/schema.ts +533 -0
- package/src/types.ts +1161 -0
- package/src/webhooks.ts +314 -0
package/src/webhooks.ts
ADDED
|
@@ -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
|
+
}
|