@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
JavaScript
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;
}