import type {WorkflowField, WorkflowOutputTemplate} from '../../../types/bot/workflow/metadata.ts'; import type {Json} from '../../../types/bot/workflow/workflow.ts'; import {assert_json} from './value.ts'; function error(message: string): never {throw new Error(`Invalid workflow template: ${message}`);} const safe_name = (name: string) => /^[a-zA-Z0-9_-]{1,100}$/.test(name) && !['__proto__', 'prototype', 'constructor'].includes(name); const object = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); export function validate_i18n(value: unknown): void { if (!Array.isArray(value) || !value.length) error('missing translations'); const languages = new Set(); for (const item of value) { if (!object(item) || typeof item.lang !== 'string' || !item.lang || languages.has(item.lang) || typeof item.name !== 'string' || !item.name.trim() || typeof item.description !== 'string' || !item.description.trim()) { error('invalid translations'); } languages.add(item.lang); } } export function validate_output(value: WorkflowOutputTemplate, depth = 0): void { if (depth > 32 || !object(value) || !['string', 'number', 'boolean', 'null', 'object', 'array', 'json'].includes(value.type)) error('invalid output'); validate_i18n(value.i18n); if (value.nullable !== undefined && typeof value.nullable !== 'boolean') error('invalid nullable output'); if (value.optional !== undefined && typeof value.optional !== 'boolean') error('invalid optional output'); if (value.properties !== undefined) { if (value.type !== 'object' || !object(value.properties)) error('invalid output properties'); for (const [name, child] of Object.entries(value.properties)) { if (!safe_name(name)) error('invalid output property name'); validate_output(child, depth + 1); } } if (value.items !== undefined) {if (value.type !== 'array') error('items require array'); validate_output(value.items, depth + 1);} if (value.additional_properties !== undefined) { if (value.type !== 'object') error('additional properties require object'); validate_output(value.additional_properties, depth + 1); } } export function validate_fields(fields: WorkflowField[], depth = 0): void { if (depth > 20 || !Array.isArray(fields) || fields.length > 100) error('invalid field list'); const names = new Set(); for (const field of fields) { if (!object(field) || !safe_name(field.name) || names.has(field.name) || typeof field.required !== 'boolean' || !['string', 'number', 'boolean', 'select', 'object', 'array', 'map', 'value_source', 'condition', 'code'].includes(field.type)) error('invalid field'); names.add(field.name); validate_i18n(field.i18n); if (field.nullable !== undefined && typeof field.nullable !== 'boolean') error('invalid nullable field'); if (field.type === 'select') { if (!Array.isArray(field.options) || !field.options.length) error('select needs options'); const values = new Set(); for (const option of field.options) { if (!object(option) || !['string', 'number'].includes(typeof option.value) || values.has(option.value)) error('invalid select option'); assert_json(option.value); values.add(option.value); validate_i18n(option.i18n); } } if (field.type === 'object') validate_fields(field.fields!, depth + 1); if (field.type === 'array' || field.type === 'map') validate_fields([field.item!], depth + 1); if (field.min !== undefined && !Number.isFinite(field.min) || field.max !== undefined && !Number.isFinite(field.max) || field.min !== undefined && field.max !== undefined && field.min > field.max) error('invalid number range'); if (field.integer !== undefined && typeof field.integer !== 'boolean') error('invalid integer constraint'); for (const limit of [field.min_length, field.max_length]) { if (limit !== undefined && (!Number.isSafeInteger(limit) || limit < 0)) error('invalid length constraint'); } if (field.min_length !== undefined && field.max_length !== undefined && field.min_length > field.max_length) error('invalid length range'); if (Object.hasOwn(field, 'default')) {assert_json(field.default); check_field(field, field.default);} } } function check_field(field: WorkflowField, value: unknown): void { if (value === null && field.nullable) return; let valid = false; switch (field.type) { case 'string': case 'code': valid = typeof value === 'string' && (field.max_length === undefined || value.length <= field.max_length) && (field.min_length === undefined || value.length >= field.min_length); break; case 'number': valid = typeof value === 'number' && Number.isFinite(value) && (!field.integer || Number.isSafeInteger(value)) && (field.min === undefined || value >= field.min) && (field.max === undefined || value <= field.max); break; case 'boolean': valid = typeof value === 'boolean'; break; case 'select': valid = field.options!.some(option => option.value === value); break; case 'array': valid = Array.isArray(value) && (field.min === undefined || value.length >= field.min) && (field.max === undefined || value.length <= field.max); if (valid) for (const item of value as unknown[]) check_field(field.item!, item); break; case 'object': valid = object(value); if (valid) resolve_fields(field.fields!, value as Record); break; case 'map': valid = object(value); if (valid) for (const item of Object.values(value)) check_field(field.item!, item); break; case 'value_source': valid = object(value) && (value.kind === 'literal' || value.kind === 'ref'); break; case 'condition': valid = object(value) && typeof value.op === 'string'; break; } if (!valid) error(`invalid value for ${field.name}`); } /** Applies defaults only to missing own properties. Explicit invalid values never fall back. */ export function resolve_fields(fields: WorkflowField[], input: Record): Record { assert_json(input); if (!object(input)) error('configuration must be an object'); const result = structuredClone(input); for (const field of fields) { if (!Object.hasOwn(result, field.name)) { if (Object.hasOwn(field, 'default')) result[field.name] = structuredClone(field.default!); else if (field.required) error(`missing ${field.name}`); else continue; } check_field(field, result[field.name]); if (field.type === 'object') result[field.name] = resolve_fields(field.fields!, result[field.name] as Record); if (field.type === 'array' && field.item!.type === 'object') result[field.name] = (result[field.name] as Record[]) .map(item => resolve_fields(field.item!.fields!, item)); if (field.type === 'map' && field.item!.type === 'object') result[field.name] = Object.fromEntries( Object.entries(result[field.name] as Record>).map(([key, item]) => [key, resolve_fields(field.item!.fields!, item)])); } return result; }