From 6d31869ba225f4a260a89cb9afffff5372ad6102 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 20:30:58 -0500 Subject: [PATCH] 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 --- server/src/blocks/index.js | 23 ++++---- server/src/blocks/propHelpers.js | 84 ++++++++++++++++++++++++++++ server/src/blocks/registry.js | 7 +++ server/src/blocks/types/cta.js | 22 ++++++++ server/src/blocks/types/divider.js | 12 ++++ server/src/blocks/types/heading.js | 21 +++++++ server/src/blocks/types/image.js | 27 +++++++++ server/src/blocks/types/quote.js | 20 +++++++ server/src/blocks/types/richText.js | 26 +++++++++ server/src/blocks/types/twoColumn.js | 20 +++++++ 10 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 server/src/blocks/propHelpers.js create mode 100644 server/src/blocks/types/cta.js create mode 100644 server/src/blocks/types/divider.js create mode 100644 server/src/blocks/types/heading.js create mode 100644 server/src/blocks/types/image.js create mode 100644 server/src/blocks/types/quote.js create mode 100644 server/src/blocks/types/richText.js create mode 100644 server/src/blocks/types/twoColumn.js diff --git a/server/src/blocks/index.js b/server/src/blocks/index.js index ac07161..daf35e1 100644 --- a/server/src/blocks/index.js +++ b/server/src/blocks/index.js @@ -4,22 +4,21 @@ // page's blocks or look up a block type should require THIS module, not // ./registry directly, so the definitions are guaranteed to be loaded. // -// Wave 1 block definitions are registered below, one require() per block, as -// they are built (spec build order step 3). Until then the registry is empty and -// validateBlocks rejects any block type — which is correct: no page can save a -// block that has no server-side schema yet. +// Wave 1 block definitions are registered below, one require() per block (each +// module self-registers on load). Requiring THIS module guarantees they are all +// present before anything validates a page's blocks. const registry = require('./registry') const { validateBlocks, MAX_BLOCKS, MAX_SUBBLOCKS } = require('./validateBlocks') -// ── Wave 1 block definitions ────────────────────────────────────────── -// require('./types/heading').register(registry) // added in step 3 -// require('./types/richText').register(registry) -// require('./types/image').register(registry) -// require('./types/twoColumn').register(registry) -// require('./types/cta').register(registry) -// require('./types/divider').register(registry) -// require('./types/quote').register(registry) +// ── Wave 1 block definitions (self-register on require) ──────────────── +require('./types/heading') +require('./types/richText') +require('./types/image') +require('./types/twoColumn') +require('./types/cta') +require('./types/divider') +require('./types/quote') module.exports = { ...registry, diff --git a/server/src/blocks/propHelpers.js b/server/src/blocks/propHelpers.js new file mode 100644 index 0000000..b6fe587 --- /dev/null +++ b/server/src/blocks/propHelpers.js @@ -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, +} diff --git a/server/src/blocks/registry.js b/server/src/blocks/registry.js index d85c809..a412b59 100644 --- a/server/src/blocks/registry.js +++ b/server/src/blocks/registry.js @@ -12,6 +12,9 @@ // version: 1, // prop-schema version; bump when props change so a // // one-time migration can transform older blocks // 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; // // null = never cache (static blocks). Dynamic // // 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') { 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 || [] if (def.container && containerSlots.length === 0) { throw new Error(`registerBlock: container block ${def.type} needs containerSlots`) @@ -55,6 +61,7 @@ function registerBlock(def) { type: def.type, version: Number.isInteger(def.version) ? def.version : 1, schema: def.schema || null, + sanitize: def.sanitize || null, cacheTTL: def.cacheTTL == null ? null : Number(def.cacheTTL), container: Boolean(def.container), containerSlots: Object.freeze([...containerSlots]), diff --git a/server/src/blocks/types/cta.js b/server/src/blocks/types/cta.js new file mode 100644 index 0000000..c2caa68 --- /dev/null +++ b/server/src/blocks/types/cta.js @@ -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 + }, +}) diff --git a/server/src/blocks/types/divider.js b/server/src/blocks/types/divider.js new file mode 100644 index 0000000..0fc3562 --- /dev/null +++ b/server/src/blocks/types/divider.js @@ -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, []) + }, +}) diff --git a/server/src/blocks/types/heading.js b/server/src/blocks/types/heading.js new file mode 100644 index 0000000..16a1c05 --- /dev/null +++ b/server/src/blocks/types/heading.js @@ -0,0 +1,21 @@ +// heading — a section heading. `level` picks the tag (h1–h4), `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 + }, +}) diff --git a/server/src/blocks/types/image.js b/server/src/blocks/types/image.js new file mode 100644 index 0000000..b52142e --- /dev/null +++ b/server/src/blocks/types/image.js @@ -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 + }, +}) diff --git a/server/src/blocks/types/quote.js b/server/src/blocks/types/quote.js new file mode 100644 index 0000000..dea22d5 --- /dev/null +++ b/server/src/blocks/types/quote.js @@ -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 + }, +}) diff --git a/server/src/blocks/types/richText.js b/server/src/blocks/types/richText.js new file mode 100644 index 0000000..b4f922e --- /dev/null +++ b/server/src/blocks/types/richText.js @@ -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) } + }, +}) diff --git a/server/src/blocks/types/twoColumn.js b/server/src/blocks/types/twoColumn.js new file mode 100644 index 0000000..9030a40 --- /dev/null +++ b/server/src/blocks/types/twoColumn.js @@ -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']) + }, +})