Add Wave 1 block server schemas (page builder step 3, server half)

Register all seven Wave 1 block types with their server-side validation
schemas, self-registering via server/src/blocks/types/*:
heading, rich_text, image, two_column (container), cta, divider, quote.

- propHelpers.js: shared validators (isSafeUrl rejects javascript:/data:/
  protocol-relative, enum/required/optional text, strict key allowlist).
- rich_text carries a `sanitize` normalizer (registry now supports it) that
  runs html through the shared cleanBody allowlist on save.
- Registry entrypoint requires the type modules so all schemas load.

Verified: all 7 register; valid blocks pass; malformed props yield precise
per-path errors; one-level nesting cap enforced; rich_text sanitize strips
script/onerror.

Client renderers + editors (step 3 client half) still to come.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:30:58 -05:00
parent fcef08e9b6
commit 6d31869ba2
10 changed files with 250 additions and 12 deletions

View File

@@ -4,22 +4,21 @@
// page's blocks or look up a block type should require THIS module, not // page's blocks or look up a block type should require THIS module, not
// ./registry directly, so the definitions are guaranteed to be loaded. // ./registry directly, so the definitions are guaranteed to be loaded.
// //
// Wave 1 block definitions are registered below, one require() per block, as // Wave 1 block definitions are registered below, one require() per block (each
// they are built (spec build order step 3). Until then the registry is empty and // module self-registers on load). Requiring THIS module guarantees they are all
// validateBlocks rejects any block type — which is correct: no page can save a // present before anything validates a page's blocks.
// block that has no server-side schema yet.
const registry = require('./registry') const registry = require('./registry')
const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks') const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks')
// ── Wave 1 block definitions ────────────────────────────────────────── // ── Wave 1 block definitions (self-register on require) ────────────────
// require('./types/heading').register(registry) // added in step 3 require('./types/heading')
// require('./types/richText').register(registry) require('./types/richText')
// require('./types/image').register(registry) require('./types/image')
// require('./types/twoColumn').register(registry) require('./types/twoColumn')
// require('./types/cta').register(registry) require('./types/cta')
// require('./types/divider').register(registry) require('./types/divider')
// require('./types/quote').register(registry) require('./types/quote')
module.exports = { module.exports = {
...registry, ...registry,

View File

@@ -0,0 +1,84 @@
// Small shared validators used by the Wave 1 block schemas. Each block's schema
// composes these and returns a flat array of error strings; validateBlocks
// prefixes each with the block path (so 'text is required' becomes
// 'blocks[2].props.text is required'). Phrase messages to read well after that
// prefix — start with the prop name.
/** @returns {boolean} true if v is a non-empty (after trim) string. */
function isNonEmptyString(v) {
return typeof v === 'string' && v.trim().length > 0
}
/**
* Accept a same-origin relative URL ("/uploads/x.png", "/wiki/foo") or an
* absolute http/https URL. Rejects javascript:, data:, protocol-relative
* ("//evil"), and anything else — the block renderers drop these into hrefs/src
* so this is a security boundary, not just a format check.
* @param {unknown} v
* @returns {boolean}
*/
function isSafeUrl(v) {
if (typeof v !== 'string' || v.trim() === '') return false
const s = v.trim()
if (s.startsWith('//')) return false // protocol-relative — ambiguous origin
if (s.startsWith('/')) return true // same-origin relative
try {
const u = new URL(s)
return u.protocol === 'http:' || u.protocol === 'https:'
} catch {
return false
}
}
/**
* Build an enum validator for a prop.
* @param {string} name prop name (for the message)
* @param {string[]} allowed
* @returns {(v: unknown) => string|null} error string or null
*/
function oneOf(name, allowed) {
return (v) => (allowed.includes(v) ? null : `${name} must be one of ${allowed.join(', ')}`)
}
/**
* Validate a required text prop: present, non-empty, within maxLen.
* @returns {string|null}
*/
function requiredText(name, v, maxLen) {
if (!isNonEmptyString(v)) return `${name} is required`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Validate an optional text prop: if present it must be a string within maxLen.
* @returns {string|null}
*/
function optionalText(name, v, maxLen) {
if (v === undefined || v === null || v === '') return null
if (typeof v !== 'string') return `${name} must be a string`
if (v.length > maxLen) return `${name} must be at most ${maxLen} characters`
return null
}
/**
* Reject any prop key not in `allowed`. Keeps a block's props tight so nothing
* unexpected is smuggled through and stored.
* @returns {string[]} error strings
*/
function onlyKeys(props, allowed) {
const errors = []
for (const key of Object.keys(props)) {
if (!allowed.includes(key)) errors.push(`${key} is not an allowed prop`)
}
return errors
}
module.exports = {
isNonEmptyString,
isSafeUrl,
oneOf,
requiredText,
optionalText,
onlyKeys,
}

View File

@@ -12,6 +12,9 @@
// version: 1, // prop-schema version; bump when props change so a // version: 1, // prop-schema version; bump when props change so a
// // one-time migration can transform older blocks // // one-time migration can transform older blocks
// schema: (props) => [], // returns an array of error strings ([] = valid) // schema: (props) => [], // returns an array of error strings ([] = valid)
// sanitize: (props) => props, // optional normalizer run on save AFTER
// // validation, e.g. rich_text runs its html through
// // the shared allowlist; returns cleaned props
// cacheTTL: null, // seconds a rendered instance may be cached; // cacheTTL: null, // seconds a rendered instance may be cached;
// // null = never cache (static blocks). Dynamic // // null = never cache (static blocks). Dynamic
// // Wave 2 blocks set this (e.g. server_status: 10). // // Wave 2 blocks set this (e.g. server_status: 10).
@@ -47,6 +50,9 @@ function registerBlock(def) {
if (def.schema != null && typeof def.schema !== 'function') { if (def.schema != null && typeof def.schema !== 'function') {
throw new Error(`registerBlock: ${def.type}.schema must be a function`) throw new Error(`registerBlock: ${def.type}.schema must be a function`)
} }
if (def.sanitize != null && typeof def.sanitize !== 'function') {
throw new Error(`registerBlock: ${def.type}.sanitize must be a function`)
}
const containerSlots = def.containerSlots || [] const containerSlots = def.containerSlots || []
if (def.container && containerSlots.length === 0) { if (def.container && containerSlots.length === 0) {
throw new Error(`registerBlock: container block ${def.type} needs containerSlots`) throw new Error(`registerBlock: container block ${def.type} needs containerSlots`)
@@ -55,6 +61,7 @@ function registerBlock(def) {
type: def.type, type: def.type,
version: Number.isInteger(def.version) ? def.version : 1, version: Number.isInteger(def.version) ? def.version : 1,
schema: def.schema || null, schema: def.schema || null,
sanitize: def.sanitize || null,
cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL), cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL),
container: Boolean(def.container), container: Boolean(def.container),
containerSlots: Object.freeze([...containerSlots]), containerSlots: Object.freeze([...containerSlots]),

View File

@@ -0,0 +1,22 @@
// cta — a call-to-action button. `text` is the label, `url` the destination
// (same-origin path or http/https), `style` picks primary/secondary appearance.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, requiredText, onlyKeys } = require('../propHelpers')
const STYLES = ['primary', 'secondary']
const MAX_TEXT = 100
registerBlock({
type: 'cta',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'url', 'style'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
if (!isSafeUrl(props.url)) errors.push('url must be a same-origin path or http(s) URL')
const style = oneOf('style', STYLES)(props.style)
if (style) errors.push(style)
return errors
},
})

View File

@@ -0,0 +1,12 @@
// divider — a pure spacer / horizontal rule. Carries no props.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'divider',
version: 1,
cacheTTL: null,
schema(props) {
return onlyKeys(props, [])
},
})

View File

@@ -0,0 +1,21 @@
// heading — a section heading. `level` picks the tag (h1h4), `text` is plain
// text (the renderer escapes it; no HTML here — use rich_text for markup).
const { registerBlock } = require('../registry')
const { oneOf, requiredText, onlyKeys } = require('../propHelpers')
const LEVELS = ['h1', 'h2', 'h3', 'h4']
const MAX_TEXT = 200
registerBlock({
type: 'heading',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['level', 'text'])
const level = oneOf('level', LEVELS)(props.level)
if (level) errors.push(level)
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
return errors
},
})

View File

@@ -0,0 +1,27 @@
// image — a single image with optional caption. `src` must be a same-origin
// upload path or an http/https URL (isSafeUrl); `alignment` controls layout.
// Stays URL-based until the Wave 3 asset picker lands, then src swaps to an
// asset id via a small migration.
const { registerBlock } = require('../registry')
const { isSafeUrl, oneOf, optionalText, onlyKeys } = require('../propHelpers')
const ALIGNMENTS = ['left', 'center', 'right', 'full']
const MAX_ALT = 300
const MAX_CAPTION = 500
registerBlock({
type: 'image',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['src', 'alt', 'caption', 'alignment'])
if (!isSafeUrl(props.src)) errors.push('src must be a same-origin path or http(s) URL')
const alt = optionalText('alt', props.alt, MAX_ALT)
if (alt) errors.push(alt)
const caption = optionalText('caption', props.caption, MAX_CAPTION)
if (caption) errors.push(caption)
const alignment = oneOf('alignment', ALIGNMENTS)(props.alignment)
if (alignment) errors.push(alignment)
return errors
},
})

View File

@@ -0,0 +1,20 @@
// quote — a pull quote with optional attribution.
const { registerBlock } = require('../registry')
const { requiredText, optionalText, onlyKeys } = require('../propHelpers')
const MAX_TEXT = 1000
const MAX_ATTRIB = 200
registerBlock({
type: 'quote',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['text', 'attribution'])
const text = requiredText('text', props.text, MAX_TEXT)
if (text) errors.push(text)
const attribution = optionalText('attribution', props.attribution, MAX_ATTRIB)
if (attribution) errors.push(attribution)
return errors
},
})

View File

@@ -0,0 +1,26 @@
// rich_text — a block of HTML authored in the shared rich-text editor. Validated
// only for type/size here; the actual safety comes from `sanitize`, which runs
// the html through the same allowlist (cleanBody) used for posts/wiki bodies, so
// a direct API call can't smuggle unsafe markup past the editor.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
const { cleanBody } = require('../../utils/sanitizeHtml')
const MAX_HTML = 50000
registerBlock({
type: 'rich_text',
version: 1,
cacheTTL: null,
schema(props) {
const errors = onlyKeys(props, ['html'])
if (typeof props.html !== 'string') errors.push('html must be a string')
else if (props.html.length > MAX_HTML) {
errors.push(`html must be at most ${MAX_HTML} characters`)
}
return errors
},
sanitize(props) {
return { ...props, html: cleanBody(props.html) }
},
})

View File

@@ -0,0 +1,20 @@
// two_column — the only container block. Holds two ordered arrays of sub-blocks
// (`left`, `right`). The sub-block arrays are validated by validateBlocks, which
// also enforces the one-level nesting cap (a column may not contain another
// container). This schema only guards the prop shape; the slot arrays default to
// empty when absent.
const { registerBlock } = require('../registry')
const { onlyKeys } = require('../propHelpers')
registerBlock({
type: 'two_column',
version: 1,
cacheTTL: null,
container: true,
containerSlots: ['left', 'right'],
schema(props) {
// Slot array contents are validated by validateBlocks' container handling;
// here we only reject stray props.
return onlyKeys(props, ['left', 'right'])
},
})