112 lines
7.3 KiB
TypeScript
112 lines
7.3 KiB
TypeScript
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<string, any> => !!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<string>();
|
|
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<string>();
|
|
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<string | number>();
|
|
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<string, Json>); 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<string, Json>): Record<string, Json> {
|
|
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<string, Json>);
|
|
if (field.type === 'array' && field.item!.type === 'object') result[field.name] = (result[field.name] as Record<string, Json>[])
|
|
.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<string, Record<string, Json>>).map(([key, item]) => [key, resolve_fields(field.item!.fields!, item)]));
|
|
}
|
|
return result;
|
|
}
|