@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/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
+ }