@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/index.ts
ADDED
|
@@ -0,0 +1,1145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenMDM Core
|
|
3
|
+
*
|
|
4
|
+
* A flexible, embeddable MDM (Mobile Device Management) SDK.
|
|
5
|
+
* Inspired by better-auth's design philosophy.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { createMDM } from '@openmdm/core';
|
|
10
|
+
* import { drizzleAdapter } from '@openmdm/drizzle-adapter';
|
|
11
|
+
*
|
|
12
|
+
* const mdm = createMDM({
|
|
13
|
+
* database: drizzleAdapter(db),
|
|
14
|
+
* enrollment: {
|
|
15
|
+
* deviceSecret: process.env.DEVICE_HMAC_SECRET!,
|
|
16
|
+
* autoEnroll: true,
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* // Use in your routes
|
|
21
|
+
* const devices = await mdm.devices.list();
|
|
22
|
+
* ```
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
|
|
26
|
+
import type {
|
|
27
|
+
MDMConfig,
|
|
28
|
+
MDMInstance,
|
|
29
|
+
Device,
|
|
30
|
+
Policy,
|
|
31
|
+
Application,
|
|
32
|
+
Command,
|
|
33
|
+
Group,
|
|
34
|
+
Heartbeat,
|
|
35
|
+
EnrollmentRequest,
|
|
36
|
+
EnrollmentResponse,
|
|
37
|
+
DeviceFilter,
|
|
38
|
+
DeviceListResult,
|
|
39
|
+
CreateDeviceInput,
|
|
40
|
+
UpdateDeviceInput,
|
|
41
|
+
CreatePolicyInput,
|
|
42
|
+
UpdatePolicyInput,
|
|
43
|
+
CreateApplicationInput,
|
|
44
|
+
UpdateApplicationInput,
|
|
45
|
+
SendCommandInput,
|
|
46
|
+
CommandFilter,
|
|
47
|
+
CreateGroupInput,
|
|
48
|
+
UpdateGroupInput,
|
|
49
|
+
DeployTarget,
|
|
50
|
+
DeviceManager,
|
|
51
|
+
PolicyManager,
|
|
52
|
+
ApplicationManager,
|
|
53
|
+
CommandManager,
|
|
54
|
+
GroupManager,
|
|
55
|
+
PushAdapter,
|
|
56
|
+
PushResult,
|
|
57
|
+
PushBatchResult,
|
|
58
|
+
PushMessage,
|
|
59
|
+
EventType,
|
|
60
|
+
EventHandler,
|
|
61
|
+
EventPayloadMap,
|
|
62
|
+
MDMEvent,
|
|
63
|
+
MDMPlugin,
|
|
64
|
+
CommandResult,
|
|
65
|
+
InstalledApp,
|
|
66
|
+
WebhookManager,
|
|
67
|
+
} from './types';
|
|
68
|
+
import {
|
|
69
|
+
DeviceNotFoundError,
|
|
70
|
+
ApplicationNotFoundError,
|
|
71
|
+
EnrollmentError,
|
|
72
|
+
} from './types';
|
|
73
|
+
import { createWebhookManager } from './webhooks';
|
|
74
|
+
|
|
75
|
+
// Re-export all types
|
|
76
|
+
export * from './types';
|
|
77
|
+
export * from './schema';
|
|
78
|
+
export { createWebhookManager, verifyWebhookSignature } from './webhooks';
|
|
79
|
+
export type { WebhookPayload } from './webhooks';
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Create an MDM instance with the given configuration.
|
|
83
|
+
*/
|
|
84
|
+
export function createMDM(config: MDMConfig): MDMInstance {
|
|
85
|
+
const { database, push, enrollment, webhooks: webhooksConfig, plugins = [] } = config;
|
|
86
|
+
|
|
87
|
+
// Event handlers registry
|
|
88
|
+
const eventHandlers = new Map<EventType, Set<EventHandler<EventType>>>();
|
|
89
|
+
|
|
90
|
+
// Create push adapter
|
|
91
|
+
const pushAdapter: PushAdapter = push
|
|
92
|
+
? createPushAdapter(push, database)
|
|
93
|
+
: createStubPushAdapter();
|
|
94
|
+
|
|
95
|
+
// Create webhook manager if configured
|
|
96
|
+
const webhookManager: WebhookManager | undefined = webhooksConfig
|
|
97
|
+
? createWebhookManager(webhooksConfig)
|
|
98
|
+
: undefined;
|
|
99
|
+
|
|
100
|
+
// Event subscription
|
|
101
|
+
const on = <T extends EventType>(
|
|
102
|
+
event: T,
|
|
103
|
+
handler: EventHandler<T>
|
|
104
|
+
): (() => void) => {
|
|
105
|
+
if (!eventHandlers.has(event)) {
|
|
106
|
+
eventHandlers.set(event, new Set());
|
|
107
|
+
}
|
|
108
|
+
const handlers = eventHandlers.get(event)!;
|
|
109
|
+
handlers.add(handler as EventHandler<EventType>);
|
|
110
|
+
|
|
111
|
+
// Return unsubscribe function
|
|
112
|
+
return () => {
|
|
113
|
+
handlers.delete(handler as EventHandler<EventType>);
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Event emission
|
|
118
|
+
const emit = async <T extends EventType>(
|
|
119
|
+
event: T,
|
|
120
|
+
data: EventPayloadMap[T]
|
|
121
|
+
): Promise<void> => {
|
|
122
|
+
const handlers = eventHandlers.get(event);
|
|
123
|
+
|
|
124
|
+
// Create event record
|
|
125
|
+
const eventRecord: MDMEvent<EventPayloadMap[T]> = {
|
|
126
|
+
id: randomUUID(),
|
|
127
|
+
deviceId: (data as any).device?.id || (data as any).deviceId || '',
|
|
128
|
+
type: event,
|
|
129
|
+
payload: data,
|
|
130
|
+
createdAt: new Date(),
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// Persist event
|
|
134
|
+
try {
|
|
135
|
+
await database.createEvent({
|
|
136
|
+
deviceId: eventRecord.deviceId,
|
|
137
|
+
type: eventRecord.type,
|
|
138
|
+
payload: eventRecord.payload as Record<string, unknown>,
|
|
139
|
+
});
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error('[OpenMDM] Failed to persist event:', error);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Deliver webhooks (async, don't wait)
|
|
145
|
+
if (webhookManager) {
|
|
146
|
+
webhookManager.deliver(eventRecord).catch((error) => {
|
|
147
|
+
console.error('[OpenMDM] Webhook delivery error:', error);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Call handlers
|
|
152
|
+
if (handlers) {
|
|
153
|
+
for (const handler of handlers) {
|
|
154
|
+
try {
|
|
155
|
+
await handler(eventRecord);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error(`[OpenMDM] Event handler error for ${event}:`, error);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Call config hook if defined
|
|
163
|
+
if (config.onEvent) {
|
|
164
|
+
try {
|
|
165
|
+
await config.onEvent(eventRecord);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error('[OpenMDM] onEvent hook error:', error);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// ============================================
|
|
173
|
+
// Device Manager
|
|
174
|
+
// ============================================
|
|
175
|
+
|
|
176
|
+
const devices: DeviceManager = {
|
|
177
|
+
async get(id: string): Promise<Device | null> {
|
|
178
|
+
return database.findDevice(id);
|
|
179
|
+
},
|
|
180
|
+
|
|
181
|
+
async getByEnrollmentId(enrollmentId: string): Promise<Device | null> {
|
|
182
|
+
return database.findDeviceByEnrollmentId(enrollmentId);
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
async list(filter?: DeviceFilter): Promise<DeviceListResult> {
|
|
186
|
+
return database.listDevices(filter);
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async create(data: CreateDeviceInput): Promise<Device> {
|
|
190
|
+
const device = await database.createDevice(data);
|
|
191
|
+
|
|
192
|
+
await emit('device.enrolled', { device });
|
|
193
|
+
|
|
194
|
+
if (config.onDeviceEnrolled) {
|
|
195
|
+
await config.onDeviceEnrolled(device);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return device;
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async update(id: string, data: UpdateDeviceInput): Promise<Device> {
|
|
202
|
+
const oldDevice = await database.findDevice(id);
|
|
203
|
+
if (!oldDevice) {
|
|
204
|
+
throw new DeviceNotFoundError(id);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const device = await database.updateDevice(id, data);
|
|
208
|
+
|
|
209
|
+
// Emit status change event if status changed
|
|
210
|
+
if (data.status && data.status !== oldDevice.status) {
|
|
211
|
+
await emit('device.statusChanged', {
|
|
212
|
+
device,
|
|
213
|
+
oldStatus: oldDevice.status,
|
|
214
|
+
newStatus: data.status,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Emit policy change event if policy changed
|
|
219
|
+
if (data.policyId !== undefined && data.policyId !== oldDevice.policyId) {
|
|
220
|
+
await emit('device.policyChanged', {
|
|
221
|
+
device,
|
|
222
|
+
oldPolicyId: oldDevice.policyId || undefined,
|
|
223
|
+
newPolicyId: data.policyId || undefined,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return device;
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
async delete(id: string): Promise<void> {
|
|
231
|
+
const device = await database.findDevice(id);
|
|
232
|
+
if (device) {
|
|
233
|
+
await database.deleteDevice(id);
|
|
234
|
+
await emit('device.unenrolled', { device });
|
|
235
|
+
|
|
236
|
+
if (config.onDeviceUnenrolled) {
|
|
237
|
+
await config.onDeviceUnenrolled(device);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
async assignPolicy(
|
|
243
|
+
deviceId: string,
|
|
244
|
+
policyId: string | null
|
|
245
|
+
): Promise<Device> {
|
|
246
|
+
const device = await this.update(deviceId, { policyId });
|
|
247
|
+
|
|
248
|
+
// Notify device of policy change
|
|
249
|
+
await pushAdapter.send(deviceId, {
|
|
250
|
+
type: 'policy.updated',
|
|
251
|
+
payload: { policyId },
|
|
252
|
+
priority: 'high',
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return device;
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
async addToGroup(deviceId: string, groupId: string): Promise<void> {
|
|
259
|
+
await database.addDeviceToGroup(deviceId, groupId);
|
|
260
|
+
},
|
|
261
|
+
|
|
262
|
+
async removeFromGroup(deviceId: string, groupId: string): Promise<void> {
|
|
263
|
+
await database.removeDeviceFromGroup(deviceId, groupId);
|
|
264
|
+
},
|
|
265
|
+
|
|
266
|
+
async getGroups(deviceId: string): Promise<Group[]> {
|
|
267
|
+
return database.getDeviceGroups(deviceId);
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
async sendCommand(
|
|
271
|
+
deviceId: string,
|
|
272
|
+
input: Omit<SendCommandInput, 'deviceId'>
|
|
273
|
+
): Promise<Command> {
|
|
274
|
+
const command = await database.createCommand({
|
|
275
|
+
...input,
|
|
276
|
+
deviceId,
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Send via push
|
|
280
|
+
const pushResult = await pushAdapter.send(deviceId, {
|
|
281
|
+
type: `command.${input.type}`,
|
|
282
|
+
payload: {
|
|
283
|
+
commandId: command.id,
|
|
284
|
+
type: input.type,
|
|
285
|
+
...input.payload,
|
|
286
|
+
},
|
|
287
|
+
priority: 'high',
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// Update command status
|
|
291
|
+
if (pushResult.success) {
|
|
292
|
+
await database.updateCommand(command.id, {
|
|
293
|
+
status: 'sent',
|
|
294
|
+
sentAt: new Date(),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (config.onCommand) {
|
|
299
|
+
await config.onCommand(command);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return database.findCommand(command.id) as Promise<Command>;
|
|
303
|
+
},
|
|
304
|
+
|
|
305
|
+
async sync(deviceId: string): Promise<Command> {
|
|
306
|
+
return this.sendCommand(deviceId, { type: 'sync' });
|
|
307
|
+
},
|
|
308
|
+
|
|
309
|
+
async reboot(deviceId: string): Promise<Command> {
|
|
310
|
+
return this.sendCommand(deviceId, { type: 'reboot' });
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async lock(deviceId: string, message?: string): Promise<Command> {
|
|
314
|
+
return this.sendCommand(deviceId, {
|
|
315
|
+
type: 'lock',
|
|
316
|
+
payload: message ? { message } : undefined,
|
|
317
|
+
});
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
async wipe(deviceId: string, preserveData?: boolean): Promise<Command> {
|
|
321
|
+
return this.sendCommand(deviceId, {
|
|
322
|
+
type: preserveData ? 'wipe' : 'factoryReset',
|
|
323
|
+
payload: { preserveData },
|
|
324
|
+
});
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// ============================================
|
|
329
|
+
// Policy Manager
|
|
330
|
+
// ============================================
|
|
331
|
+
|
|
332
|
+
const policies: PolicyManager = {
|
|
333
|
+
async get(id: string): Promise<Policy | null> {
|
|
334
|
+
return database.findPolicy(id);
|
|
335
|
+
},
|
|
336
|
+
|
|
337
|
+
async getDefault(): Promise<Policy | null> {
|
|
338
|
+
return database.findDefaultPolicy();
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
async list(): Promise<Policy[]> {
|
|
342
|
+
return database.listPolicies();
|
|
343
|
+
},
|
|
344
|
+
|
|
345
|
+
async create(data: CreatePolicyInput): Promise<Policy> {
|
|
346
|
+
// If this is being set as default, clear other defaults first
|
|
347
|
+
if (data.isDefault) {
|
|
348
|
+
const existingPolicies = await database.listPolicies();
|
|
349
|
+
for (const policy of existingPolicies) {
|
|
350
|
+
if (policy.isDefault) {
|
|
351
|
+
await database.updatePolicy(policy.id, { isDefault: false });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return database.createPolicy(data);
|
|
357
|
+
},
|
|
358
|
+
|
|
359
|
+
async update(id: string, data: UpdatePolicyInput): Promise<Policy> {
|
|
360
|
+
// If setting as default, clear other defaults first
|
|
361
|
+
if (data.isDefault) {
|
|
362
|
+
const existingPolicies = await database.listPolicies();
|
|
363
|
+
for (const policy of existingPolicies) {
|
|
364
|
+
if (policy.isDefault && policy.id !== id) {
|
|
365
|
+
await database.updatePolicy(policy.id, { isDefault: false });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const policy = await database.updatePolicy(id, data);
|
|
371
|
+
|
|
372
|
+
// Notify all devices with this policy
|
|
373
|
+
const devicesResult = await database.listDevices({ policyId: id });
|
|
374
|
+
if (devicesResult.devices.length > 0) {
|
|
375
|
+
const deviceIds = devicesResult.devices.map((d) => d.id);
|
|
376
|
+
await pushAdapter.sendBatch(deviceIds, {
|
|
377
|
+
type: 'policy.updated',
|
|
378
|
+
payload: { policyId: id },
|
|
379
|
+
priority: 'high',
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return policy;
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
async delete(id: string): Promise<void> {
|
|
387
|
+
// Check if any devices use this policy
|
|
388
|
+
const devicesResult = await database.listDevices({ policyId: id });
|
|
389
|
+
if (devicesResult.devices.length > 0) {
|
|
390
|
+
// Remove policy from devices first
|
|
391
|
+
for (const device of devicesResult.devices) {
|
|
392
|
+
await database.updateDevice(device.id, { policyId: null });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
await database.deletePolicy(id);
|
|
397
|
+
},
|
|
398
|
+
|
|
399
|
+
async setDefault(id: string): Promise<Policy> {
|
|
400
|
+
return this.update(id, { isDefault: true });
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
async getDevices(policyId: string): Promise<Device[]> {
|
|
404
|
+
const result = await database.listDevices({ policyId });
|
|
405
|
+
return result.devices;
|
|
406
|
+
},
|
|
407
|
+
|
|
408
|
+
async applyToDevice(policyId: string, deviceId: string): Promise<void> {
|
|
409
|
+
await devices.assignPolicy(deviceId, policyId);
|
|
410
|
+
},
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// ============================================
|
|
414
|
+
// Application Manager
|
|
415
|
+
// ============================================
|
|
416
|
+
|
|
417
|
+
const apps: ApplicationManager = {
|
|
418
|
+
async get(id: string): Promise<Application | null> {
|
|
419
|
+
return database.findApplication(id);
|
|
420
|
+
},
|
|
421
|
+
|
|
422
|
+
async getByPackage(
|
|
423
|
+
packageName: string,
|
|
424
|
+
version?: string
|
|
425
|
+
): Promise<Application | null> {
|
|
426
|
+
return database.findApplicationByPackage(packageName, version);
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
async list(activeOnly?: boolean): Promise<Application[]> {
|
|
430
|
+
return database.listApplications(activeOnly);
|
|
431
|
+
},
|
|
432
|
+
|
|
433
|
+
async register(data: CreateApplicationInput): Promise<Application> {
|
|
434
|
+
return database.createApplication(data);
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
async update(id: string, data: UpdateApplicationInput): Promise<Application> {
|
|
438
|
+
return database.updateApplication(id, data);
|
|
439
|
+
},
|
|
440
|
+
|
|
441
|
+
async delete(id: string): Promise<void> {
|
|
442
|
+
await database.deleteApplication(id);
|
|
443
|
+
},
|
|
444
|
+
|
|
445
|
+
async activate(id: string): Promise<Application> {
|
|
446
|
+
return database.updateApplication(id, { isActive: true });
|
|
447
|
+
},
|
|
448
|
+
|
|
449
|
+
async deactivate(id: string): Promise<Application> {
|
|
450
|
+
return database.updateApplication(id, { isActive: false });
|
|
451
|
+
},
|
|
452
|
+
|
|
453
|
+
async deploy(packageName: string, target: DeployTarget): Promise<void> {
|
|
454
|
+
const app = await database.findApplicationByPackage(packageName);
|
|
455
|
+
if (!app) {
|
|
456
|
+
throw new ApplicationNotFoundError(packageName);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const deviceIds: string[] = [];
|
|
460
|
+
|
|
461
|
+
// Collect target devices
|
|
462
|
+
if (target.devices) {
|
|
463
|
+
deviceIds.push(...target.devices);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
if (target.groups) {
|
|
467
|
+
for (const groupId of target.groups) {
|
|
468
|
+
const groupDevices = await database.listDevicesInGroup(groupId);
|
|
469
|
+
deviceIds.push(...groupDevices.map((d) => d.id));
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (target.policies) {
|
|
474
|
+
for (const policyId of target.policies) {
|
|
475
|
+
const result = await database.listDevices({ policyId });
|
|
476
|
+
deviceIds.push(...result.devices.map((d) => d.id));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Deduplicate
|
|
481
|
+
const uniqueDeviceIds = [...new Set(deviceIds)];
|
|
482
|
+
|
|
483
|
+
// Send install command to all devices
|
|
484
|
+
if (uniqueDeviceIds.length > 0) {
|
|
485
|
+
await pushAdapter.sendBatch(uniqueDeviceIds, {
|
|
486
|
+
type: 'command.installApp',
|
|
487
|
+
payload: {
|
|
488
|
+
packageName: app.packageName,
|
|
489
|
+
version: app.version,
|
|
490
|
+
versionCode: app.versionCode,
|
|
491
|
+
url: app.url,
|
|
492
|
+
hash: app.hash,
|
|
493
|
+
},
|
|
494
|
+
priority: 'high',
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
// Create command records for each device
|
|
498
|
+
for (const deviceId of uniqueDeviceIds) {
|
|
499
|
+
await database.createCommand({
|
|
500
|
+
deviceId,
|
|
501
|
+
type: 'installApp',
|
|
502
|
+
payload: {
|
|
503
|
+
packageName: app.packageName,
|
|
504
|
+
version: app.version,
|
|
505
|
+
url: app.url,
|
|
506
|
+
},
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
async installOnDevice(
|
|
513
|
+
packageName: string,
|
|
514
|
+
deviceId: string,
|
|
515
|
+
version?: string
|
|
516
|
+
): Promise<Command> {
|
|
517
|
+
const app = await database.findApplicationByPackage(packageName, version);
|
|
518
|
+
if (!app) {
|
|
519
|
+
throw new ApplicationNotFoundError(packageName);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return devices.sendCommand(deviceId, {
|
|
523
|
+
type: 'installApp',
|
|
524
|
+
payload: {
|
|
525
|
+
packageName: app.packageName,
|
|
526
|
+
version: app.version,
|
|
527
|
+
versionCode: app.versionCode,
|
|
528
|
+
url: app.url,
|
|
529
|
+
hash: app.hash,
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
},
|
|
533
|
+
|
|
534
|
+
async uninstallFromDevice(
|
|
535
|
+
packageName: string,
|
|
536
|
+
deviceId: string
|
|
537
|
+
): Promise<Command> {
|
|
538
|
+
return devices.sendCommand(deviceId, {
|
|
539
|
+
type: 'uninstallApp',
|
|
540
|
+
payload: { packageName },
|
|
541
|
+
});
|
|
542
|
+
},
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// ============================================
|
|
546
|
+
// Command Manager
|
|
547
|
+
// ============================================
|
|
548
|
+
|
|
549
|
+
const commands: CommandManager = {
|
|
550
|
+
async get(id: string): Promise<Command | null> {
|
|
551
|
+
return database.findCommand(id);
|
|
552
|
+
},
|
|
553
|
+
|
|
554
|
+
async list(filter?: CommandFilter): Promise<Command[]> {
|
|
555
|
+
return database.listCommands(filter);
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
async send(input: SendCommandInput): Promise<Command> {
|
|
559
|
+
return devices.sendCommand(input.deviceId, {
|
|
560
|
+
type: input.type,
|
|
561
|
+
payload: input.payload,
|
|
562
|
+
});
|
|
563
|
+
},
|
|
564
|
+
|
|
565
|
+
async cancel(id: string): Promise<Command> {
|
|
566
|
+
return database.updateCommand(id, { status: 'cancelled' });
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
async acknowledge(id: string): Promise<Command> {
|
|
570
|
+
const command = await database.updateCommand(id, {
|
|
571
|
+
status: 'acknowledged',
|
|
572
|
+
acknowledgedAt: new Date(),
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const device = await database.findDevice(command.deviceId);
|
|
576
|
+
if (device) {
|
|
577
|
+
await emit('command.acknowledged', { device, command });
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
return command;
|
|
581
|
+
},
|
|
582
|
+
|
|
583
|
+
async complete(id: string, result: CommandResult): Promise<Command> {
|
|
584
|
+
const command = await database.updateCommand(id, {
|
|
585
|
+
status: 'completed',
|
|
586
|
+
result,
|
|
587
|
+
completedAt: new Date(),
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const device = await database.findDevice(command.deviceId);
|
|
591
|
+
if (device) {
|
|
592
|
+
await emit('command.completed', { device, command, result });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return command;
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
async fail(id: string, error: string): Promise<Command> {
|
|
599
|
+
const command = await database.updateCommand(id, {
|
|
600
|
+
status: 'failed',
|
|
601
|
+
error,
|
|
602
|
+
completedAt: new Date(),
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
const device = await database.findDevice(command.deviceId);
|
|
606
|
+
if (device) {
|
|
607
|
+
await emit('command.failed', { device, command, error });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
return command;
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
async getPending(deviceId: string): Promise<Command[]> {
|
|
614
|
+
return database.getPendingCommands(deviceId);
|
|
615
|
+
},
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
// ============================================
|
|
619
|
+
// Group Manager
|
|
620
|
+
// ============================================
|
|
621
|
+
|
|
622
|
+
const groups: GroupManager = {
|
|
623
|
+
async get(id: string): Promise<Group | null> {
|
|
624
|
+
return database.findGroup(id);
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
async list(): Promise<Group[]> {
|
|
628
|
+
return database.listGroups();
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
async create(data: CreateGroupInput): Promise<Group> {
|
|
632
|
+
return database.createGroup(data);
|
|
633
|
+
},
|
|
634
|
+
|
|
635
|
+
async update(id: string, data: UpdateGroupInput): Promise<Group> {
|
|
636
|
+
return database.updateGroup(id, data);
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
async delete(id: string): Promise<void> {
|
|
640
|
+
await database.deleteGroup(id);
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
async getDevices(groupId: string): Promise<Device[]> {
|
|
644
|
+
return database.listDevicesInGroup(groupId);
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
async addDevice(groupId: string, deviceId: string): Promise<void> {
|
|
648
|
+
await database.addDeviceToGroup(deviceId, groupId);
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
async removeDevice(groupId: string, deviceId: string): Promise<void> {
|
|
652
|
+
await database.removeDeviceFromGroup(deviceId, groupId);
|
|
653
|
+
},
|
|
654
|
+
|
|
655
|
+
async getChildren(groupId: string): Promise<Group[]> {
|
|
656
|
+
const allGroups = await database.listGroups();
|
|
657
|
+
return allGroups.filter((g) => g.parentId === groupId);
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
// ============================================
|
|
662
|
+
// Enrollment
|
|
663
|
+
// ============================================
|
|
664
|
+
|
|
665
|
+
const enroll = async (
|
|
666
|
+
request: EnrollmentRequest
|
|
667
|
+
): Promise<EnrollmentResponse> => {
|
|
668
|
+
// Validate method if restricted
|
|
669
|
+
if (
|
|
670
|
+
enrollment?.allowedMethods &&
|
|
671
|
+
!enrollment.allowedMethods.includes(request.method)
|
|
672
|
+
) {
|
|
673
|
+
throw new EnrollmentError(
|
|
674
|
+
`Enrollment method '${request.method}' is not allowed`
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Verify signature if secret is configured
|
|
679
|
+
if (enrollment?.deviceSecret) {
|
|
680
|
+
const isValid = verifyEnrollmentSignature(
|
|
681
|
+
request,
|
|
682
|
+
enrollment.deviceSecret
|
|
683
|
+
);
|
|
684
|
+
if (!isValid) {
|
|
685
|
+
throw new EnrollmentError('Invalid enrollment signature');
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
// Custom validation
|
|
690
|
+
if (enrollment?.validate) {
|
|
691
|
+
const isValid = await enrollment.validate(request);
|
|
692
|
+
if (!isValid) {
|
|
693
|
+
throw new EnrollmentError('Enrollment validation failed');
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Determine enrollment ID
|
|
698
|
+
const enrollmentId =
|
|
699
|
+
request.macAddress ||
|
|
700
|
+
request.serialNumber ||
|
|
701
|
+
request.imei ||
|
|
702
|
+
request.androidId;
|
|
703
|
+
|
|
704
|
+
if (!enrollmentId) {
|
|
705
|
+
throw new EnrollmentError(
|
|
706
|
+
'Device must provide at least one identifier (macAddress, serialNumber, imei, or androidId)'
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// Check if device already exists
|
|
711
|
+
let device = await database.findDeviceByEnrollmentId(enrollmentId);
|
|
712
|
+
|
|
713
|
+
if (device) {
|
|
714
|
+
// Device re-enrolling
|
|
715
|
+
device = await database.updateDevice(device.id, {
|
|
716
|
+
status: 'enrolled',
|
|
717
|
+
model: request.model,
|
|
718
|
+
manufacturer: request.manufacturer,
|
|
719
|
+
osVersion: request.osVersion,
|
|
720
|
+
lastSync: new Date(),
|
|
721
|
+
});
|
|
722
|
+
} else if (enrollment?.autoEnroll) {
|
|
723
|
+
// Auto-create device
|
|
724
|
+
device = await database.createDevice({
|
|
725
|
+
enrollmentId,
|
|
726
|
+
model: request.model,
|
|
727
|
+
manufacturer: request.manufacturer,
|
|
728
|
+
osVersion: request.osVersion,
|
|
729
|
+
serialNumber: request.serialNumber,
|
|
730
|
+
imei: request.imei,
|
|
731
|
+
macAddress: request.macAddress,
|
|
732
|
+
androidId: request.androidId,
|
|
733
|
+
policyId: request.policyId || enrollment.defaultPolicyId,
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
// Add to default group if configured
|
|
737
|
+
if (enrollment.defaultGroupId) {
|
|
738
|
+
await database.addDeviceToGroup(device.id, enrollment.defaultGroupId);
|
|
739
|
+
}
|
|
740
|
+
} else if (enrollment?.requireApproval) {
|
|
741
|
+
// Create pending device
|
|
742
|
+
device = await database.createDevice({
|
|
743
|
+
enrollmentId,
|
|
744
|
+
model: request.model,
|
|
745
|
+
manufacturer: request.manufacturer,
|
|
746
|
+
osVersion: request.osVersion,
|
|
747
|
+
serialNumber: request.serialNumber,
|
|
748
|
+
imei: request.imei,
|
|
749
|
+
macAddress: request.macAddress,
|
|
750
|
+
androidId: request.androidId,
|
|
751
|
+
});
|
|
752
|
+
// Status remains 'pending'
|
|
753
|
+
} else {
|
|
754
|
+
throw new EnrollmentError(
|
|
755
|
+
'Device not registered and auto-enroll is disabled'
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// Get policy
|
|
760
|
+
let policy: Policy | null = null;
|
|
761
|
+
if (device.policyId) {
|
|
762
|
+
policy = await database.findPolicy(device.policyId);
|
|
763
|
+
}
|
|
764
|
+
if (!policy) {
|
|
765
|
+
policy = await database.findDefaultPolicy();
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// Generate JWT token for device auth
|
|
769
|
+
const tokenSecret =
|
|
770
|
+
config.auth?.deviceTokenSecret || enrollment?.deviceSecret || '';
|
|
771
|
+
const tokenExpiration = config.auth?.deviceTokenExpiration || 365 * 24 * 60 * 60;
|
|
772
|
+
const token = generateDeviceToken(device.id, tokenSecret, tokenExpiration);
|
|
773
|
+
|
|
774
|
+
// Emit enrollment event
|
|
775
|
+
await emit('device.enrolled', { device });
|
|
776
|
+
|
|
777
|
+
// Call config hook if defined
|
|
778
|
+
if (config.onDeviceEnrolled) {
|
|
779
|
+
await config.onDeviceEnrolled(device);
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Call plugin hooks
|
|
783
|
+
for (const plugin of plugins) {
|
|
784
|
+
if (plugin.onEnroll) {
|
|
785
|
+
await plugin.onEnroll(device, request);
|
|
786
|
+
}
|
|
787
|
+
if (plugin.onDeviceEnrolled) {
|
|
788
|
+
await plugin.onDeviceEnrolled(device);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
return {
|
|
793
|
+
deviceId: device.id,
|
|
794
|
+
enrollmentId: device.enrollmentId,
|
|
795
|
+
policyId: policy?.id,
|
|
796
|
+
policy: policy || undefined,
|
|
797
|
+
serverUrl: config.serverUrl || '',
|
|
798
|
+
pushConfig: {
|
|
799
|
+
provider: push?.provider || 'polling',
|
|
800
|
+
fcmSenderId: (push?.fcmCredentials as any)?.project_id,
|
|
801
|
+
mqttUrl: push?.mqttUrl,
|
|
802
|
+
mqttTopic: push?.mqttTopicPrefix
|
|
803
|
+
? `${push.mqttTopicPrefix}/${device.id}`
|
|
804
|
+
: `openmdm/devices/${device.id}`,
|
|
805
|
+
pollingInterval: push?.pollingInterval || 60,
|
|
806
|
+
},
|
|
807
|
+
token,
|
|
808
|
+
tokenExpiresAt: new Date(Date.now() + tokenExpiration * 1000),
|
|
809
|
+
};
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
// ============================================
|
|
813
|
+
// Heartbeat Processing
|
|
814
|
+
// ============================================
|
|
815
|
+
|
|
816
|
+
const processHeartbeat = async (
|
|
817
|
+
deviceId: string,
|
|
818
|
+
heartbeat: Heartbeat
|
|
819
|
+
): Promise<void> => {
|
|
820
|
+
const device = await database.findDevice(deviceId);
|
|
821
|
+
if (!device) {
|
|
822
|
+
throw new DeviceNotFoundError(deviceId);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Update device with heartbeat data
|
|
826
|
+
const updateData: UpdateDeviceInput = {
|
|
827
|
+
lastHeartbeat: heartbeat.timestamp,
|
|
828
|
+
batteryLevel: heartbeat.batteryLevel,
|
|
829
|
+
storageUsed: heartbeat.storageUsed,
|
|
830
|
+
storageTotal: heartbeat.storageTotal,
|
|
831
|
+
installedApps: heartbeat.installedApps,
|
|
832
|
+
};
|
|
833
|
+
|
|
834
|
+
if (heartbeat.location) {
|
|
835
|
+
updateData.location = heartbeat.location;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const updatedDevice = await database.updateDevice(deviceId, updateData);
|
|
839
|
+
|
|
840
|
+
// Emit heartbeat event
|
|
841
|
+
await emit('device.heartbeat', { device: updatedDevice, heartbeat });
|
|
842
|
+
|
|
843
|
+
// Emit location event if location changed
|
|
844
|
+
if (heartbeat.location) {
|
|
845
|
+
await emit('device.locationUpdated', {
|
|
846
|
+
device: updatedDevice,
|
|
847
|
+
location: heartbeat.location,
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// Check for app changes
|
|
852
|
+
if (device.installedApps && heartbeat.installedApps) {
|
|
853
|
+
const oldApps = new Map(
|
|
854
|
+
device.installedApps.map((a) => [a.packageName, a])
|
|
855
|
+
);
|
|
856
|
+
const newApps = new Map(
|
|
857
|
+
heartbeat.installedApps.map((a) => [a.packageName, a])
|
|
858
|
+
);
|
|
859
|
+
|
|
860
|
+
// Check for new installs
|
|
861
|
+
for (const [pkg, app] of newApps) {
|
|
862
|
+
const oldApp = oldApps.get(pkg);
|
|
863
|
+
if (!oldApp) {
|
|
864
|
+
await emit('app.installed', { device: updatedDevice, app });
|
|
865
|
+
} else if (oldApp.version !== app.version) {
|
|
866
|
+
await emit('app.updated', {
|
|
867
|
+
device: updatedDevice,
|
|
868
|
+
app,
|
|
869
|
+
oldVersion: oldApp.version,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Check for uninstalls
|
|
875
|
+
for (const [pkg] of oldApps) {
|
|
876
|
+
if (!newApps.has(pkg)) {
|
|
877
|
+
await emit('app.uninstalled', {
|
|
878
|
+
device: updatedDevice,
|
|
879
|
+
packageName: pkg,
|
|
880
|
+
});
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// Call config hook if defined
|
|
886
|
+
if (config.onHeartbeat) {
|
|
887
|
+
await config.onHeartbeat(updatedDevice, heartbeat);
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// Call plugin hooks
|
|
891
|
+
for (const plugin of plugins) {
|
|
892
|
+
if (plugin.onHeartbeat) {
|
|
893
|
+
await plugin.onHeartbeat(updatedDevice, heartbeat);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
|
|
898
|
+
// ============================================
|
|
899
|
+
// Token Verification
|
|
900
|
+
// ============================================
|
|
901
|
+
|
|
902
|
+
const verifyDeviceToken = async (
|
|
903
|
+
token: string
|
|
904
|
+
): Promise<{ deviceId: string } | null> => {
|
|
905
|
+
try {
|
|
906
|
+
const tokenSecret =
|
|
907
|
+
config.auth?.deviceTokenSecret || enrollment?.deviceSecret || '';
|
|
908
|
+
|
|
909
|
+
const parts = token.split('.');
|
|
910
|
+
if (parts.length !== 3) {
|
|
911
|
+
return null;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
const [header, payload, signature] = parts;
|
|
915
|
+
|
|
916
|
+
// Verify signature
|
|
917
|
+
const expectedSignature = createHmac('sha256', tokenSecret)
|
|
918
|
+
.update(`${header}.${payload}`)
|
|
919
|
+
.digest('base64url');
|
|
920
|
+
|
|
921
|
+
if (signature !== expectedSignature) {
|
|
922
|
+
return null;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
// Decode payload
|
|
926
|
+
const decoded = JSON.parse(
|
|
927
|
+
Buffer.from(payload, 'base64url').toString('utf-8')
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
// Check expiration
|
|
931
|
+
if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) {
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
return { deviceId: decoded.sub };
|
|
936
|
+
} catch {
|
|
937
|
+
return null;
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
|
|
941
|
+
// ============================================
|
|
942
|
+
// Plugin Management
|
|
943
|
+
// ============================================
|
|
944
|
+
|
|
945
|
+
const getPlugins = (): MDMPlugin[] => plugins;
|
|
946
|
+
|
|
947
|
+
const getPlugin = (name: string): MDMPlugin | undefined => {
|
|
948
|
+
return plugins.find((p) => p.name === name);
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
// ============================================
|
|
952
|
+
// Create Instance
|
|
953
|
+
// ============================================
|
|
954
|
+
|
|
955
|
+
const instance: MDMInstance = {
|
|
956
|
+
devices,
|
|
957
|
+
policies,
|
|
958
|
+
apps,
|
|
959
|
+
commands,
|
|
960
|
+
groups,
|
|
961
|
+
push: pushAdapter,
|
|
962
|
+
webhooks: webhookManager,
|
|
963
|
+
db: database,
|
|
964
|
+
config,
|
|
965
|
+
on,
|
|
966
|
+
emit,
|
|
967
|
+
enroll,
|
|
968
|
+
processHeartbeat,
|
|
969
|
+
verifyDeviceToken,
|
|
970
|
+
getPlugins,
|
|
971
|
+
getPlugin,
|
|
972
|
+
};
|
|
973
|
+
|
|
974
|
+
// Initialize plugins
|
|
975
|
+
(async () => {
|
|
976
|
+
for (const plugin of plugins) {
|
|
977
|
+
if (plugin.onInit) {
|
|
978
|
+
try {
|
|
979
|
+
await plugin.onInit(instance);
|
|
980
|
+
console.log(`[OpenMDM] Plugin initialized: ${plugin.name}`);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
console.error(
|
|
983
|
+
`[OpenMDM] Failed to initialize plugin ${plugin.name}:`,
|
|
984
|
+
error
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
})();
|
|
990
|
+
|
|
991
|
+
return instance;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ============================================
|
|
995
|
+
// Push Adapter Factory
|
|
996
|
+
// ============================================
|
|
997
|
+
|
|
998
|
+
function createPushAdapter(
|
|
999
|
+
config: MDMConfig['push'],
|
|
1000
|
+
database: MDMConfig['database']
|
|
1001
|
+
): PushAdapter {
|
|
1002
|
+
if (!config) {
|
|
1003
|
+
return createStubPushAdapter();
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
// The actual implementations will be provided by separate packages
|
|
1007
|
+
// This is a base implementation that logs and stores tokens
|
|
1008
|
+
return {
|
|
1009
|
+
async send(deviceId: string, message: PushMessage): Promise<PushResult> {
|
|
1010
|
+
console.log(
|
|
1011
|
+
`[OpenMDM] Push to ${deviceId}: ${message.type}`,
|
|
1012
|
+
message.payload
|
|
1013
|
+
);
|
|
1014
|
+
|
|
1015
|
+
// In production, this would be replaced by FCM/MQTT adapter
|
|
1016
|
+
return { success: true, messageId: randomUUID() };
|
|
1017
|
+
},
|
|
1018
|
+
|
|
1019
|
+
async sendBatch(
|
|
1020
|
+
deviceIds: string[],
|
|
1021
|
+
message: PushMessage
|
|
1022
|
+
): Promise<PushBatchResult> {
|
|
1023
|
+
console.log(
|
|
1024
|
+
`[OpenMDM] Push to ${deviceIds.length} devices: ${message.type}`
|
|
1025
|
+
);
|
|
1026
|
+
|
|
1027
|
+
const results = deviceIds.map((deviceId) => ({
|
|
1028
|
+
deviceId,
|
|
1029
|
+
result: { success: true, messageId: randomUUID() },
|
|
1030
|
+
}));
|
|
1031
|
+
|
|
1032
|
+
return {
|
|
1033
|
+
successCount: deviceIds.length,
|
|
1034
|
+
failureCount: 0,
|
|
1035
|
+
results,
|
|
1036
|
+
};
|
|
1037
|
+
},
|
|
1038
|
+
|
|
1039
|
+
async registerToken(deviceId: string, token: string): Promise<void> {
|
|
1040
|
+
// Polling doesn't use push tokens
|
|
1041
|
+
if (config.provider === 'polling') {
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
await database.upsertPushToken({
|
|
1045
|
+
deviceId,
|
|
1046
|
+
provider: config.provider,
|
|
1047
|
+
token,
|
|
1048
|
+
});
|
|
1049
|
+
},
|
|
1050
|
+
|
|
1051
|
+
async unregisterToken(deviceId: string): Promise<void> {
|
|
1052
|
+
// Polling doesn't use push tokens
|
|
1053
|
+
if (config.provider === 'polling') {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
await database.deletePushToken(deviceId, config.provider);
|
|
1057
|
+
},
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function createStubPushAdapter(): PushAdapter {
|
|
1062
|
+
return {
|
|
1063
|
+
async send(deviceId: string, message: PushMessage): Promise<PushResult> {
|
|
1064
|
+
console.log(`[OpenMDM] Push (stub): ${deviceId} <- ${message.type}`);
|
|
1065
|
+
return { success: true, messageId: 'stub' };
|
|
1066
|
+
},
|
|
1067
|
+
|
|
1068
|
+
async sendBatch(
|
|
1069
|
+
deviceIds: string[],
|
|
1070
|
+
message: PushMessage
|
|
1071
|
+
): Promise<PushBatchResult> {
|
|
1072
|
+
console.log(
|
|
1073
|
+
`[OpenMDM] Push (stub): ${deviceIds.length} devices <- ${message.type}`
|
|
1074
|
+
);
|
|
1075
|
+
return {
|
|
1076
|
+
successCount: deviceIds.length,
|
|
1077
|
+
failureCount: 0,
|
|
1078
|
+
results: deviceIds.map((deviceId) => ({
|
|
1079
|
+
deviceId,
|
|
1080
|
+
result: { success: true, messageId: 'stub' },
|
|
1081
|
+
})),
|
|
1082
|
+
};
|
|
1083
|
+
},
|
|
1084
|
+
};
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// ============================================
|
|
1088
|
+
// Utility Functions
|
|
1089
|
+
// ============================================
|
|
1090
|
+
|
|
1091
|
+
function verifyEnrollmentSignature(
|
|
1092
|
+
request: EnrollmentRequest,
|
|
1093
|
+
secret: string
|
|
1094
|
+
): boolean {
|
|
1095
|
+
const { signature, ...data } = request;
|
|
1096
|
+
|
|
1097
|
+
if (!signature) {
|
|
1098
|
+
return false;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// Reconstruct the message that was signed
|
|
1102
|
+
// Format: identifier:timestamp
|
|
1103
|
+
const identifier =
|
|
1104
|
+
data.macAddress || data.serialNumber || data.imei || data.androidId || '';
|
|
1105
|
+
const message = `${identifier}:${data.timestamp}`;
|
|
1106
|
+
|
|
1107
|
+
const expectedSignature = createHmac('sha256', secret)
|
|
1108
|
+
.update(message)
|
|
1109
|
+
.digest('hex');
|
|
1110
|
+
|
|
1111
|
+
try {
|
|
1112
|
+
return timingSafeEqual(
|
|
1113
|
+
Buffer.from(signature, 'hex'),
|
|
1114
|
+
Buffer.from(expectedSignature, 'hex')
|
|
1115
|
+
);
|
|
1116
|
+
} catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function generateDeviceToken(
|
|
1122
|
+
deviceId: string,
|
|
1123
|
+
secret: string,
|
|
1124
|
+
expirationSeconds: number
|
|
1125
|
+
): string {
|
|
1126
|
+
const header = Buffer.from(
|
|
1127
|
+
JSON.stringify({ alg: 'HS256', typ: 'JWT' })
|
|
1128
|
+
).toString('base64url');
|
|
1129
|
+
|
|
1130
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1131
|
+
const payload = Buffer.from(
|
|
1132
|
+
JSON.stringify({
|
|
1133
|
+
sub: deviceId,
|
|
1134
|
+
iat: now,
|
|
1135
|
+
exp: now + expirationSeconds,
|
|
1136
|
+
iss: 'openmdm',
|
|
1137
|
+
})
|
|
1138
|
+
).toString('base64url');
|
|
1139
|
+
|
|
1140
|
+
const signature = createHmac('sha256', secret)
|
|
1141
|
+
.update(`${header}.${payload}`)
|
|
1142
|
+
.digest('base64url');
|
|
1143
|
+
|
|
1144
|
+
return `${header}.${payload}.${signature}`;
|
|
1145
|
+
}
|