Add pages API: model, controller, routes, preview (steps 4/6/7/8)

Backend for the CMS page builder, all under the existing /api/v1:

- pages.model: authoritative save gate — validates blocks against the
  registry and sanitizes them on every create/update; maps rows to/from the
  grouped API shape (metadata / settings); slug validated + reserved-checked
  at create and immutable after; `protected` can be set true via PATCH but
  only cleared via the unprotect path; published_at stamped on first publish.
- sanitizeBlocks: post-validation normalizer (applies each block's sanitize,
  stamps version, defaults visible, recurses container slots).
- reservedSlugs: guards page slugs from shadowing named routes/API namespaces.
- Admin routes (staff-gated): GET/POST /pages, GET/PATCH/DELETE /pages/:id,
  POST /pages/:id/unprotect (password step-up, verified against the caller's
  own hash, never logged), POST /pages/:id/preview (1h token). Audit-logs
  create/publish/unpublish/protect/unprotect/delete.
- Public routes: GET /public/pages/:slug (published; staff see drafts; site-
  mode gated) and GET /public/pages/:id/preview/:token (ungated, token is the
  access control). Preview token primitives added to auth/token.js.
- Swagger annotations for all new endpoints.

Verified end-to-end: model integration test against the dev DB (sanitize,
invalid-block rejection, slug immutability, protected/unprotect, dup/reserved
slug, published_at) + authenticated HTTP smoke (201 create, 400 invalid
blocks, publish, public slug fetch, preview mint+fetch, 403 delete-protected,
401 wrong-password unprotect).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:48:31 -05:00
parent 764fb0c069
commit 4d87c5f627
10 changed files with 770 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
const { query } = require('../../utils/db')
// `blocks` is stored as a JSON string (MEDIUMTEXT) and parsed in the model.
const COLS = [
'id', 'slug', 'title', 'blocks', 'status', 'protected', 'author_id',
'seo_title', 'meta_description', 'og_image', 'canonical_url', 'robots',
'layout', 'show_in_nav', 'nav_group', 'nav_order',
'created_at', 'updated_at', 'published_at',
].join(', ')
// Admin list — every page, newest first. Excludes the (potentially large)
// blocks payload; callers that need it fetch the row by id/slug.
async function listSummaries() {
return query(
`SELECT id, slug, title, status, protected, show_in_nav, nav_group, nav_order,
updated_at, published_at
FROM pages ORDER BY updated_at DESC, id DESC`,
)
}
async function findById(id) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE id = ? LIMIT 1`, [id])
return rows[0] || null
}
async function findBySlug(slug) {
const rows = await query(`SELECT ${COLS} FROM pages WHERE slug = ? LIMIT 1`, [slug])
return rows[0] || null
}
// Insert a fully-formed column map. `blocks` must already be a JSON string.
async function insert(page) {
const res = await query(
`INSERT INTO pages
(slug, title, blocks, status, protected, author_id,
seo_title, meta_description, og_image, canonical_url, robots,
layout, show_in_nav, nav_group, nav_order, published_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
page.slug,
page.title,
page.blocks,
page.status,
page.protected ? 1 : 0,
page.author_id ?? null,
page.seo_title ?? null,
page.meta_description ?? null,
page.og_image ?? null,
page.canonical_url ?? null,
page.robots ?? null,
page.layout ?? 'default',
page.show_in_nav ? 1 : 0,
page.nav_group ?? null,
page.nav_order ?? null,
page.published_at ?? null,
],
)
return res.insertId
}
// Update only the provided columns. Keys must be real column names (the model
// builds this map from a whitelist, never straight from the request body).
async function update(id, fields) {
const cols = []
const params = []
for (const [key, val] of Object.entries(fields)) {
cols.push(`${key} = ?`)
params.push(val)
}
if (cols.length === 0) return
params.push(id)
await query(`UPDATE pages SET ${cols.join(', ')} WHERE id = ?`, params)
}
async function remove(id) {
return query('DELETE FROM pages WHERE id = ?', [id])
}
module.exports = { listSummaries, findById, findBySlug, insert, update, remove }

View File

@@ -0,0 +1,306 @@
// CMS pages model. Owns the rules the API surface must not bypass:
// - blocks are validated against the block registry and sanitized on every
// save (the authoritative gate — a direct API call can't skip it);
// - the DB row is mapped to/from the grouped API shape (metadata / settings);
// - slug is validated + reserved-checked at create and is immutable after;
// - `protected` can be turned ON via a normal update but only OFF via the
// dedicated unprotect path (see unprotect()), enforced here regardless of
// what the request body contains.
//
// Business/validation failures throw a PageError carrying an HTTP status + code
// so the controller can translate without knowing the rules.
const pagesDb = require('./pages.db')
const { isReservedSlug } = require('./reservedSlugs')
const { validateBlocks, sanitizeBlocks } = require('../../blocks')
const LAYOUTS = ['default', 'full_width', 'landing']
const NAV_GROUPS = ['main', 'footer', 'account', 'hidden']
const STATUSES = ['draft', 'published']
const SLUG_RE = /^[a-z0-9-]+$/
const MAX_SLUG = 160
class PageError extends Error {
constructor(status, code, message, extra) {
super(message)
this.name = 'PageError'
this.status = status
this.code = code
if (extra) Object.assign(this, extra)
}
}
// ── Serialization (row → API shape) ───────────────────────────────────
function parseBlocks(raw) {
if (raw == null || raw === '') return []
try {
const parsed = JSON.parse(raw)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
function serialize(row) {
if (!row) return null
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
blocks: parseBlocks(row.blocks),
metadata: {
seoTitle: row.seo_title,
metaDescription: row.meta_description,
ogImage: row.og_image,
canonicalUrl: row.canonical_url,
robots: row.robots,
},
settings: {
layout: row.layout,
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
protected: Boolean(row.protected),
},
authorId: row.author_id,
createdAt: row.created_at,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
function serializeSummary(row) {
return {
id: row.id,
slug: row.slug,
title: row.title,
status: row.status,
protected: Boolean(row.protected),
showInNav: Boolean(row.show_in_nav),
navGroup: row.nav_group,
navOrder: row.nav_order,
updatedAt: row.updated_at,
publishedAt: row.published_at,
}
}
// ── Field validation / mapping ────────────────────────────────────────
function assertSlug(slug) {
if (typeof slug !== 'string' || !SLUG_RE.test(slug) || slug.length > MAX_SLUG) {
throw new PageError(400, 'invalid_slug', 'Slug must be lowercase letters, numbers and dashes.')
}
if (isReservedSlug(slug)) {
throw new PageError(400, 'reserved_slug', `"${slug}" is a reserved slug.`)
}
}
function assertStatus(status) {
if (status !== undefined && !STATUSES.includes(status)) {
throw new PageError(400, 'invalid_status', `status must be one of ${STATUSES.join(', ')}.`)
}
}
// Validate + sanitize blocks; returns a JSON string ready to store.
function buildBlocks(blocks) {
const { valid, errors } = validateBlocks(blocks)
if (!valid) {
throw new PageError(400, 'invalid_blocks', 'One or more blocks are invalid.', { errors })
}
return JSON.stringify(sanitizeBlocks(blocks))
}
// Map the grouped `metadata` object to DB columns. Only keys present in the
// input are returned, so a PATCH touches only what it sends.
function mapMetadata(metadata) {
const cols = {}
if (!metadata || typeof metadata !== 'object') return cols
const strOrNull = (v, max, field) => {
if (v === null || v === undefined || v === '') return null
if (typeof v !== 'string' || v.length > max) {
throw new PageError(400, 'invalid_metadata', `${field} must be a string of at most ${max} characters.`)
}
return v
}
if ('seoTitle' in metadata) cols.seo_title = strOrNull(metadata.seoTitle, 200, 'seoTitle')
if ('metaDescription' in metadata) cols.meta_description = strOrNull(metadata.metaDescription, 400, 'metaDescription')
if ('ogImage' in metadata) cols.og_image = strOrNull(metadata.ogImage, 500, 'ogImage')
if ('canonicalUrl' in metadata) cols.canonical_url = strOrNull(metadata.canonicalUrl, 500, 'canonicalUrl')
if ('robots' in metadata) cols.robots = strOrNull(metadata.robots, 100, 'robots')
return cols
}
// Map the grouped `settings` object to DB columns (except `protected`, which is
// handled by the caller so the unprotect rule stays centralized).
function mapSettings(settings) {
const cols = {}
if (!settings || typeof settings !== 'object') return cols
if ('layout' in settings) {
if (!LAYOUTS.includes(settings.layout)) {
throw new PageError(400, 'invalid_settings', `layout must be one of ${LAYOUTS.join(', ')}.`)
}
cols.layout = settings.layout
}
if ('showInNav' in settings) {
if (typeof settings.showInNav !== 'boolean') {
throw new PageError(400, 'invalid_settings', 'showInNav must be a boolean.')
}
cols.show_in_nav = settings.showInNav ? 1 : 0
}
if ('navGroup' in settings) {
if (settings.navGroup !== null && !NAV_GROUPS.includes(settings.navGroup)) {
throw new PageError(400, 'invalid_settings', `navGroup must be null or one of ${NAV_GROUPS.join(', ')}.`)
}
cols.nav_group = settings.navGroup
}
if ('navOrder' in settings) {
if (settings.navOrder !== null && !Number.isInteger(settings.navOrder)) {
throw new PageError(400, 'invalid_settings', 'navOrder must be an integer or null.')
}
cols.nav_order = settings.navOrder
}
return cols
}
// ── Reads ─────────────────────────────────────────────────────────────
async function list() {
const rows = await pagesDb.listSummaries()
return rows.map(serializeSummary)
}
async function getById(id) {
return serialize(await pagesDb.findById(id))
}
// Public read by slug. Non-admins only see published pages (returns null for a
// draft so the caller can 404 it indistinguishably from a missing page).
async function getBySlug(slug, { includeUnpublished = false } = {}) {
const row = await pagesDb.findBySlug(slug)
if (!row) return null
if (!includeUnpublished && row.status !== 'published') return null
return serialize(row)
}
// Raw row (for the controller's protected/status checks without re-serializing).
async function getRawById(id) {
return pagesDb.findById(id)
}
// ── Writes ────────────────────────────────────────────────────────────
async function create(input, authorId) {
const { slug, title, blocks = [], status = 'draft', metadata, settings } = input
assertSlug(slug)
assertStatus(status)
if (typeof title !== 'string' || title.trim() === '' || title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
const row = {
slug,
title: title.trim(),
blocks: buildBlocks(blocks),
status,
author_id: authorId,
...mapMetadata(metadata),
...mapSettings(settings),
protected: settings && settings.protected === true ? 1 : 0,
published_at: status === 'published' ? new Date() : null,
}
let id
try {
id = await pagesDb.insert(row)
} catch (err) {
if (err && (err.code === 'ER_DUP_ENTRY' || err.errno === 1062)) {
throw new PageError(409, 'slug_taken', `A page with slug "${slug}" already exists.`)
}
throw err
}
return getById(id)
}
async function update(id, patch) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
// slug is immutable after create — reject an attempt rather than silently
// ignoring it, so the caller knows their change didn't take.
if (patch.slug !== undefined && patch.slug !== current.slug) {
throw new PageError(400, 'slug_immutable', 'A page slug cannot be changed after creation.')
}
const fields = {}
if (patch.title !== undefined) {
if (typeof patch.title !== 'string' || patch.title.trim() === '' || patch.title.length > 200) {
throw new PageError(400, 'invalid_title', 'Title is required (max 200 characters).')
}
fields.title = patch.title.trim()
}
if (patch.blocks !== undefined) {
fields.blocks = buildBlocks(patch.blocks)
}
if (patch.status !== undefined) {
assertStatus(patch.status)
fields.status = patch.status
// Stamp published_at the first time a page becomes published.
if (patch.status === 'published' && !current.published_at) {
fields.published_at = new Date()
}
}
Object.assign(fields, mapMetadata(patch.metadata))
Object.assign(fields, mapSettings(patch.settings))
// Protected transitions: ON is allowed here; OFF is not (must go through the
// password-gated unprotect endpoint), regardless of the request body.
if (patch.settings && 'protected' in patch.settings) {
const want = patch.settings.protected
if (want === true) {
fields.protected = 1
} else if (want === false && current.protected) {
throw new PageError(403, 'unprotect_required', 'Disabling protection requires the unprotect endpoint.')
}
// want === false while already unprotected → no-op.
}
await pagesDb.update(id, fields)
return getById(id)
}
async function remove(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
if (current.protected) {
throw new PageError(403, 'page_protected', 'This page is protected and cannot be deleted.')
}
await pagesDb.remove(id)
return { id }
}
// Flip protected → false. The controller performs the password step-up before
// calling this; the model just applies it.
async function unprotect(id) {
const current = await pagesDb.findById(id)
if (!current) throw new PageError(404, 'not_found', 'Page not found.')
await pagesDb.update(id, { protected: 0 })
return getById(id)
}
module.exports = {
PageError,
LAYOUTS,
NAV_GROUPS,
STATUSES,
serialize,
list,
getById,
getBySlug,
getRawById,
create,
update,
remove,
unprotect,
}

View File

@@ -0,0 +1,28 @@
// Slugs a CMS page may not claim, because a top-level page lives at `/:slug` and
// must never shadow an existing named route (SPA route or API namespace). The
// catch-all page route is matched only after these, but reserving the names up
// front gives the admin a clear "that slug is reserved" error at create time
// instead of a silently unreachable page.
//
// Kept as a Set of lowercase single-segment slugs. Page slugs are validated to a
// single segment (^[a-z0-9-]+$) so we only need to guard first path segments.
const RESERVED_SLUGS = new Set([
// API / infrastructure
'api', 'internal', 'uploads', 'assets', 'static', 'public',
// Auth / account
'login', 'logout', 'register', 'account', 'auth',
// Admin app
'admin',
// Existing top-level SPA sections
'site', 'wiki', 'news', 'newsletter', 'screenshots', 'five-on-friday', 'about', 'status',
// Page-builder's own surface
'pages', 'preview',
])
/** @returns {boolean} true if `slug` collides with a reserved route name. */
function isReservedSlug(slug) {
return RESERVED_SLUGS.has(String(slug).toLowerCase())
}
module.exports = { RESERVED_SLUGS, isReservedSlug }