Files
Toju/toju-app/src/app/domains/plugins/domain/logic/plugin-manifest-validation.logic.ts
2026-04-29 01:14:14 +02:00

205 lines
6.2 KiB
TypeScript

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<string>(PLUGIN_CAPABILITIES);
const eventDirectionSet = new Set<string>(PLUGIN_EVENT_DIRECTIONS);
const eventScopeSet = new Set<string>(PLUGIN_EVENT_SCOPES);
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function readString(record: Record<string, unknown>, 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<string, unknown>,
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<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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);
}