/** * OpenMDM Core * * A flexible, embeddable MDM (Mobile Device Management) SDK. * Inspired by better-auth's design philosophy. * * @example * ```typescript * import { createMDM } from '@openmdm/core'; * import { drizzleAdapter } from '@openmdm/drizzle-adapter'; * * const mdm = createMDM({ * database: drizzleAdapter(db), * enrollment: { * deviceSecret: process.env.DEVICE_HMAC_SECRET!, * autoEnroll: true, * }, * }); * * // Use in your routes * const devices = await mdm.devices.list(); * ``` */ import { createHmac, timingSafeEqual, randomUUID } from 'crypto'; import type { MDMConfig, MDMInstance, Device, Policy, Application, Command, Group, Heartbeat, EnrollmentRequest, EnrollmentResponse, DeviceFilter, DeviceListResult, CreateDeviceInput, UpdateDeviceInput, CreatePolicyInput, UpdatePolicyInput, CreateApplicationInput, UpdateApplicationInput, SendCommandInput, CommandFilter, CreateGroupInput, UpdateGroupInput, DeployTarget, DeviceManager, PolicyManager, ApplicationManager, CommandManager, GroupManager, PushAdapter, PushResult, PushBatchResult, PushMessage, EventType, EventHandler, EventPayloadMap, MDMEvent, MDMPlugin, CommandResult, InstalledApp, WebhookManager, TenantManager, AuthorizationManager, AuditManager, ScheduleManager, MessageQueueManager, DashboardManager, PluginStorageAdapter, GroupTreeNode, GroupHierarchyStats, Logger, EnrollmentChallenge, } from './types'; import { DeviceNotFoundError, ApplicationNotFoundError, CommandNotFoundError, EnrollmentError, } from './types'; import { createWebhookManager } from './webhooks'; import { createTenantManager } from './tenant'; import { createAuthorizationManager } from './authorization'; import { createAuditManager } from './audit'; import { createScheduleManager } from './schedule'; import { createMessageQueueManager } from './queue'; import { createDashboardManager } from './dashboard'; import { createPluginStorageAdapter, createMemoryPluginStorageAdapter } from './plugin-storage'; import { createConsoleLogger, createSilentLogger } from './logger'; import { importPublicKeyFromSpki, verifyEcdsaSignature, canonicalEnrollmentMessage, canonicalDeviceRequestMessage, verifyDeviceRequest, InvalidPublicKeyError, PublicKeyMismatchError, ChallengeInvalidError, } from './device-identity'; // Re-export all types export * from './types'; export * from './schema'; export * from './agent-protocol'; export { createWebhookManager, verifyWebhookSignature } from './webhooks'; export type { WebhookPayload } from './webhooks'; export { createConsoleLogger, createSilentLogger } from './logger'; // Device identity (Phase 2b) export { importPublicKeyFromSpki, verifyEcdsaSignature, canonicalEnrollmentMessage, canonicalDeviceRequestMessage, verifyDeviceRequest, InvalidPublicKeyError, PublicKeyMismatchError, ChallengeInvalidError, } from './device-identity'; // Re-export enterprise manager factories export { createTenantManager } from './tenant'; export { createAuthorizationManager } from './authorization'; export { createAuditManager } from './audit'; export { createScheduleManager } from './schedule'; export { createMessageQueueManager } from './queue'; export { createDashboardManager } from './dashboard'; export { createPluginStorageAdapter, createMemoryPluginStorageAdapter, createPluginKey, parsePluginKey } from './plugin-storage'; /** * Create an MDM instance with the given configuration. */ export function createMDM(config: MDMConfig): MDMInstance { const { database, push, enrollment, webhooks: webhooksConfig, plugins = [] } = config; // Structured logger. Falls back to the console-backed default if // the host doesn't pass one. Host code is expected to pass a real // pino/winston instance in production. const logger = config.logger ?? createConsoleLogger(); // Extract a stable message from an unknown thrown value so it // survives JSON serialization into the log context. Error objects // stringify to `{}` otherwise, which is the #1 cause of "we can't // tell why this failed" in production logs. const errorMessage = (err: unknown): string => { if (err instanceof Error) return err.message; if (typeof err === 'string') return err; try { return JSON.stringify(err); } catch { return String(err); } }; // Event handlers registry const eventHandlers = new Map>>(); // Create push adapter const pushAdapter: PushAdapter = push ? createPushAdapter(push, database, logger) : createStubPushAdapter(logger); // Create webhook manager if configured const webhookManager: WebhookManager | undefined = webhooksConfig ? createWebhookManager(webhooksConfig, logger) : undefined; // ============================================ // Enterprise Managers (optional) // ============================================ // Create tenant manager if multi-tenancy is enabled const tenantManager: TenantManager | undefined = config.multiTenancy?.enabled ? createTenantManager(database) : undefined; // Create authorization manager if authorization is enabled const authorizationManager: AuthorizationManager | undefined = config.authorization?.enabled ? createAuthorizationManager(database) : undefined; // Create audit manager if audit logging is enabled const auditManager: AuditManager | undefined = config.audit?.enabled ? createAuditManager(database) : undefined; // Create schedule manager if scheduling is enabled const scheduleManager: ScheduleManager | undefined = config.scheduling?.enabled ? createScheduleManager(database) : undefined; // Create message queue manager if the database supports it const messageQueueManager: MessageQueueManager | undefined = database.enqueueMessage ? createMessageQueueManager(database) : undefined; // Create dashboard manager (always available, uses database fallbacks) const dashboardManager: DashboardManager = createDashboardManager(database); // Create plugin storage adapter const pluginStorageAdapter: PluginStorageAdapter | undefined = config.pluginStorage?.adapter === 'database' ? createPluginStorageAdapter(database) : config.pluginStorage?.adapter === 'memory' ? createMemoryPluginStorageAdapter() : undefined; // Event subscription const on = ( event: T, handler: EventHandler ): (() => void) => { if (!eventHandlers.has(event)) { eventHandlers.set(event, new Set()); } const handlers = eventHandlers.get(event)!; handlers.add(handler as EventHandler); // Return unsubscribe function return () => { handlers.delete(handler as EventHandler); }; }; // Event emission const emit = async ( event: T, data: EventPayloadMap[T] ): Promise => { const handlers = eventHandlers.get(event); // Create event record const eventRecord: MDMEvent = { id: randomUUID(), deviceId: (data as any).device?.id || (data as any).deviceId || '', type: event, payload: data, createdAt: new Date(), }; // Persist event try { await database.createEvent({ deviceId: eventRecord.deviceId, type: eventRecord.type, payload: eventRecord.payload as Record, }); } catch (error) { logger.error({ err: errorMessage(error), event }, 'Failed to persist event'); } // Deliver webhooks (async, don't wait) if (webhookManager) { webhookManager.deliver(eventRecord).catch((error) => { logger.error( { err: errorMessage(error), event }, 'Webhook delivery error', ); }); } // Call handlers if (handlers) { for (const handler of handlers) { try { await handler(eventRecord); } catch (error) { logger.error( { err: errorMessage(error), event }, 'Event handler threw', ); } } } // Call config hook if defined if (config.onEvent) { try { await config.onEvent(eventRecord); } catch (error) { logger.error({ err: errorMessage(error) }, 'onEvent hook threw'); } } }; // ============================================ // Device Manager // ============================================ const devices: DeviceManager = { async get(id: string): Promise { return database.findDevice(id); }, async getByEnrollmentId(enrollmentId: string): Promise { return database.findDeviceByEnrollmentId(enrollmentId); }, async list(filter?: DeviceFilter): Promise { return database.listDevices(filter); }, async create(data: CreateDeviceInput): Promise { const device = await database.createDevice(data); await emit('device.enrolled', { device }); if (config.onDeviceEnrolled) { await config.onDeviceEnrolled(device); } return device; }, async update(id: string, data: UpdateDeviceInput): Promise { const oldDevice = await database.findDevice(id); if (!oldDevice) { throw new DeviceNotFoundError(id); } const device = await database.updateDevice(id, data); // Emit status change event if status changed if (data.status && data.status !== oldDevice.status) { await emit('device.statusChanged', { device, oldStatus: oldDevice.status, newStatus: data.status, }); } // Emit policy change event if policy changed if (data.policyId !== undefined && data.policyId !== oldDevice.policyId) { await emit('device.policyChanged', { device, oldPolicyId: oldDevice.policyId || undefined, newPolicyId: data.policyId || undefined, }); } return device; }, async delete(id: string): Promise { const device = await database.findDevice(id); if (device) { await database.deleteDevice(id); await emit('device.unenrolled', { device }); if (config.onDeviceUnenrolled) { await config.onDeviceUnenrolled(device); } } }, async assignPolicy( deviceId: string, policyId: string | null ): Promise { const device = await this.update(deviceId, { policyId }); // Notify device of policy change await pushAdapter.send(deviceId, { type: 'policy.updated', payload: { policyId }, priority: 'high', }); return device; }, async addToGroup(deviceId: string, groupId: string): Promise { await database.addDeviceToGroup(deviceId, groupId); }, async removeFromGroup(deviceId: string, groupId: string): Promise { await database.removeDeviceFromGroup(deviceId, groupId); }, async getGroups(deviceId: string): Promise { return database.getDeviceGroups(deviceId); }, async sendCommand( deviceId: string, input: Omit ): Promise { const command = await database.createCommand({ ...input, deviceId, }); // Send via push const pushResult = await pushAdapter.send(deviceId, { type: `command.${input.type}`, payload: { commandId: command.id, type: input.type, ...input.payload, }, priority: 'high', }); // Update command status if (pushResult.success) { await database.updateCommand(command.id, { status: 'sent', sentAt: new Date(), }); } if (config.onCommand) { await config.onCommand(command); } return database.findCommand(command.id) as Promise; }, async sync(deviceId: string): Promise { return this.sendCommand(deviceId, { type: 'sync' }); }, async reboot(deviceId: string): Promise { return this.sendCommand(deviceId, { type: 'reboot' }); }, async lock(deviceId: string, message?: string): Promise { return this.sendCommand(deviceId, { type: 'lock', payload: message ? { message } : undefined, }); }, async wipe(deviceId: string, preserveData?: boolean): Promise { return this.sendCommand(deviceId, { type: preserveData ? 'wipe' : 'factoryReset', payload: { preserveData }, }); }, }; // ============================================ // Policy Manager // ============================================ const policies: PolicyManager = { async get(id: string): Promise { return database.findPolicy(id); }, async getDefault(): Promise { return database.findDefaultPolicy(); }, async list(): Promise { return database.listPolicies(); }, async create(data: CreatePolicyInput): Promise { // If this is being set as default, clear other defaults first if (data.isDefault) { const existingPolicies = await database.listPolicies(); for (const policy of existingPolicies) { if (policy.isDefault) { await database.updatePolicy(policy.id, { isDefault: false }); } } } return database.createPolicy(data); }, async update(id: string, data: UpdatePolicyInput): Promise { // If setting as default, clear other defaults first if (data.isDefault) { const existingPolicies = await database.listPolicies(); for (const policy of existingPolicies) { if (policy.isDefault && policy.id !== id) { await database.updatePolicy(policy.id, { isDefault: false }); } } } const policy = await database.updatePolicy(id, data); // Notify all devices with this policy const devicesResult = await database.listDevices({ policyId: id }); if (devicesResult.devices.length > 0) { const deviceIds = devicesResult.devices.map((d) => d.id); await pushAdapter.sendBatch(deviceIds, { type: 'policy.updated', payload: { policyId: id }, priority: 'high', }); } return policy; }, async delete(id: string): Promise { // Check if any devices use this policy const devicesResult = await database.listDevices({ policyId: id }); if (devicesResult.devices.length > 0) { // Remove policy from devices first for (const device of devicesResult.devices) { await database.updateDevice(device.id, { policyId: null }); } } await database.deletePolicy(id); }, async setDefault(id: string): Promise { return this.update(id, { isDefault: true }); }, async getDevices(policyId: string): Promise { const result = await database.listDevices({ policyId }); return result.devices; }, async applyToDevice(policyId: string, deviceId: string): Promise { await devices.assignPolicy(deviceId, policyId); }, }; // ============================================ // Application Manager // ============================================ const apps: ApplicationManager = { async get(id: string): Promise { return database.findApplication(id); }, async getByPackage( packageName: string, version?: string ): Promise { return database.findApplicationByPackage(packageName, version); }, async list(activeOnly?: boolean): Promise { return database.listApplications(activeOnly); }, async register(data: CreateApplicationInput): Promise { return database.createApplication(data); }, async update(id: string, data: UpdateApplicationInput): Promise { return database.updateApplication(id, data); }, async delete(id: string): Promise { await database.deleteApplication(id); }, async activate(id: string): Promise { return database.updateApplication(id, { isActive: true }); }, async deactivate(id: string): Promise { return database.updateApplication(id, { isActive: false }); }, async deploy(packageName: string, target: DeployTarget): Promise { const app = await database.findApplicationByPackage(packageName); if (!app) { throw new ApplicationNotFoundError(packageName); } const deviceIds: string[] = []; // Collect target devices if (target.devices) { deviceIds.push(...target.devices); } if (target.groups) { for (const groupId of target.groups) { const groupDevices = await database.listDevicesInGroup(groupId); deviceIds.push(...groupDevices.map((d) => d.id)); } } if (target.policies) { for (const policyId of target.policies) { const result = await database.listDevices({ policyId }); deviceIds.push(...result.devices.map((d) => d.id)); } } // Deduplicate const uniqueDeviceIds = [...new Set(deviceIds)]; // Send install command to all devices if (uniqueDeviceIds.length > 0) { await pushAdapter.sendBatch(uniqueDeviceIds, { type: 'command.installApp', payload: { packageName: app.packageName, version: app.version, versionCode: app.versionCode, url: app.url, hash: app.hash, }, priority: 'high', }); // Create command records for each device for (const deviceId of uniqueDeviceIds) { await database.createCommand({ deviceId, type: 'installApp', payload: { packageName: app.packageName, version: app.version, url: app.url, }, }); } } }, async installOnDevice( packageName: string, deviceId: string, version?: string ): Promise { const app = await database.findApplicationByPackage(packageName, version); if (!app) { throw new ApplicationNotFoundError(packageName); } return devices.sendCommand(deviceId, { type: 'installApp', payload: { packageName: app.packageName, version: app.version, versionCode: app.versionCode, url: app.url, hash: app.hash, }, }); }, async uninstallFromDevice( packageName: string, deviceId: string ): Promise { return devices.sendCommand(deviceId, { type: 'uninstallApp', payload: { packageName }, }); }, }; // ============================================ // Command Manager // ============================================ const commands: CommandManager = { async get(id: string): Promise { return database.findCommand(id); }, async list(filter?: CommandFilter): Promise { return database.listCommands(filter); }, async send(input: SendCommandInput): Promise { return devices.sendCommand(input.deviceId, { type: input.type, payload: input.payload, }); }, async cancel(id: string): Promise { const command = await database.updateCommand(id, { status: 'cancelled' }); if (!command) { throw new CommandNotFoundError(id); } return command; }, async acknowledge(id: string): Promise { const command = await database.updateCommand(id, { status: 'acknowledged', acknowledgedAt: new Date(), }); if (!command) { throw new CommandNotFoundError(id); } const device = await database.findDevice(command.deviceId); if (device) { await emit('command.acknowledged', { device, command }); } return command; }, async complete(id: string, result: CommandResult): Promise { const command = await database.updateCommand(id, { status: 'completed', result, completedAt: new Date(), }); if (!command) { throw new CommandNotFoundError(id); } const device = await database.findDevice(command.deviceId); if (device) { await emit('command.completed', { device, command, result }); } return command; }, async fail(id: string, error: string): Promise { const command = await database.updateCommand(id, { status: 'failed', error, completedAt: new Date(), }); if (!command) { throw new CommandNotFoundError(id); } const device = await database.findDevice(command.deviceId); if (device) { await emit('command.failed', { device, command, error }); } return command; }, async getPending(deviceId: string): Promise { return database.getPendingCommands(deviceId); }, }; // ============================================ // Group Manager // ============================================ const groups: GroupManager = { async get(id: string): Promise { return database.findGroup(id); }, async list(): Promise { return database.listGroups(); }, async create(data: CreateGroupInput): Promise { return database.createGroup(data); }, async update(id: string, data: UpdateGroupInput): Promise { return database.updateGroup(id, data); }, async delete(id: string): Promise { await database.deleteGroup(id); }, async getDevices(groupId: string): Promise { return database.listDevicesInGroup(groupId); }, async addDevice(groupId: string, deviceId: string): Promise { await database.addDeviceToGroup(deviceId, groupId); }, async removeDevice(groupId: string, deviceId: string): Promise { await database.removeDeviceFromGroup(deviceId, groupId); }, async getChildren(groupId: string): Promise { const allGroups = await database.listGroups(); return allGroups.filter((g) => g.parentId === groupId); }, async getTree(rootId?: string): Promise { // Use database implementation if available if (database.getGroupTree) { return database.getGroupTree(rootId); } // Fallback: Build tree from flat list const allGroups = await database.listGroups(); const groupMap = new Map(allGroups.map((g) => [g.id, g])); const buildNode = (group: Group, depth: number, path: string[]): GroupTreeNode => { const children = allGroups .filter((g) => g.parentId === group.id) .map((child) => buildNode(child, depth + 1, [...path, group.id])); return { ...group, children, depth, path, effectivePolicyId: group.policyId, }; }; // Find root groups (those with no parent or matching rootId) const roots = allGroups.filter((g) => rootId ? g.id === rootId : !g.parentId ); return roots.map((root) => buildNode(root, 0, [])); }, async getAncestors(groupId: string): Promise { // Use database implementation if available if (database.getGroupAncestors) { return database.getGroupAncestors(groupId); } // Fallback: Traverse up the tree const ancestors: Group[] = []; const allGroups = await database.listGroups(); const groupMap = new Map(allGroups.map((g) => [g.id, g])); let current = groupMap.get(groupId); while (current?.parentId) { const parent = groupMap.get(current.parentId); if (parent) { ancestors.push(parent); current = parent; } else { break; } } return ancestors; }, async getDescendants(groupId: string): Promise { // Use database implementation if available if (database.getGroupDescendants) { return database.getGroupDescendants(groupId); } // Fallback: Find all descendants recursively const allGroups = await database.listGroups(); const descendants: Group[] = []; const findDescendants = (parentId: string) => { const children = allGroups.filter((g) => g.parentId === parentId); for (const child of children) { descendants.push(child); findDescendants(child.id); } }; findDescendants(groupId); return descendants; }, async move(groupId: string, newParentId: string | null): Promise { // Validate that we're not creating a cycle if (newParentId) { const ancestors = await this.getAncestors(newParentId); if (ancestors.some((a) => a.id === groupId)) { throw new Error('Cannot move group: would create circular reference'); } } return database.updateGroup(groupId, { parentId: newParentId }); }, async getEffectivePolicy(groupId: string): Promise { // Use database implementation if available if (database.getGroupEffectivePolicy) { return database.getGroupEffectivePolicy(groupId); } // Fallback: Walk up the tree to find first policy const group = await database.findGroup(groupId); if (!group) return null; if (group.policyId) { return database.findPolicy(group.policyId); } // Check ancestors const ancestors = await this.getAncestors(groupId); for (const ancestor of ancestors) { if (ancestor.policyId) { return database.findPolicy(ancestor.policyId); } } return null; }, async getHierarchyStats(): Promise { // Use database implementation if available if (database.getGroupHierarchyStats) { return database.getGroupHierarchyStats(); } // Fallback: Compute from flat list const allGroups = await database.listGroups(); let maxDepth = 0; let groupsWithDevices = 0; let groupsWithPolicies = 0; for (const group of allGroups) { // Calculate depth const ancestors = await this.getAncestors(group.id); maxDepth = Math.max(maxDepth, ancestors.length); // Check for devices const devices = await database.listDevicesInGroup(group.id); if (devices.length > 0) groupsWithDevices++; // Check for policies if (group.policyId) groupsWithPolicies++; } return { totalGroups: allGroups.length, maxDepth, groupsWithDevices, groupsWithPolicies, }; }, }; // ============================================ // Enrollment // ============================================ const enroll = async ( request: EnrollmentRequest ): Promise => { // Validate method if restricted if ( enrollment?.allowedMethods && !enrollment.allowedMethods.includes(request.method) ) { throw new EnrollmentError( `Enrollment method '${request.method}' is not allowed` ); } // Determine which enrollment path the request is asking for. // The presence of `publicKey` is the signal: if the device // supplies a public key, it is attempting the Phase 2b // device-pinned-key path and must also supply a valid // attestation challenge. Otherwise we fall through to the // legacy HMAC path. const isPinnedKeyPath = Boolean(request.publicKey); if (!isPinnedKeyPath && enrollment?.pinnedKey?.required) { throw new EnrollmentError( 'Pinned-key enrollment is required but the request carried no publicKey. ' + 'The agent must generate a Keystore keypair and submit the SPKI public key ' + 'alongside an ECDSA signature over the canonical enrollment message.', ); } // HMAC path (Phase 2a): unchanged behavior. if (!isPinnedKeyPath && enrollment?.deviceSecret) { const isValid = verifyEnrollmentSignature( request, enrollment.deviceSecret ); if (!isValid) { throw new EnrollmentError('Invalid enrollment signature'); } } // Pinned-key path (Phase 2b). let challengeRecord: EnrollmentChallenge | null = null; let importedPublicKey: ReturnType | null = null; if (isPinnedKeyPath) { if (!request.attestationChallenge) { throw new EnrollmentError( 'Pinned-key enrollment requires attestationChallenge. ' + 'Fetch a fresh challenge from /agent/enroll/challenge first.', ); } if (!database.consumeEnrollmentChallenge) { throw new EnrollmentError( 'Pinned-key enrollment requires an adapter that implements enrollment ' + 'challenge storage. Upgrade to a database adapter that supports it, or ' + 'submit an HMAC-signed enrollment instead.', ); } // Parse the public key first — if it's malformed the signature // cannot possibly verify and we want a specific error. try { importedPublicKey = importPublicKeyFromSpki(request.publicKey as string); } catch (err) { throw new EnrollmentError( err instanceof Error ? `Invalid enrollment public key: ${err.message}` : 'Invalid enrollment public key', ); } // Atomically consume the challenge. This must happen BEFORE // signature verification, otherwise two concurrent requests // with the same challenge could both succeed. challengeRecord = await database.consumeEnrollmentChallenge( request.attestationChallenge, ); if (!challengeRecord) { throw new ChallengeInvalidError( 'Enrollment challenge is missing, expired, or already consumed', request.attestationChallenge, ); } if (challengeRecord.expiresAt.getTime() < Date.now()) { throw new ChallengeInvalidError( 'Enrollment challenge has expired', request.attestationChallenge, ); } const canonical = canonicalEnrollmentMessage({ publicKey: request.publicKey as string, model: request.model, manufacturer: request.manufacturer, osVersion: request.osVersion, serialNumber: request.serialNumber, imei: request.imei, macAddress: request.macAddress, androidId: request.androidId, method: request.method, timestamp: request.timestamp, challenge: request.attestationChallenge, }); const verified = verifyEcdsaSignature( importedPublicKey, canonical, request.signature, ); if (!verified) { throw new EnrollmentError( 'Invalid enrollment signature (device-pinned-key path)', ); } } // Custom validation if (enrollment?.validate) { const isValid = await enrollment.validate(request); if (!isValid) { throw new EnrollmentError('Enrollment validation failed'); } } // Determine enrollment ID const enrollmentId = request.macAddress || request.serialNumber || request.imei || request.androidId; if (!enrollmentId) { throw new EnrollmentError( 'Device must provide at least one identifier (macAddress, serialNumber, imei, or androidId)' ); } // Check if device already exists let device = await database.findDeviceByEnrollmentId(enrollmentId); if (device) { // Device re-enrolling. If the device is already on the // pinned-key path, the submitted public key MUST match the // pinned one — otherwise we reject loudly. This is how we // prevent an attacker who extracted the enrollment secret // from hijacking an enrolled device's identity: without the // original private key they cannot produce a valid signature, // and even if they could (via a forged HMAC fallback), the // pinned key still identifies the legitimate device. if (isPinnedKeyPath && device.publicKey) { if (device.publicKey !== request.publicKey) { throw new PublicKeyMismatchError(device.id); } } const updateInput: UpdateDeviceInput = { status: 'enrolled', model: request.model, manufacturer: request.manufacturer, osVersion: request.osVersion, lastSync: new Date(), }; // Pin the key on first pinned-key enrollment for a device // that originally enrolled on HMAC. This is the migration // path: a device that used to sign with the shared secret // can upgrade by sending its freshly-generated public key on // its next enrollment, and the server will pin it from then // on. if (isPinnedKeyPath && !device.publicKey) { updateInput.publicKey = request.publicKey; updateInput.enrollmentMethod = 'pinned-key'; } device = await database.updateDevice(device.id, updateInput); } else if (enrollment?.autoEnroll) { // Auto-create device device = await database.createDevice({ enrollmentId, model: request.model, manufacturer: request.manufacturer, osVersion: request.osVersion, serialNumber: request.serialNumber, imei: request.imei, macAddress: request.macAddress, androidId: request.androidId, policyId: request.policyId || enrollment.defaultPolicyId, }); // Pin the public key on first enrollment for pinned-key path. // `CreateDeviceInput` deliberately doesn't carry auth fields — // we keep auth state a post-creation concern so legacy // adapters don't have to know about it. if (isPinnedKeyPath) { device = await database.updateDevice(device.id, { publicKey: request.publicKey, enrollmentMethod: 'pinned-key', }); } // Add to default group if configured if (enrollment.defaultGroupId) { await database.addDeviceToGroup(device.id, enrollment.defaultGroupId); } } else if (enrollment?.requireApproval) { // Create pending device device = await database.createDevice({ enrollmentId, model: request.model, manufacturer: request.manufacturer, osVersion: request.osVersion, serialNumber: request.serialNumber, imei: request.imei, macAddress: request.macAddress, androidId: request.androidId, }); // Pin the public key even for pending devices — we want to // know which key originally enrolled once an admin approves. if (isPinnedKeyPath) { device = await database.updateDevice(device.id, { publicKey: request.publicKey, enrollmentMethod: 'pinned-key', }); } // Status remains 'pending' } else { throw new EnrollmentError( 'Device not registered and auto-enroll is disabled' ); } // Get policy let policy: Policy | null = null; if (device.policyId) { policy = await database.findPolicy(device.policyId); } if (!policy) { policy = await database.findDefaultPolicy(); } // Generate JWT token for device auth const tokenSecret = config.auth?.deviceTokenSecret || enrollment?.deviceSecret || ''; const tokenExpiration = config.auth?.deviceTokenExpiration || 365 * 24 * 60 * 60; const token = generateDeviceToken(device.id, tokenSecret, tokenExpiration); // Emit enrollment event await emit('device.enrolled', { device }); // Call config hook if defined if (config.onDeviceEnrolled) { await config.onDeviceEnrolled(device); } // Call plugin hooks for (const plugin of plugins) { if (plugin.onEnroll) { await plugin.onEnroll(device, request); } if (plugin.onDeviceEnrolled) { await plugin.onDeviceEnrolled(device); } } return { deviceId: device.id, enrollmentId: device.enrollmentId, policyId: policy?.id, policy: policy || undefined, serverUrl: config.serverUrl || '', pushConfig: { provider: push?.provider || 'polling', fcmSenderId: (push?.fcmCredentials as any)?.project_id, mqttUrl: push?.mqttUrl, mqttTopic: push?.mqttTopicPrefix ? `${push.mqttTopicPrefix}/${device.id}` : `openmdm/devices/${device.id}`, pollingInterval: push?.pollingInterval || 60, }, token, tokenExpiresAt: new Date(Date.now() + tokenExpiration * 1000), }; }; // ============================================ // Heartbeat Processing // ============================================ const processHeartbeat = async ( deviceId: string, heartbeat: Heartbeat ): Promise => { const device = await database.findDevice(deviceId); if (!device) { throw new DeviceNotFoundError(deviceId); } // Update device with heartbeat data const updateData: UpdateDeviceInput = { lastHeartbeat: heartbeat.timestamp, batteryLevel: heartbeat.batteryLevel, storageUsed: heartbeat.storageUsed, storageTotal: heartbeat.storageTotal, installedApps: heartbeat.installedApps, }; if (heartbeat.location) { updateData.location = heartbeat.location; } const updatedDevice = await database.updateDevice(deviceId, updateData); // Emit heartbeat event await emit('device.heartbeat', { device: updatedDevice, heartbeat }); // Emit location event if location changed if (heartbeat.location) { await emit('device.locationUpdated', { device: updatedDevice, location: heartbeat.location, }); } // Check for app changes if (device.installedApps && heartbeat.installedApps) { const oldApps = new Map( device.installedApps.map((a) => [a.packageName, a]) ); const newApps = new Map( heartbeat.installedApps.map((a) => [a.packageName, a]) ); // Check for new installs for (const [pkg, app] of newApps) { const oldApp = oldApps.get(pkg); if (!oldApp) { await emit('app.installed', { device: updatedDevice, app }); } else if (oldApp.version !== app.version) { await emit('app.updated', { device: updatedDevice, app, oldVersion: oldApp.version, }); } } // Check for uninstalls for (const [pkg] of oldApps) { if (!newApps.has(pkg)) { await emit('app.uninstalled', { device: updatedDevice, packageName: pkg, }); } } } // Call config hook if defined if (config.onHeartbeat) { await config.onHeartbeat(updatedDevice, heartbeat); } // Call plugin hooks for (const plugin of plugins) { if (plugin.onHeartbeat) { await plugin.onHeartbeat(updatedDevice, heartbeat); } } }; // ============================================ // Token Verification // ============================================ const verifyDeviceToken = async ( token: string ): Promise<{ deviceId: string } | null> => { try { const tokenSecret = config.auth?.deviceTokenSecret || enrollment?.deviceSecret || ''; const parts = token.split('.'); if (parts.length !== 3) { return null; } const [header, payload, signature] = parts; // Verify signature const expectedSignature = createHmac('sha256', tokenSecret) .update(`${header}.${payload}`) .digest('base64url'); if (signature !== expectedSignature) { return null; } // Decode payload const decoded = JSON.parse( Buffer.from(payload, 'base64url').toString('utf-8') ); // Check expiration if (decoded.exp && decoded.exp < Math.floor(Date.now() / 1000)) { return null; } return { deviceId: decoded.sub }; } catch { return null; } }; // ============================================ // Plugin Management // ============================================ const getPlugins = (): MDMPlugin[] => plugins; const getPlugin = (name: string): MDMPlugin | undefined => { return plugins.find((p) => p.name === name); }; // ============================================ // Create Instance // ============================================ const instance: MDMInstance = { devices, policies, apps, commands, groups, push: pushAdapter, webhooks: webhookManager, db: database, logger, config, on, emit, enroll, processHeartbeat, verifyDeviceToken, getPlugins, getPlugin, // Enterprise managers (optional) tenants: tenantManager, authorization: authorizationManager, audit: auditManager, schedules: scheduleManager, messageQueue: messageQueueManager, dashboard: dashboardManager, pluginStorage: pluginStorageAdapter, }; // Initialize plugins (async () => { for (const plugin of plugins) { if (plugin.onInit) { try { await plugin.onInit(instance); logger.info({ plugin: plugin.name }, 'Plugin initialized'); } catch (error) { logger.error( { plugin: plugin.name, err: errorMessage(error) }, 'Failed to initialize plugin', ); } } } })(); return instance; } // ============================================ // Push Adapter Factory // ============================================ function createPushAdapter( config: MDMConfig['push'], database: MDMConfig['database'], logger: Logger, ): PushAdapter { if (!config) { return createStubPushAdapter(logger); } const pushLogger = logger.child({ component: 'push' }); // The actual implementations will be provided by separate packages // This is a base implementation that logs and stores tokens return { async send(deviceId: string, message: PushMessage): Promise { pushLogger.debug( { deviceId, type: message.type, payload: message.payload }, 'send', ); // In production, this would be replaced by FCM/MQTT adapter return { success: true, messageId: randomUUID() }; }, async sendBatch( deviceIds: string[], message: PushMessage ): Promise { pushLogger.debug( { count: deviceIds.length, type: message.type }, 'sendBatch', ); const results = deviceIds.map((deviceId) => ({ deviceId, result: { success: true, messageId: randomUUID() }, })); return { successCount: deviceIds.length, failureCount: 0, results, }; }, async registerToken(deviceId: string, token: string): Promise { // Polling doesn't use push tokens if (config.provider === 'polling') { return; } await database.upsertPushToken({ deviceId, provider: config.provider, token, }); }, async unregisterToken(deviceId: string): Promise { // Polling doesn't use push tokens if (config.provider === 'polling') { return; } await database.deletePushToken(deviceId, config.provider); }, }; } function createStubPushAdapter(logger: Logger): PushAdapter { const stubLogger = logger.child({ component: 'push-stub' }); return { async send(deviceId: string, message: PushMessage): Promise { stubLogger.debug({ deviceId, type: message.type }, 'send (stub)'); return { success: true, messageId: 'stub' }; }, async sendBatch( deviceIds: string[], message: PushMessage ): Promise { stubLogger.debug( { count: deviceIds.length, type: message.type }, 'sendBatch (stub)', ); return { successCount: deviceIds.length, failureCount: 0, results: deviceIds.map((deviceId) => ({ deviceId, result: { success: true, messageId: 'stub' }, })), }; }, }; } // ============================================ // Utility Functions // ============================================ export function verifyEnrollmentSignature( request: EnrollmentRequest, secret: string ): boolean { const { signature, ...data } = request; if (!signature) { return false; } // Reconstruct the message that was signed. This must stay in lockstep with // @openmdm/client's generateEnrollmentSignature — any change here is a wire // break and must land in both places. A contract test in core/tests guards // the format and will fail on divergence. const message = [ data.model, data.manufacturer, data.osVersion, data.serialNumber || '', data.imei || '', data.macAddress || '', data.androidId || '', data.method, data.timestamp, ].join('|'); const expectedSignature = createHmac('sha256', secret) .update(message) .digest('hex'); try { return timingSafeEqual( Buffer.from(signature, 'hex'), Buffer.from(expectedSignature, 'hex') ); } catch { return false; } } function generateDeviceToken( deviceId: string, secret: string, expirationSeconds: number ): string { const header = Buffer.from( JSON.stringify({ alg: 'HS256', typ: 'JWT' }) ).toString('base64url'); const now = Math.floor(Date.now() / 1000); const payload = Buffer.from( JSON.stringify({ sub: deviceId, iat: now, exp: now + expirationSeconds, iss: 'openmdm', }) ).toString('base64url'); const signature = createHmac('sha256', secret) .update(`${header}.${payload}`) .digest('base64url'); return `${header}.${payload}.${signature}`; }