// Pure mapping from a POSHManager Intune deployment plan (+ optional PSADT // profile) to the Microsoft Graph `win32LobApp` resource shape and its // assignment/relationship bodies. // // These functions perform no I/O so they can be unit-tested without a tenant. // The live HTTP calls live in graphService; the publish controller composes the // two. Field shapes follow the Graph beta `win32LobApp` documentation. const RESTART_BEHAVIOR = { 'return-code': 'basedOnReturnCode', suppress: 'suppress', intune: 'force' }; const RETURN_CODE_TYPES = new Set(['success', 'softReboot', 'hardReboot', 'retry', 'failed']); // Map a human OS string ("Windows 10 22H2", "Windows 11 23H2") to the Graph // windowsMinimumOperatingSystem boolean flag. Defaults to a safe broad floor. function minimumSupportedOperatingSystem(minOs = '') { const text = String(minOs).toLowerCase(); const flags = { v10_1607: false, v10_1703: false, v10_1709: false, v10_1803: false, v10_1809: false, v10_1903: false, v10_1909: false, v10_2004: false, v10_20H2: false, v10_21H1: false, v10_21H2: false, v10_22H2: false, v11_21H2: false, v11_22H2: false, v11_23H2: false, v11_24H2: false }; const map = [ ['11 24h2', 'v11_24H2'], ['11 23h2', 'v11_23H2'], ['11 22h2', 'v11_22H2'], ['11 21h2', 'v11_21H2'], ['22h2', 'v10_22H2'], ['21h2', 'v10_21H2'], ['21h1', 'v10_21H1'], ['20h2', 'v10_20H2'], ['2004', 'v10_2004'], ['1909', 'v10_1909'], ['1903', 'v10_1903'], ['1809', 'v10_1809'], ['1803', 'v10_1803'], ['1709', 'v10_1709'], ['1703', 'v10_1703'], ['1607', 'v10_1607'] ]; const match = map.find(([needle]) => text.includes(needle)); flags[match ? match[1] : 'v10_1809'] = true; return flags; } function applicableArchitectures(architecture = 'x64') { if (architecture === 'both') return 'x64,x86'; if (architecture === 'x86') return 'x86'; return 'x64'; } function mapReturnCodes(returnCodes = []) { const mapped = (returnCodes || []).map((entry) => ({ returnCode: Number(entry.code), type: RETURN_CODE_TYPES.has(entry.type) ? entry.type : 'failed' })).filter((entry) => Number.isFinite(entry.returnCode)); // Intune requires at least the canonical 0/1707/3010/1641 floor; ensure 0=success exists. if (!mapped.some((entry) => entry.returnCode === 0)) mapped.unshift({ returnCode: 0, type: 'success' }); return mapped; } // Detection rule mapping. Our model stores a single `detectionRule` string plus // a `detectionType`, so structured types (file/registry) that need more fields // are emitted best-effort and a warning is returned for operator review. function mapDetectionRules(deployment, warnings) { const rule = String(deployment.detectionRule || '').trim(); switch (deployment.detectionType) { case 'msi-product-code': if (!rule) warnings.push('MSI detection selected but no product code provided.'); return [{ '@odata.type': '#microsoft.graph.win32LobAppProductCodeDetection', productCode: rule, productVersionOperator: 'notConfigured', productVersion: null }]; case 'custom-script': if (!rule) warnings.push('Custom-script detection selected but the script body is empty.'); return [{ '@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection', scriptContent: Buffer.from(rule, 'utf8').toString('base64'), enforceSignatureCheck: false, runAs32Bit: false }]; case 'file-version': warnings.push('File-version detection requires structured path/file/version fields; emitted a best-effort rule for review.'); return [{ '@odata.type': '#microsoft.graph.win32LobAppFileSystemDetection', path: rule || 'C:\\Program Files', fileOrFolderName: '', check32BitOn64System: false, detectionType: 'exists', operator: 'notConfigured', detectionValue: null }]; case 'registry': warnings.push('Registry detection requires structured key/value fields; emitted a best-effort rule for review.'); return [{ '@odata.type': '#microsoft.graph.win32LobAppRegistryDetection', keyPath: rule || 'HKEY_LOCAL_MACHINE\\SOFTWARE', valueName: '', check32BitOn64System: false, detectionType: 'exists', operator: 'notConfigured', detectionValue: null }]; default: warnings.push(`Unknown detection type "${deployment.detectionType}"; defaulted to script detection.`); return [{ '@odata.type': '#microsoft.graph.win32LobAppPowerShellScriptDetection', scriptContent: Buffer.from(rule, 'utf8').toString('base64'), enforceSignatureCheck: false, runAs32Bit: false }]; } } export function buildWin32LobAppPayload(deployment, profile = null) { const warnings = []; if (!deployment) throw new Error('A deployment is required to build a win32LobApp payload'); const requirements = deployment.requirements || {}; const displayName = profile?.appName ? `${profile.appVendor ? `${profile.appVendor} ` : ''}${profile.appName}${profile.appVersion ? ` ${profile.appVersion}` : ''}`.trim() : deployment.name; const fileName = deployment.intunewinFile || `${(deployment.name || 'package').replace(/[^A-Za-z0-9._-]+/g, '-')}.intunewin`; if (!deployment.installCommand) warnings.push('Install command is empty.'); if (!deployment.uninstallCommand) warnings.push('Uninstall command is empty.'); if (!deployment.intunewinFile) warnings.push('No .intunewin file recorded; content must be uploaded before the app is installable (Phase 2).'); const payload = { '@odata.type': '#microsoft.graph.win32LobApp', displayName, description: deployment.notes || displayName, publisher: profile?.appVendor || 'POSHManager', fileName, setupFilePath: fileName, installCommandLine: deployment.installCommand || '', uninstallCommandLine: deployment.uninstallCommand || '', applicableArchitectures: applicableArchitectures(requirements.architecture), allowAvailableUninstall: true, minimumFreeDiskSpaceInMB: Number(requirements.diskSpaceMb) > 0 ? Number(requirements.diskSpaceMb) : null, minimumSupportedOperatingSystem: minimumSupportedOperatingSystem(requirements.minOs), installExperience: { '@odata.type': 'microsoft.graph.win32LobAppInstallExperience', runAsAccount: deployment.installBehavior === 'user' ? 'user' : 'system', deviceRestartBehavior: RESTART_BEHAVIOR[deployment.restartBehavior] || 'basedOnReturnCode' }, returnCodes: mapReturnCodes(deployment.returnCodes), detectionRules: mapDetectionRules(deployment, warnings), requirementRules: [], msiInformation: null, runAs32Bit: Boolean(requirements.runAs32Bit) }; return { payload, warnings }; } // --------------------------------------------------------------------------- // Phase 3 — assignments / filters / rings // --------------------------------------------------------------------------- const ASSIGNMENT_INTENTS = new Set(['available', 'required', 'uninstall']); function assignmentTarget(assignment, warnings) { const filter = assignment.filterId ? { deviceAndAppManagementAssignmentFilterId: assignment.filterId, deviceAndAppManagementAssignmentFilterType: assignment.filterType && assignment.filterType !== 'none' ? assignment.filterType : 'include' } : { deviceAndAppManagementAssignmentFilterId: null, deviceAndAppManagementAssignmentFilterType: 'none' }; if (assignment.groupMode === 'allDevices') { return { '@odata.type': '#microsoft.graph.allDevicesAssignmentTarget', ...filter }; } if (assignment.groupMode === 'allUsers') { return { '@odata.type': '#microsoft.graph.allLicensedUsersAssignmentTarget', ...filter }; } if (!assignment.groupId) { warnings.push(`Assignment "${assignment.ring}" has no Entra group id and was skipped. Resolve the group to its object id first.`); return null; } const isExclusion = assignment.intent === 'exclude'; return { '@odata.type': isExclusion ? '#microsoft.graph.exclusionGroupAssignmentTarget' : '#microsoft.graph.groupAssignmentTarget', groupId: assignment.groupId, ...filter }; } // Build the `mobileAppAssignments` array for the /assign action. Exclusions map // to exclusionGroupAssignmentTarget (Graph still requires a real intent, so we // exclude from "required"). Group assignments without a resolved object id are // skipped with a warning rather than producing an invalid request. export function buildAssignmentsPayload(deployment) { const warnings = []; const assignments = []; for (const assignment of deployment.assignments || []) { const target = assignmentTarget(assignment, warnings); if (!target) continue; const intent = assignment.intent === 'exclude' ? 'required' : (ASSIGNMENT_INTENTS.has(assignment.intent) ? assignment.intent : 'required'); assignments.push({ '@odata.type': '#microsoft.graph.mobileAppAssignment', intent, target, settings: { '@odata.type': '#microsoft.graph.win32LobAppAssignmentSettings', notifications: 'showAll', restartSettings: null, installTimeSettings: null, deliveryOptimizationPriority: 'notConfigured', autoUpdateSettings: assignment.autoUpdate ? { '@odata.type': '#microsoft.graph.win32LobAppAutoUpdateSettings', autoUpdateSupersededApps: 'enabled' } : null } }); } if (!assignments.length) warnings.push('No assignable targets were produced; nothing would be assigned.'); return { assignments, warnings }; } // --------------------------------------------------------------------------- // Phase 4 — supersedence & dependency relationships // --------------------------------------------------------------------------- // Intune limits a supersedence graph to 11 nodes total (this app + superseded + // their related apps). We can only see this app's direct edges, so we enforce a // local ceiling of 10 supersedence targets and flag that the full graph limit is // tenant-wide. Self-references and duplicates are rejected as cycles. export const MAX_SUPERSEDENCE_TARGETS = 10; export function validateRelationships(relationships = [], selfAppId = '') { const errors = []; const seen = new Set(); let supersedenceCount = 0; for (const rel of relationships) { const target = rel.targetAppId; if (!target) { errors.push('A relationship is missing targetAppId.'); continue; } if (selfAppId && target === selfAppId) errors.push(`An app cannot have a ${rel.kind} relationship with itself (${target}).`); const key = `${rel.kind}:${target}`; if (seen.has(key)) errors.push(`Duplicate ${rel.kind} relationship to ${target}.`); seen.add(key); if (rel.kind === 'supersedence') supersedenceCount += 1; } if (supersedenceCount > MAX_SUPERSEDENCE_TARGETS) { errors.push(`Too many supersedence targets (${supersedenceCount}); Intune allows at most ${MAX_SUPERSEDENCE_TARGETS} direct targets (11-node graph limit).`); } return errors; } export function buildRelationshipsPayload(relationships = [], selfAppId = '') { const errors = validateRelationships(relationships, selfAppId); if (errors.length) { const error = new Error(errors.join(' ')); error.validation = errors; throw error; } const mapped = relationships.map((rel) => { if (rel.kind === 'dependency') { return { '@odata.type': '#microsoft.graph.mobileAppDependency', targetId: rel.targetAppId, dependencyType: rel.mode === 'autoInstall' ? 'autoInstall' : 'detect' }; } return { '@odata.type': '#microsoft.graph.mobileAppSupersedence', targetId: rel.targetAppId, supersedenceType: rel.mode === 'replace' ? 'replace' : 'update' }; }); return { relationships: mapped }; }