From 8aac1a35e899940d92fb4f1d5346dded03d9437b Mon Sep 17 00:00:00 2001 From: Parth Date: Wed, 11 Mar 2026 19:36:50 -0400 Subject: [PATCH] feat(builder): enhance field sanitization in schema generation --- apps/web/app/(builder)/lib/schema-builder.ts | 172 ++++++++++++++++++- 1 file changed, 164 insertions(+), 8 deletions(-) diff --git a/apps/web/app/(builder)/lib/schema-builder.ts b/apps/web/app/(builder)/lib/schema-builder.ts index 851979c..f16be7e 100644 --- a/apps/web/app/(builder)/lib/schema-builder.ts +++ b/apps/web/app/(builder)/lib/schema-builder.ts @@ -35,6 +35,158 @@ function cleanEmptyValues(obj: T): T { return obj; } +const BASE_FIELD_KEYS = new Set([ + 'type', + 'name', + 'id', + 'label', + 'description', + 'placeholder', + 'required', + 'disabled', + 'hidden', + 'readOnly', + 'defaultValue', + 'schema', + 'validate', + 'condition', + 'style', + 'component', + 'input', + 'autoComplete', + 'meta', +]); + +const TYPE_KEYS: Record> = { + text: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'pattern', 'trim', 'ui']), + email: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'pattern', 'trim', 'ui']), + password: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'criteria', 'ui']), + textarea: new Set([...BASE_FIELD_KEYS, 'minLength', 'maxLength', 'rows', 'autoResize', 'ui']), + number: new Set([...BASE_FIELD_KEYS, 'min', 'max', 'precision', 'ui']), + date: new Set([...BASE_FIELD_KEYS, 'minDate', 'maxDate', 'ui']), + datetime: new Set([...BASE_FIELD_KEYS, 'minDate', 'maxDate', 'ui']), + select: new Set([ + ...BASE_FIELD_KEYS, + 'options', + 'dependencies', + 'hasMany', + 'minSelected', + 'maxSelected', + 'ui', + ]), + 'checkbox-group': new Set([ + ...BASE_FIELD_KEYS, + 'options', + 'dependencies', + 'minSelected', + 'maxSelected', + 'ui', + ]), + checkbox: new Set([...BASE_FIELD_KEYS]), + switch: new Set([...BASE_FIELD_KEYS, 'ui']), + radio: new Set([...BASE_FIELD_KEYS, 'options', 'dependencies', 'ui']), + tags: new Set([...BASE_FIELD_KEYS, 'minTags', 'maxTags', 'maxTagLength', 'allowDuplicates', 'ui']), + upload: new Set([ + ...BASE_FIELD_KEYS, + 'hasMany', + 'minFiles', + 'maxFiles', + 'maxSize', + 'ui', + ]), + group: new Set([...BASE_FIELD_KEYS, 'fields', 'ui']), + array: new Set([...BASE_FIELD_KEYS, 'fields', 'minRows', 'maxRows', 'ui']), + row: new Set(['type', 'fields', 'ui']), + tabs: new Set(['type', 'tabs', 'ui']), + collapsible: new Set(['type', 'label', 'fields', 'collapsed', 'ui', 'style']), +}; + +const SELECT_OPTION_KEYS = new Set([ + 'label', + 'value', + 'description', + 'icon', + 'badge', + 'disabled', +]); + +const TAB_KEYS = new Set([ + 'name', + 'label', + 'fields', + 'description', + 'icon', + 'disabled', +]); + +function pickKeys>(value: T, allowed: Set): T { + const next: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (allowed.has(key)) { + next[key] = val; + } + } + return next as T; +} + +function sanitizeSelectOptions(options: unknown): unknown { + if (!Array.isArray(options)) return options; + + return options.map((option) => { + if (typeof option === 'string') return option; + if (option && typeof option === 'object' && !Array.isArray(option)) { + const picked = pickKeys(option as Record, SELECT_OPTION_KEYS); + return picked; + } + return option; + }); +} + +function sanitizeTabs(tabs: unknown): unknown { + if (!Array.isArray(tabs)) return tabs; + + return tabs.map((tab) => { + if (!tab || typeof tab !== 'object' || Array.isArray(tab)) return tab; + const picked = pickKeys(tab as Record, TAB_KEYS); + const fields = Array.isArray((tab as { fields?: unknown }).fields) + ? (tab as { fields: Field[] }).fields.map(sanitizeFieldForExport) + : []; + return { + ...picked, + fields, + }; + }); +} + +function sanitizeFieldForExport(field: Field): Field { + const allowedKeys = TYPE_KEYS[field.type]; + if (!allowedKeys) return field; + + const picked = pickKeys(field as unknown as Record, allowedKeys); + + if ('options' in picked) { + (picked as { options?: unknown }).options = sanitizeSelectOptions( + (picked as { options?: unknown }).options, + ); + } + + if ('fields' in picked) { + (picked as { fields?: unknown }).fields = Array.isArray( + (picked as { fields?: unknown }).fields, + ) + ? ((picked as { fields: Field[] }).fields.map(sanitizeFieldForExport)) + : []; + } + + if ('tabs' in picked) { + (picked as { tabs?: unknown }).tabs = sanitizeTabs( + (picked as { tabs?: unknown }).tabs, + ); + } + + return picked as unknown as Field; +} + /** * Convert builder nodes to a BuzzForm Field array. * Recursively processes the node tree and extracts field definitions. @@ -87,20 +239,22 @@ export function nodeToField(nodes: Record, id: string): Field | nu .map((childId) => nodeToField(nodes, childId)) .filter(Boolean) as Field[]; - const tabRest = { ...(tab as unknown as Record) }; - delete tabRest.fields; - const cleanedTabRest = cleanEmptyValues(tabRest); - return { - ...cleanedTabRest, + ...(cleanEmptyValues(sanitizeFieldForExport({ + type: 'tabs', + tabs: [tab], + } as Field)) as unknown as { tabs?: Array> }) + .tabs?.[0], fields: nestedFields, }; }); - return { + const fieldForExport = { ...(orderedField as Record), tabs: normalizedTabs, } as Field; + + return sanitizeFieldForExport(fieldForExport); } if (isContainerType(field.type) && 'fields' in field) { @@ -110,13 +264,15 @@ export function nodeToField(nodes: Record, id: string): Field | nu .filter(Boolean) as Field[] : []; - return { + const fieldForExport = { ...orderedField, fields: nestedFields, } as Field; + + return sanitizeFieldForExport(fieldForExport); } - return orderedField as Field; + return sanitizeFieldForExport(orderedField as Field); } /**