import ConfigurationModel from '#models/configuration' import ConfigurationVersionModel from '#models/configuration_version' import DeviceModel from '#models/device' import { type Configuration, type ConfigurationCriteria, type DesiredParameter, type InventoryPath, PolicyType, } from '../domain/configuration/configuration.js' import type { ConfigurationVersion } from '../domain/configuration/configuration_version.js' import { isParameterCoveredByInventory, suggestInventoryPath } from '../utils/inventory_coverage.js' import { matchGlob } from '../utils/glob_match.js' /** * Allowed criteria fields — DeviceId attributes (immutable) + parameterCriteria (evaluated dynamically). */ const ALLOWED_CRITERIA_FIELDS: ReadonlySet = new Set([ 'manufacturer', 'oui', 'productClass', 'serialNumber', 'parameterCriteria', ]) const IDENTIFIER_REGEX = /^[A-Za-z0-9][A-Za-z0-9_-]*$/ const SEMVER_REGEX = /^\d+\.\d+\.\d+$/ export interface ConfigurationFilters { page?: number perPage?: number tag?: string name?: string enabled?: boolean productClass?: string } export interface CreateConfigurationDto { identifier: string name: string version: string priority: number policy: PolicyType | string enabled?: boolean tags?: string[] criteria: ConfigurationCriteria inventory: InventoryPath[] desiredParameters: DesiredParameter[] firmwareTarget?: { productClass: string; targetVersion: string } schedule?: { daysOfWeek: number[] startHour: number endHour: number timezone: string } } export type UpdateConfigurationDto = Partial> & { bumpType?: 'patch' | 'minor' | 'major' } export interface OverlapWarning { type: 'overlap_different_priority' overlappingConfigurations: Array<{ identifier: string; priority: number }> } export type OverlapResult = OverlapWarning | null export class ValidationError extends Error { public readonly field: string public readonly statusCode: number constructor(message: string, field: string = '', statusCode: number = 422) { super(message) this.name = 'ValidationError' this.field = field this.statusCode = statusCode } } export default class ConfigurationService { /** * Create a new Configuration after validation. */ async create(data: CreateConfigurationDto): Promise { this.validateIdentifier(data.identifier) this.validateVersion(data.version) this.validatePriority(data.priority) this.validatePolicy(data.policy) if (data.criteria) data.criteria = this.sanitizeCriteria(data.criteria) this.validateCriteria(data.criteria) this.validateParameterCriteriaDeadlock(data.criteria, data.desiredParameters) this.validateInventoryCoverage(data.desiredParameters, data.inventory) // Detect overlap — throws on same-priority conflict, returns warning for different-priority const overlapResult = await this.detectOverlap(data.criteria, data.priority) const configuration: Partial = { identifier: data.identifier, name: data.name, version: data.version, priority: data.priority, policy: data.policy as PolicyType, enabled: data.enabled ?? true, tags: data.tags ?? [], criteria: data.criteria, inventory: data.inventory, desiredParameters: data.desiredParameters, firmwareTarget: data.firmwareTarget, schedule: data.schedule, } const created = await ConfigurationModel.createIfNotExists(configuration) if (overlapResult) { return { ...created, warning: overlapResult } } return created } /** * Return a paginated list of Configurations with optional filters. */ async findAll(filters: ConfigurationFilters = {}) { const page = filters.page ?? 1 const perPage = filters.perPage ?? 25 const mongoFilter: Record = {} if (filters.tag) { mongoFilter.tags = filters.tag } if (filters.name) { mongoFilter.name = { $regex: filters.name, $options: 'i' } } if (filters.enabled !== undefined) { mongoFilter.enabled = filters.enabled } if (filters.productClass) { mongoFilter['criteria.productClass'] = filters.productClass } return ConfigurationModel.paginateConfigurations(page, perPage, mongoFilter) } /** * Find a Configuration by its unique identifier. */ async findById(identifier: string): Promise { return ConfigurationModel.findByIdentifier(identifier) } /** * Increment the appropriate semver segment based on bump type. * patch: 1.0.0 → 1.0.1 | minor: 1.0.0 → 1.1.0 | major: 1.0.0 → 2.0.0 */ private bumpVersion(version: string, type: 'patch' | 'minor' | 'major' = 'patch'): string { const [maj, min, pat] = version.split('.').map(Number) if (type === 'major') return `${maj + 1}.0.0` if (type === 'minor') return `${maj}.${min + 1}.0` return `${maj}.${min}.${(pat ?? 0) + 1}` } /** * Returns true if the update payload touches behavioral fields that affect * device provisioning — as opposed to cosmetic fields like name or tags. */ private isBehavioralChange(data: UpdateConfigurationDto): boolean { return ( data.criteria !== undefined || data.desiredParameters !== undefined || data.inventory !== undefined || data.policy !== undefined || data.priority !== undefined || data.firmwareTarget !== undefined || data.schedule !== undefined || data.enabled !== undefined ) } /** * Update an existing Configuration after validation. * Creates a version snapshot of the current state before applying changes. * Auto-increments the patch version on behavioral changes and resets device compliance. */ async update(identifier: string, data: UpdateConfigurationDto): Promise { const existing = await ConfigurationModel.findByIdentifier(identifier) if (!existing) { throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404) } if (data.priority !== undefined) { this.validatePriority(data.priority) } if (data.policy !== undefined) { this.validatePolicy(data.policy) } if (data.criteria !== undefined) { data.criteria = this.sanitizeCriteria(data.criteria) this.validateCriteria(data.criteria) } // Validate inventory coverage with merged data const finalDesiredParameters = data.desiredParameters ?? existing.desiredParameters const finalInventory = data.inventory ?? existing.inventory const finalCriteria2 = data.criteria ?? existing.criteria this.validateParameterCriteriaDeadlock(finalCriteria2, finalDesiredParameters) this.validateInventoryCoverage(finalDesiredParameters, finalInventory) // Detect overlap if criteria or priority changed let overlapResult: OverlapResult = null const finalCriteria = data.criteria ?? existing.criteria const finalPriority = data.priority ?? existing.priority if (data.criteria !== undefined || data.priority !== undefined) { overlapResult = await this.detectOverlap(finalCriteria, finalPriority, identifier) } const behavioralChange = this.isBehavioralChange(data) // Create a version snapshot BEFORE applying changes const latestVersionNumber = await ConfigurationVersionModel.getLatestVersionNumber(identifier) const versionSnapshot: Omit = { configurationIdentifier: identifier, versionNumber: latestVersionNumber + 1, snapshot: existing, modifiedAt: new Date(), } await ConfigurationVersionModel.create(versionSnapshot) // Build update payload (only provided fields) // Version is always auto-managed: bump on behavioral changes using the requested type const updatePayload: Record = {} if (behavioralChange) { updatePayload.version = this.bumpVersion(existing.version, data.bumpType ?? 'patch') } if (data.name !== undefined) updatePayload.name = data.name if (data.priority !== undefined) updatePayload.priority = data.priority if (data.policy !== undefined) updatePayload.policy = data.policy if (data.enabled !== undefined) updatePayload.enabled = data.enabled if (data.tags !== undefined) updatePayload.tags = data.tags if (data.criteria !== undefined) updatePayload.criteria = data.criteria if (data.inventory !== undefined) updatePayload.inventory = data.inventory if (data.desiredParameters !== undefined) updatePayload.desiredParameters = data.desiredParameters if (data.firmwareTarget !== undefined) updatePayload.firmwareTarget = data.firmwareTarget if (data.schedule !== undefined) updatePayload.schedule = data.schedule await ConfigurationModel.updateOne({ identifier }, updatePayload) // Reset compliance for all devices linked to this config so they re-evaluate if (behavioralChange) { await DeviceModel.resetComplianceForConfiguration(identifier) } const updated = await ConfigurationModel.findByIdentifier(identifier) if (overlapResult) { return { ...updated!, warning: overlapResult } } return updated! } /** * Get the version history of a Configuration with pagination. * Returns versions in reverse chronological order. */ async getHistory( identifier: string, page: number = 1, limit: number = 25 ): Promise<{ data: ConfigurationVersion[]; total: number; page: number; perPage: number; lastPage: number }> { const existing = await ConfigurationModel.findByIdentifier(identifier) if (!existing) { throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404) } return ConfigurationVersionModel.paginateVersions(identifier, page, limit) } /** * Toggle the enabled flag of a Configuration without modifying other fields. */ async toggleEnabled(identifier: string, enabled: boolean): Promise { const existing = await ConfigurationModel.findByIdentifier(identifier) if (!existing) { throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404) } await ConfigurationModel.updateOne({ identifier }, { enabled }) const updated = await ConfigurationModel.findByIdentifier(identifier) return updated! } /** * Add and/or remove tags from a Configuration without modifying other fields. */ async updateTags( identifier: string, add?: string[], remove?: string[] ): Promise { const existing = await ConfigurationModel.findByIdentifier(identifier) if (!existing) { throw new ValidationError(`Configuration '${identifier}' not found`, 'identifier', 404) } let tags = [...existing.tags] // Add tags (avoid duplicates) if (add && add.length > 0) { for (const tag of add) { if (!tags.includes(tag)) { tags.push(tag) } } } // Remove tags if (remove && remove.length > 0) { tags = tags.filter((tag) => !remove.includes(tag)) } await ConfigurationModel.updateOne({ identifier }, { tags }) const updated = await ConfigurationModel.findByIdentifier(identifier) return updated! } /** * Delete a Configuration and all its associated versions. */ async delete(identifier: string): Promise { const deleted = await ConfigurationModel.deleteByIdentifier(identifier) if (deleted) { await ConfigurationVersionModel.deleteByConfigurationIdentifier(identifier) } return deleted } // ─── Overlap Detection ───────────────────────────────────────────────────── /** * Detect overlap between a Configuration's criteria and existing enabled Configurations. * * Two configurations overlap if their criteria could match the same device. * - If both define the same field, check if patterns could match overlapping values * - If one doesn't define a field, it matches all values → potential overlap * * @param criteria - The criteria to check for overlap * @param priority - The priority of the configuration being created/updated * @param excludeId - Optional identifier to exclude (for updates) * @returns OverlapWarning if different-priority overlap found, null if no overlap * @throws ValidationError with 409 if same-priority overlap found */ async detectOverlap( criteria: ConfigurationCriteria, priority: number, excludeId?: string ): Promise { const allEnabled = await ConfigurationModel.findAllEnabled() const samePriorityConflicts: Array<{ identifier: string; priority: number }> = [] const differentPriorityOverlaps: Array<{ identifier: string; priority: number }> = [] for (const existing of allEnabled) { // Skip the configuration being updated if (excludeId && existing.identifier === excludeId) { continue } if (this.criteriaOverlap(criteria, existing.criteria)) { if (existing.priority === priority) { samePriorityConflicts.push({ identifier: existing.identifier, priority: existing.priority, }) } else { differentPriorityOverlaps.push({ identifier: existing.identifier, priority: existing.priority, }) } } } // Same-priority overlap → reject with 409 if (samePriorityConflicts.length > 0) { const identifiers = samePriorityConflicts.map((c) => c.identifier).join(', ') throw new ValidationError( `Priority conflict: configurations with the same priority ${priority} have overlapping criteria: ${identifiers}`, 'priority', 409 ) } // Different-priority overlap → return warning if (differentPriorityOverlaps.length > 0) { return { type: 'overlap_different_priority', overlappingConfigurations: differentPriorityOverlaps, } } return null } /** * Check if two sets of criteria could potentially match the same device. * * Conservative approach: * - If one criteria doesn't define a field, it matches ALL values → potential overlap * - If both define the same field, check if patterns could overlap using glob matching */ private criteriaOverlap( criteriaA: ConfigurationCriteria, criteriaB: ConfigurationCriteria ): boolean { const fields: Array = [ 'manufacturer', 'oui', 'productClass', 'serialNumber', ] for (const field of fields) { const patternA = criteriaA[field] as string | undefined const patternB = criteriaB[field] as string | undefined // If either doesn't define the field, it matches all → potential overlap on this field if (!patternA || !patternB) { continue } // Both define the field — check if patterns could match overlapping values if (!this.patternsCouldOverlap(patternA, patternB)) { return false } } // All fields are potentially overlapping return true } /** * Determine if two glob patterns could match overlapping sets of values. * * Conservative approach: if one pattern matches the other (or vice versa), * they could overlap. Also, if both are identical, they definitely overlap. * For complex cases, we assume potential overlap (conservative). */ private patternsCouldOverlap(patternA: string, patternB: string): boolean { // Identical patterns always overlap if (patternA === patternB) { return true } // Check if one pattern could match a value that the other also matches. // Simple heuristic: try matching each pattern against the other as a literal value. // If patternA matches patternB (as a value) or vice versa, they could overlap. if (matchGlob(patternA, patternB) || matchGlob(patternB, patternA)) { return true } // If either contains wildcards, be conservative and check for prefix overlap const hasWildcardA = patternA.includes('*') || patternA.includes('?') const hasWildcardB = patternB.includes('*') || patternB.includes('?') if (hasWildcardA || hasWildcardB) { // Extract the fixed prefix before the first wildcard const prefixA = patternA.split(/[*?]/)[0] const prefixB = patternB.split(/[*?]/)[0] // If one prefix starts with the other, patterns could overlap if (prefixA.startsWith(prefixB) || prefixB.startsWith(prefixA)) { return true } } // No overlap detected return false } // ─── Validation Methods ────────────────────────────────────────────────────── private validateIdentifier(identifier: string): void { if (!identifier || !IDENTIFIER_REGEX.test(identifier)) { throw new ValidationError( `Invalid identifier '${identifier}'. Must start with alphanumeric and contain only alphanumeric, hyphens, or underscores.`, 'identifier' ) } } private validateVersion(version: string): void { if (!version || !SEMVER_REGEX.test(version)) { throw new ValidationError( `Invalid version '${version}'. Must be a valid semver format (e.g. "1.0.0").`, 'version' ) } } private validatePriority(priority: number): void { if (!Number.isInteger(priority) || priority <= 0) { throw new ValidationError( `Invalid priority '${priority}'. Must be a positive integer greater than 0.`, 'priority' ) } } private validatePolicy(policy: string): void { if (policy !== PolicyType.ONCE && policy !== PolicyType.MAINTAIN) { throw new ValidationError( `Invalid policy '${policy}'. Must be "once" or "maintain".`, 'policy' ) } } private validateCriteria(criteria: ConfigurationCriteria): void { const fields = Object.keys(criteria) for (const field of fields) { if (!ALLOWED_CRITERIA_FIELDS.has(field)) { throw new ValidationError( `Invalid criteria field '${field}'. Only DeviceId fields are allowed: ${[...ALLOWED_CRITERIA_FIELDS].join(', ')}.`, 'criteria' ) } } } /** Remove parameterCriteria entries with null/empty name — avoids silent matching failures. */ private sanitizeCriteria(criteria: ConfigurationCriteria): ConfigurationCriteria { if (!criteria.parameterCriteria?.length) return criteria const filtered = criteria.parameterCriteria.filter( (pc) => pc.name != null && String(pc.name).trim() !== '' ) return { ...criteria, parameterCriteria: filtered.length > 0 ? filtered : undefined, } } /** * Warn if a parameterCriteria path is also in desiredParameters — this creates a risk of * the config never matching (deadlock) if the device doesn't already have that parameter value. * We throw a ValidationError to inform the caller of this dangerous overlap. */ private validateParameterCriteriaDeadlock( criteria: ConfigurationCriteria, desiredParameters: DesiredParameter[] ): void { const pcNames = (criteria.parameterCriteria || []).map((p) => p.name) const dpNames = new Set((desiredParameters || []).map((p) => p.name)) const conflicts = pcNames.filter((name) => dpNames.has(name)) if (conflicts.length > 0) { throw new ValidationError( `Deadlock risk: parameter(s) [${conflicts.join(', ')}] appear in both criteria.parameterCriteria and desiredParameters. ` + `A device that does not yet have these values will never match, and the config will never apply.`, 'criteria.parameterCriteria' ) } } private validateInventoryCoverage( desiredParameters: DesiredParameter[], inventory: InventoryPath[] ): void { if (!desiredParameters || desiredParameters.length === 0) { return } const uncoveredParams: Array<{ name: string; suggestedPath: string }> = [] for (const param of desiredParameters) { if (!isParameterCoveredByInventory(param.name, inventory)) { uncoveredParams.push({ name: param.name, suggestedPath: suggestInventoryPath(param.name), }) } } if (uncoveredParams.length > 0) { const details = uncoveredParams .map((p) => `"${p.name}" (suggest adding "${p.suggestedPath}" to inventory)`) .join(', ') throw new ValidationError( `Desired parameters not covered by inventory: ${details}`, 'desiredParameters' ) } } }