import { SCHEMA_VERSION, type OrgChartDataset, type ValidationIssue, type ValidationResult } from './types.js'; import { snapshotOrgChartDataset } from './snapshot.js'; const organizationTypes = ['root', 'division', 'office', 'div', 'cell', 'team']; const statuses = ['active', 'dispatched', 'onLeave']; const relationTypes = ['cooperation', 'support']; const unsafeText = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]|<\/?[a-z][^>]*>|(?:javascript|vbscript)\s*:/i; type RecordValue = Record; function record(input: unknown): input is RecordValue { return !!input && typeof input === 'object' && !Array.isArray(input) && (Object.getPrototypeOf(input) === Object.prototype || Object.getPrototypeOf(input) === null); } export function isSafeImageUrl(input: string): boolean { if (!input.trim() || unsafeText.test(input) || /\s|\\/.test(input)) return false; if (/^[a-z][a-z\d+.-]*:/i.test(input)) { try { const url = new URL(input); return ['https:', 'http:'].includes(url.protocol) && !url.username && !url.password; } catch { return false; } } // Protocol-relative URLs inherit network origins; require an explicit HTTP(S) URL instead. return !input.startsWith('//') && !/%(?:0[0-9a-f]|1[0-9a-f]|7f|5c)/i.test(input); } function validDate(value: string): boolean { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; const parsed = new Date(value); return Number.isFinite(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value; } function validTimestamp(value: string): boolean { const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(value); return !!match && validDate(match[1]!) && Number(match[2]) < 24 && Number(match[3]) < 60 && Number(match[4]) < 60 && (!match[6] || Number(match[6]) < 24) && (!match[7] || Number(match[7]) < 60) && Number.isFinite(Date.parse(value)); } /** Strict contract validation. Messages contain no input values or server details. */ export function validateOrgChartDataset(input: unknown): ValidationResult { const issues: ValidationIssue[] = []; const issue = (code: string, path: string, message: string) => issues.push({ code, path, message }); const object = (value: unknown, path: string, keys: string[]): value is RecordValue => { if (!record(value)) { issue('REQUIRED_VALUE', path, 'Expected a plain JSON object.'); return false; } for (const key of Object.keys(value)) if (!keys.includes(key)) issue('UNKNOWN_FIELD', `${path}.${key}`, 'Field is not part of this contract.'); return true; }; const text = (value: unknown, path: string, nullable = false): boolean => { if (nullable && value === null) return true; if (typeof value !== 'string' || !value.trim()) { issue('REQUIRED_VALUE', path, 'Expected a non-empty text value.'); return false; } if (unsafeText.test(value)) { issue('UNSAFE_INPUT', path, 'Text contains unsupported executable markup or control characters.'); return false; } return true; }; const choice = (value: unknown, path: string, allowed: string[], code: string) => { if (typeof value !== 'string' || !allowed.includes(value)) issue(code, path, 'Value is outside the supported contract vocabulary.'); }; const collection = (value: unknown, path: string, keys: string[], inspect: (item: RecordValue, path: string) => void, optional = false) => { if (optional && value === undefined) return; if (!Array.isArray(value)) { issue('REQUIRED_VALUE', path, 'Expected an array.'); return; } const ids = new Set(); value.forEach((item, index) => { const location = `${path}[${index}]`; if (!object(item, location, keys)) return; if (text(item.id, `${location}.id`)) { if (ids.has(item.id as string)) issue('DUPLICATE_ID', `${location}.id`, 'ID must be unique within its collection.'); ids.add(item.id as string); } inspect(item, location); }); }; if (!object(input, '$', ['schemaVersion', 'organizations', 'employees', 'affiliations', 'relations', 'presentation', 'metadata'])) return { valid: false, issues }; if (input.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.schemaVersion', 'Unsupported or missing schema version.'); collection(input.organizations, '$.organizations', ['id', 'companyId', 'parentId', 'name', 'type', 'leaderId', 'displayOrder'], (org, path) => { if (org.companyId !== undefined) text(org.companyId, `${path}.companyId`); text(org.parentId, `${path}.parentId`, true); text(org.name, `${path}.name`); text(org.leaderId, `${path}.leaderId`, true); choice(org.type, `${path}.type`, organizationTypes, 'INVALID_TYPE'); if (!Number.isSafeInteger(org.displayOrder) || (org.displayOrder as number) < 0) issue('REQUIRED_VALUE', `${path}.displayOrder`, 'Expected a non-negative safe integer.'); }); if (Array.isArray(input.organizations) && input.organizations.length === 0) issue('EMPTY_ORGANIZATIONS', '$.organizations', 'At least one organization is required.'); collection(input.employees, '$.employees', ['id', 'organizationId', 'affiliationId', 'name', 'position', 'jobTitle', 'profileImage', 'workStatus'], (employee, path) => { for (const key of ['organizationId', 'affiliationId', 'name', 'position']) text(employee[key], `${path}.${key}`); text(employee.jobTitle, `${path}.jobTitle`, true); if (text(employee.profileImage, `${path}.profileImage`, true) && typeof employee.profileImage === 'string' && !isSafeImageUrl(employee.profileImage)) issue('UNSAFE_INPUT', `${path}.profileImage`, 'Image URL must use HTTP(S) or a safe relative path.'); choice(employee.workStatus, `${path}.workStatus`, statuses, 'INVALID_STATUS'); }); collection(input.affiliations, '$.affiliations', ['id', 'name'], (affiliation, path) => { text(affiliation.name, `${path}.name`); }); collection(input.relations, '$.relations', ['id', 'sourceOrganizationId', 'targetOrganizationId', 'type'], (relation, path) => { text(relation.sourceOrganizationId, `${path}.sourceOrganizationId`); text(relation.targetOrganizationId, `${path}.targetOrganizationId`); choice(relation.type, `${path}.type`, relationTypes, 'INVALID_RELATION'); }, true); if (object(input.metadata, '$.metadata', ['schemaVersion', 'asOfDate', 'dataVersion', 'updatedAt', 'updateNote'])) { const meta = input.metadata; if (meta.schemaVersion !== undefined && meta.schemaVersion !== SCHEMA_VERSION) issue('SCHEMA_VERSION', '$.metadata.schemaVersion', 'Compatibility schema version must match the top-level version.'); if (text(meta.asOfDate, '$.metadata.asOfDate') && !validDate(meta.asOfDate as string)) issue('REQUIRED_VALUE', '$.metadata.asOfDate', 'Expected a real calendar date in YYYY-MM-DD format.'); text(meta.dataVersion, '$.metadata.dataVersion'); if (meta.updatedAt !== undefined && text(meta.updatedAt, '$.metadata.updatedAt', true) && typeof meta.updatedAt === 'string' && !validTimestamp(meta.updatedAt)) issue('REQUIRED_VALUE', '$.metadata.updatedAt', 'Expected an RFC 3339 timestamp with timezone.'); if (meta.updateNote !== undefined) text(meta.updateNote, '$.metadata.updateNote', true); } if (input.presentation !== undefined && object(input.presentation, '$.presentation', ['title', 'description', 'hubText', 'groups', 'organizationOrder'])) { const presentation = input.presentation; text(presentation.title, '$.presentation.title'); text(presentation.description, '$.presentation.description', true); text(presentation.hubText, '$.presentation.hubText', true); const flags = ['showEmployees', 'showSummary', 'showDispatched', 'showOnLeave', 'showAffiliations']; if (object(presentation.groups, '$.presentation.groups', flags)) for (const flag of flags) if (typeof presentation.groups[flag] !== 'boolean') issue('REQUIRED_VALUE', `$.presentation.groups.${flag}`, 'Expected a boolean.'); if (!Array.isArray(presentation.organizationOrder)) issue('REQUIRED_VALUE', '$.presentation.organizationOrder', 'Expected an array of organization IDs.'); else presentation.organizationOrder.forEach((id, index) => text(id, `$.presentation.organizationOrder[${index}]`)); } if (issues.length) return { valid: false, issues }; const data = input as unknown as OrgChartDataset; const organizations = new Map(data.organizations.map(org => [org.id, org])); const employees = new Map(data.employees.map(employee => [employee.id, employee])); const affiliations = new Set(data.affiliations.map(affiliation => affiliation.id)); data.organizations.forEach((org, index) => { if (org.parentId !== null && !organizations.has(org.parentId)) issue('PARENT_REFERENCE', `$.organizations[${index}].parentId`, 'Parent organization does not exist.'); if (org.leaderId !== null) { const leader = employees.get(org.leaderId); if (!leader) issue('LEADER_REFERENCE', `$.organizations[${index}].leaderId`, 'Leader employee does not exist.'); else if (leader.organizationId !== org.id) issue('LEADER_APPROVAL_REQUIRED', `$.organizations[${index}].leaderId`, 'Leader membership outside this organization requires an approved business policy.'); } }); data.employees.forEach((employee, index) => { if (!organizations.has(employee.organizationId)) issue('ORGANIZATION_REFERENCE', `$.employees[${index}].organizationId`, 'Employee organization does not exist.'); if (!affiliations.has(employee.affiliationId)) issue('AFFILIATION_REFERENCE', `$.employees[${index}].affiliationId`, 'Employee affiliation does not exist.'); }); const checked = new Set(); for (const org of data.organizations) { if (checked.has(org.id)) continue; const chain = new Set(); let current: typeof org | undefined = org; while (current && !checked.has(current.id)) { if (chain.has(current.id)) { issue('HIERARCHY_CYCLE', '$.organizations', 'Organization parent references contain a cycle.'); break; } chain.add(current.id); current = current.parentId === null ? undefined : organizations.get(current.parentId); } for (const id of chain) checked.add(id); } const relationKeys = new Set(); data.relations?.forEach((relation, index) => { if (!organizations.has(relation.sourceOrganizationId) || !organizations.has(relation.targetOrganizationId)) issue('RELATION_REFERENCE', `$.relations[${index}]`, 'Relation endpoint organization does not exist.'); const key = JSON.stringify([relation.sourceOrganizationId, relation.targetOrganizationId, relation.type]); if (relation.sourceOrganizationId === relation.targetOrganizationId || relationKeys.has(key)) issue('INVALID_RELATION', `$.relations[${index}]`, 'Self-relations and duplicate directed relations are unsupported.'); relationKeys.add(key); }); const orderIds = new Set(); data.presentation?.organizationOrder.forEach((id, index) => { if (!organizations.has(id) || orderIds.has(id)) issue('PRESENTATION_REFERENCE', `$.presentation.organizationOrder[${index}]`, 'Presentation order requires existing, unique organization IDs.'); orderIds.add(id); }); if (issues.length) return { valid: false, issues }; try { return { valid: true, data: snapshotOrgChartDataset(data) }; } catch { return { valid: false, issues: [{ code: 'UNSAFE_INPUT', path: '$', message: 'Input must be a cloneable JSON dataset.' }] }; } }