// Server-side validation for a page's `blocks` array, run on every save before // persisting. The admin UI validates client-side too, but that can be bypassed // by a direct API call, so this is the authoritative gate: it enforces the block // envelope (reserved keys only), that every `type` is a registered block, that // each block's props satisfy the registry schema, and the one-level nesting cap // (only container blocks may hold sub-blocks, and sub-blocks may not themselves // be containers). // // Returns { valid, errors } — a flat list of human-readable error strings, each // prefixed with the path to the offending block (e.g. `blocks[2].props.text`). // It never throws on bad input; callers turn a non-empty `errors` into a 400. const { getBlock, RESERVED_KEYS } = require('./registry') // Bound the payload so a single page can't carry an unreasonable block tree. const MAX_BLOCKS = 100 // top-level blocks per page const MAX_SUBBLOCKS = 50 // sub-blocks per container slot const ID_RE = /^[A-Za-z0-9_-]{1,40}$/ /** * Validate a stored blocks array against the registry. * @param {unknown} blocks * @returns {{ valid: boolean, errors: string[] }} */ function validateBlocks(blocks) { const errors = [] if (!Array.isArray(blocks)) { return { valid: false, errors: ['blocks must be an array'] } } if (blocks.length > MAX_BLOCKS) { errors.push(`blocks may not exceed ${MAX_BLOCKS} top-level entries`) } const seenIds = new Set() blocks.forEach((block, i) => { validateBlock(block, `blocks[${i}]`, seenIds, errors, { nested: false }) }) return { valid: errors.length === 0, errors } } /** * Validate one block envelope in place. `nested` = true when validating a * sub-block inside a container slot, which forbids further nesting. */ function validateBlock(block, path, seenIds, errors, { nested }) { if (block === null || typeof block !== 'object' || Array.isArray(block)) { errors.push(`${path} must be an object`) return } // Envelope: only the reserved keys, nothing smuggled at the top level. for (const key of Object.keys(block)) { if (!RESERVED_KEYS.includes(key)) { errors.push(`${path}.${key} is not an allowed top-level key`) } } // id — stable, unique across the whole page (top-level and nested share one // namespace since ids are the future join point for revision history). if (typeof block.id !== 'string' || !ID_RE.test(block.id)) { errors.push(`${path}.id must be a short id string`) } else if (seenIds.has(block.id)) { errors.push(`${path}.id duplicates another block id (${block.id})`) } else { seenIds.add(block.id) } // visible — optional in input, but if present must be a boolean. if (block.visible !== undefined && typeof block.visible !== 'boolean') { errors.push(`${path}.visible must be a boolean`) } // props — always an object bag. const props = block.props if (props === null || typeof props !== 'object' || Array.isArray(props)) { errors.push(`${path}.props must be an object`) } // type — must resolve to a registered block. const def = typeof block.type === 'string' ? getBlock(block.type) : null if (!def) { errors.push(`${path}.type is not a registered block type (${String(block.type)})`) return // can't validate props or nesting without a definition } // Per-block prop schema from the registry. if (def.schema && props && typeof props === 'object') { let schemaErrors = [] try { schemaErrors = def.schema(props) || [] } catch (err) { schemaErrors = [`schema threw: ${err.message}`] } for (const e of schemaErrors) errors.push(`${path}.props.${e}`) } // Nesting: only container blocks may hold sub-blocks, capped at one level. if (def.container) { if (nested) { errors.push(`${path} is a container and may not be nested inside another container`) return } for (const slot of def.containerSlots) { const sub = props ? props[slot] : undefined if (sub === undefined) continue // an empty slot is allowed if (!Array.isArray(sub)) { errors.push(`${path}.props.${slot} must be an array of blocks`) continue } if (sub.length > MAX_SUBBLOCKS) { errors.push(`${path}.props.${slot} may not exceed ${MAX_SUBBLOCKS} blocks`) } sub.forEach((child, j) => { validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true }) }) } } } module.exports = { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }