UNPKG

@fusedashlabs/ui9000-workspace

Version:

UI9000 workspace MCP. One tool, show_workspace, draws a chart from your table. Set TYPESAFE_API_KEY to let Jev choose the chart.

310 lines (309 loc) • 11.8 kB
import { SPEC_ACTION_SET, } from '../spec/workspace-spec.js'; import { fail } from './codes.js'; export { VALIDATION_CODES } from './codes.js'; const HANDLER_KEYS = new Set([ 'onclick', 'onchange', 'onsubmit', 'onload', 'onerror', 'onmouseover', 'onfocus', 'onblur', 'oninput', 'onkeydown', 'onkeyup', 'handler', 'innerhtml', 'outerhtml', 'dangerouslysetinnerhtml', 'html', 'srcdoc', ]); const FIELD_DEF_KEYS = new Set(['name', 'id', 'label', 'type', 'required']); const HANDLER_SOURCE = /^(?:async\s*)?(?:function\b|\([^)]*\)\s*=>|[A-Za-z_$][\w$]*\s*=>)/; /** Script URL schemes — not the word "javascript". Also matches url(javascript:…). */ const SCRIPT_SCHEME = /(?:^|[^a-z0-9_+.-])(?:javascript|vbscript)\s*:/i; /** data: / blob: payloads — not prose like "Compare data: production". `;` covers `data:;base64`. */ const DATA_URL = /(?:^|[^a-z0-9_+.-])data\s*:(?:[a-z]+\/[a-z0-9.+-]+|,|;)/i; const BLOB_URL = /(?:^|[^a-z0-9_+.-])blob\s*:/i; const ROW_KEYS = ['data', 'points', 'series', 'rows']; export function validateSpec(spec, catalog) { return validateNode(spec, catalog, byId(catalog)); } function validateNode(spec, catalog, index) { const schema = layerSchema(spec); if (!schema.ok) return schema; const node = schema.spec; const safety = layerSafety(node); if (!safety.ok) return safety; const listed = index.get(node.component); if (!listed) { return fail('unknown_component', `component "${node.component}" is not in the engine catalog`); } const dataA11y = layerDataA11y(node, listed); if (!dataA11y.ok) return dataA11y; for (const child of node.children ?? []) { const nested = validateNode(child, catalog, index); if (!nested.ok) return nested; } return { ok: true, spec: node }; } function layerSchema(spec) { if (spec === null || typeof spec !== 'object' || Array.isArray(spec)) { return fail('unknown_component', 'spec must be an object'); } const raw = spec; if (typeof raw.component !== 'string' || raw.component.trim() === '') { return fail('unknown_component', 'spec.component must be a non-empty string'); } if (raw.props !== undefined && !isPlainObject(raw.props)) { return fail('unknown_component', 'spec.props must be an object'); } if (raw.binds !== undefined && !isPlainObject(raw.binds) && !Array.isArray(raw.binds)) { return fail('missing_bound_field', 'spec.binds must be an object or array'); } if (raw.tools !== undefined && !Array.isArray(raw.tools)) { return fail('unnamed_tool', 'spec.tools must be an array'); } if (raw.allowedActions !== undefined && !Array.isArray(raw.allowedActions)) { return fail('unknown_action', 'spec.allowedActions must be an array'); } if (raw.actions !== undefined && !Array.isArray(raw.actions)) { return fail('unknown_action', 'spec.actions must be an array'); } if (raw.children !== undefined && !Array.isArray(raw.children)) { return fail('unknown_component', 'spec.children must be an array'); } if (raw.fields !== undefined && !Array.isArray(raw.fields)) { return fail('missing_bound_field', 'spec.fields must be an array'); } if (hasRowPayload(raw)) { return fail('unmet_data', 'spec must not include data rows; use binds and a data handle'); } return { ok: true, spec: raw }; } function layerSafety(spec) { const seen = new WeakSet(); const queue = [spec]; while (queue.length > 0) { const value = queue.pop(); if (typeof value === 'function') { return fail('handler_prop', 'functions are not allowed on the spec'); } if (typeof value === 'string') { const scanned = value.replace(/[\s\u0000-\u001f]+/g, ''); if (SCRIPT_SCHEME.test(scanned)) { return fail('javascript_url', 'javascript: and vbscript: URLs are refused'); } if (DATA_URL.test(scanned) || BLOB_URL.test(scanned)) { return fail('data_url', 'data: and blob: URLs are refused'); } const source = value.replace(/[\u0000-\u001f]+/g, '').trim(); if (HANDLER_SOURCE.test(source)) { return fail('handler_prop', 'handler source is not allowed on the spec'); } continue; } if (!value || typeof value !== 'object') continue; if (seen.has(value)) continue; seen.add(value); if (Array.isArray(value)) { queue.push(...value); continue; } for (const [key, child] of Object.entries(value)) { if (isHandlerKey(key)) { return fail('handler_prop', `prop "${key}" is a handler and is refused`); } if (ROW_KEYS.includes(key) && isRowArray(child)) { return fail('unmet_data', 'spec must not include data rows; use binds and a data handle'); } queue.push(child); } } return { ok: true, spec }; } function layerDataA11y(spec, entry) { const actions = declaredActions(spec); const catalogActions = new Set(entry.allowedActions ?? []); for (const action of actions) { if (!SPEC_ACTION_SET.has(action)) { return fail('unknown_action', `action "${action}" is not in hover|resize|select|submit|approve|reject`); } if (catalogActions.size > 0 && !catalogActions.has(action)) { return fail('unknown_action', `action "${action}" is not allowed on "${entry.id}"`); } } for (const tool of spec.tools ?? []) { if (!tool || typeof tool !== 'object' || typeof tool.name !== 'string' || tool.name.trim() === '') { return fail('unnamed_tool', 'every tool must have a non-empty name'); } } const binds = collectBinds(spec); const roles = new Map((entry.dataRoles ?? []).map((role) => [role.id, role])); const fields = declaredFieldNames(spec); if (binds.length > 0 && fields === null) { return fail('missing_bound_field', 'binds require a declared fields list'); } for (const bind of binds) { if (!bind.field) { return fail('missing_bound_field', `bind "${bind.role}" has no field`); } if (roles.size > 0 && !roles.has(bind.role)) { return fail('missing_bound_field', `bind role "${bind.role}" is not a catalog data role`); } if (fields && !fields.has(bind.field)) { return fail('missing_bound_field', `bind field "${bind.field}" is not a declared field`); } } for (const role of entry.dataRoles ?? []) { if (!role.required) continue; if (role.id === 'fields' && satisfiesFieldsRole(spec)) continue; if (binds.some((bind) => bind.role === role.id)) continue; return fail('unmet_data', `required data role "${role.id}" is not bound`); } if (needsLabel(entry) && !controlLabel(spec)) { return fail('unlabelled_control', `component "${entry.id}" requires a label`); } for (const item of fieldObjects(spec)) { const label = stringProp(item, 'label'); if (!label) { return fail('unlabelled_control', 'every control field must have a label'); } } return { ok: true, spec }; } function byId(catalog) { const index = new Map(); if (!Array.isArray(catalog)) return index; for (const entry of catalog) { if (entry && typeof entry.id === 'string') index.set(entry.id, entry); } return index; } function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isHandlerKey(key) { const lower = key.toLowerCase(); if (HANDLER_KEYS.has(lower)) return true; if (key.endsWith('Handler')) return true; // Event handlers (`onclick`, `onwheel`, `ONWHEEL`). Not `orientation` / `one` / `only`. return /^on[a-z]{3,}/i.test(key) && !/^(orientation|opacity|online|only|once)$/i.test(key); } function hasRowPayload(raw) { if (ROW_KEYS.some((key) => isRowArray(raw[key]))) return true; const props = raw.props; if (isPlainObject(props) && ROW_KEYS.some((key) => isRowArray(props[key]))) return true; return false; } function isRowArray(value) { if (!Array.isArray(value) || value.length === 0) return false; return value.some((item) => isPlainObject(item) && !isFieldDef(item)); } function isFieldDef(item) { const keys = Object.keys(item); return keys.length > 0 && keys.every((key) => FIELD_DEF_KEYS.has(key)); } function declaredActions(spec) { const listed = spec.allowedActions ?? spec.actions ?? []; return listed.filter((action) => typeof action === 'string'); } function collectBinds(spec) { const binds = spec.binds; if (!binds) return []; if (Array.isArray(binds)) { return binds .filter((item) => isPlainObject(item) && typeof item.role === 'string') .map((item) => ({ role: item.role, field: typeof item.field === 'string' ? item.field : '', })); } return Object.entries(binds).map(([role, value]) => { if (typeof value === 'string') return { role, field: value }; if (isPlainObject(value) && typeof value.field === 'string') { return { role, field: value.field }; } return { role, field: '' }; }); } function declaredFieldNames(spec) { const raw = spec.fields ?? (isPlainObject(spec.props) ? spec.props.fields : undefined) ?? nestedFields(spec.props); if (!Array.isArray(raw)) return null; const names = raw .map((item) => { if (typeof item === 'string') return item; if (isPlainObject(item)) { if (typeof item.name === 'string') return item.name; if (typeof item.id === 'string') return item.id; } return ''; }) .filter((name) => name !== ''); return new Set(names); } function nestedFields(props) { if (!isPlainObject(props) || !isPlainObject(props.data)) return undefined; return props.data.fields; } function satisfiesFieldsRole(spec) { if ((spec.children?.length ?? 0) > 0) return true; const fields = declaredFieldNames(spec); return fields !== null && fields.size > 0; } function needsLabel(entry) { const from = entry.accessibility?.nameFrom; return from === 'label' || from === 'form label'; } function controlLabel(spec) { return (stringProp(spec, 'label') || (isPlainObject(spec.props) ? stringProp(spec.props, 'label') : undefined) || (isPlainObject(spec.props) ? stringProp(spec.props, 'aria-label') : undefined) || (isPlainObject(spec.props) ? stringProp(spec.props, 'ariaLabel') : undefined) || (isPlainObject(spec.props) && isPlainObject(spec.props.data) ? stringProp(spec.props.data, 'label') : undefined)); } function fieldObjects(spec) { const raw = spec.fields ?? (isPlainObject(spec.props) ? spec.props.fields : undefined) ?? nestedFields(spec.props); if (!Array.isArray(raw)) return []; return raw.filter((item) => isPlainObject(item) && isFieldDef(item)); } function stringProp(obj, key) { const value = obj[key]; if (typeof value !== 'string') return undefined; const trimmed = value.trim(); return trimmed === '' ? undefined : trimmed; }