import { PLUGIN_CAPABILITIES, PLUGIN_EVENT_DIRECTIONS, PLUGIN_EVENT_SCOPES, type PluginCapabilityId, type TojuPluginManifest } from '../../../../shared-kernel'; import type { PluginManifestValidationResult, PluginValidationIssue } from '../models/plugin-runtime.models'; const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/; const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/; const capabilitySet = new Set(PLUGIN_CAPABILITIES); const eventDirectionSet = new Set(PLUGIN_EVENT_DIRECTIONS); const eventScopeSet = new Set(PLUGIN_EVENT_SCOPES); function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function readString(record: Record, key: string): string | null { const value = record[key]; return typeof value === 'string' ? value.trim() : null; } function pushIssue( issues: PluginValidationIssue[], path: string, message: string, severity: PluginValidationIssue['severity'] = 'error' ): void { issues.push({ path, message, severity }); } function validateStringField( issues: PluginValidationIssue[], record: Record, key: string, options?: { pattern?: RegExp; required?: boolean } ): void { const value = readString(record, key); if (!value) { if (options?.required !== false) { pushIssue(issues, key, `${key} is required`); } return; } if (options?.pattern && !options.pattern.test(value)) { pushIssue(issues, key, `${key} has an invalid format`); } } function validateStringArray( issues: PluginValidationIssue[], value: unknown, path: string ): void { if (value === undefined) { return; } if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) { pushIssue(issues, path, `${path} must be an array of non-empty strings`); } } function validateRelationships(issues: PluginValidationIssue[], manifestRecord: Record): void { const relationships = manifestRecord['relationships']; if (relationships === undefined) { return; } if (!isRecord(relationships)) { pushIssue(issues, 'relationships', 'relationships must be an object'); return; } validateStringArray(issues, relationships['after'], 'relationships.after'); validateStringArray(issues, relationships['before'], 'relationships.before'); validateStringArray(issues, relationships['conflicts'], 'relationships.conflicts'); for (const key of ['requires', 'optional'] as const) { const entries = relationships[key]; if (entries === undefined) { continue; } if (!Array.isArray(entries)) { pushIssue(issues, `relationships.${key}`, `relationships.${key} must be an array`); continue; } entries.forEach((entry, index) => { if (!isRecord(entry) || typeof entry['id'] !== 'string' || !entry['id'].trim()) { pushIssue(issues, `relationships.${key}.${index}`, 'dependency id is required'); } }); } } function validateCapabilities(issues: PluginValidationIssue[], manifestRecord: Record): void { const capabilities = manifestRecord['capabilities']; if (capabilities === undefined) { return; } if (!Array.isArray(capabilities)) { pushIssue(issues, 'capabilities', 'capabilities must be an array'); return; } capabilities.forEach((capability, index) => { if (typeof capability !== 'string' || !capabilitySet.has(capability)) { pushIssue(issues, `capabilities.${index}`, `Unknown capability ${String(capability)}`); } }); } function validateEvents(issues: PluginValidationIssue[], manifestRecord: Record): void { const events = manifestRecord['events']; if (events === undefined) { return; } if (!Array.isArray(events)) { pushIssue(issues, 'events', 'events must be an array'); return; } events.forEach((event, index) => { if (!isRecord(event)) { pushIssue(issues, `events.${index}`, 'event must be an object'); return; } if (typeof event['eventName'] !== 'string' || !event['eventName'].trim()) { pushIssue(issues, `events.${index}.eventName`, 'eventName is required'); } if (typeof event['direction'] !== 'string' || !eventDirectionSet.has(event['direction'])) { pushIssue(issues, `events.${index}.direction`, 'direction is invalid'); } if (typeof event['scope'] !== 'string' || !eventScopeSet.has(event['scope'])) { pushIssue(issues, `events.${index}.scope`, 'scope is invalid'); } }); } export function validateTojuPluginManifest(value: unknown): PluginManifestValidationResult { const issues: PluginValidationIssue[] = []; if (!isRecord(value)) { return { issues: [{ path: '', message: 'Manifest must be an object', severity: 'error' }], valid: false }; } validateStringField(issues, value, 'id', { pattern: PLUGIN_ID_PATTERN }); validateStringField(issues, value, 'title'); validateStringField(issues, value, 'description'); validateStringField(issues, value, 'version', { pattern: VERSION_PATTERN }); validateStringField(issues, value, 'apiVersion'); if (value['schemaVersion'] !== 1) { pushIssue(issues, 'schemaVersion', 'schemaVersion must be 1'); } if (value['kind'] !== 'client' && value['kind'] !== 'library') { pushIssue(issues, 'kind', 'kind must be client or library'); } if (!isRecord(value['compatibility'])) { pushIssue(issues, 'compatibility', 'compatibility is required'); } else { validateStringField(issues, value['compatibility'], 'minimumTojuVersion'); } if (typeof value['entrypoint'] !== 'string' && value['kind'] === 'client') { pushIssue(issues, 'entrypoint', 'client plugins require an entrypoint'); } validateCapabilities(issues, value); validateRelationships(issues, value); validateEvents(issues, value); return { issues, manifest: issues.some((issue) => issue.severity === 'error') ? undefined : value as unknown as TojuPluginManifest, valid: !issues.some((issue) => issue.severity === 'error') }; } export function isKnownPluginCapability(value: string): value is PluginCapabilityId { return capabilitySet.has(value); }