Every subject and body moves out of `mailer.js` into `engagement_templates` rows an operator can edit. A relocation, not a regression: nothing that sends mail today starts depending on an operator authoring something first. - `email.*` block family in its own registry, sharing the page family's envelope walk and validate-then-sanitize order by binding rather than by copy. - A server-side renderer producing both parts of a multipart message; the text part is byte-identical to the literals this commit deletes. - Nine seeded templates, six of them wired now; the seeder's `customized = 0` guard lives in the UPDATE's own WHERE. - `renderByKey` falls back to the shipped seed when a row is missing or unusable, so no failure of the table can stop a password reset. Also fixes `check:hosts` reading the template key `auth.email-verify` as the hostname `auth.email`. Co-Authored-By: Claude <noreply@anthropic.com>
155 lines
6.1 KiB
JavaScript
155 lines
6.1 KiB
JavaScript
// Server-side validation for a stored `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.
|
|
//
|
|
// **The walk is parameterized by a registry lookup, and the page registry is one
|
|
// binding of it** (engagement Phase 5a). The `email.*` family is a SEPARATE
|
|
// registry — its entries carry renderers instead of a cache policy, and a
|
|
// CMS page must not validate with an email block inside it — but the envelope,
|
|
// the id uniqueness, the schema dispatch and the nesting cap are the same rules
|
|
// for both. Sharing the walk is what keeps them the same rules rather than two
|
|
// copies that drift.
|
|
|
|
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}$/
|
|
|
|
/**
|
|
* Build a blocks validator bound to one registry.
|
|
*
|
|
* @param {(type: string) => object|null} lookup registry `getBlock`
|
|
* @param {{ maxBlocks?: number, maxSubBlocks?: number }} [limits]
|
|
* @returns {(blocks: unknown) => { valid: boolean, errors: string[] }}
|
|
*/
|
|
function makeValidateBlocks(lookup, limits = {}) {
|
|
const maxBlocks = limits.maxBlocks || MAX_BLOCKS
|
|
const maxSubBlocks = limits.maxSubBlocks || MAX_SUBBLOCKS
|
|
|
|
// Envelope: only the reserved keys, nothing smuggled at the top level.
|
|
function checkEnvelope(block, path, errors) {
|
|
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 document (top-level and nested share one
|
|
// namespace since ids are the future join point for revision history).
|
|
function checkId(block, path, seenIds, errors) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// Per-block prop schema from the registry (skipped when props isn't an object —
|
|
// that's already reported separately).
|
|
function checkPropSchema(def, props, path, errors) {
|
|
if (!def.schema || !props || typeof props !== 'object') return
|
|
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.
|
|
function checkNesting(def, props, path, seenIds, errors, nested) {
|
|
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 > maxSubBlocks) {
|
|
errors.push(`${path}.props.${slot} may not exceed ${maxSubBlocks} blocks`)
|
|
}
|
|
sub.forEach((child, j) => {
|
|
validateBlock(child, `${path}.props.${slot}[${j}]`, seenIds, errors, { nested: true })
|
|
})
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|
|
|
|
checkEnvelope(block, path, errors)
|
|
checkId(block, path, seenIds, errors)
|
|
|
|
// 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' ? lookup(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
|
|
}
|
|
|
|
checkPropSchema(def, props, path, errors)
|
|
if (def.container) checkNesting(def, props, path, seenIds, errors, nested)
|
|
}
|
|
|
|
/**
|
|
* Validate a stored blocks array against the bound registry.
|
|
* @param {unknown} blocks
|
|
* @returns {{ valid: boolean, errors: string[] }}
|
|
*/
|
|
return function validateBlocks(blocks) {
|
|
const errors = []
|
|
if (!Array.isArray(blocks)) {
|
|
return { valid: false, errors: ['blocks must be an array'] }
|
|
}
|
|
if (blocks.length > maxBlocks) {
|
|
errors.push(`blocks may not exceed ${maxBlocks} 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 }
|
|
}
|
|
}
|
|
|
|
// The page-registry binding — the export every existing caller already uses.
|
|
const validateBlocks = makeValidateBlocks(getBlock)
|
|
|
|
module.exports = { validateBlocks, makeValidateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS }
|