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

@@ -12,6 +12,7 @@ const authProviders = require('./authProviders.controller')
const discordBot = require('./discordBot.controller')
const emailConfig = require('./emailConfig.controller')
const moderation = require('./moderation.controller')
const pagesCtrl = require('./pages.controller')
const { isLoggedIn, requireRole } = require('../../../utils/auth')
const noindex = require('../../../middleware/noindex')
const validate = require('../../../middleware/validate')
@@ -475,6 +476,99 @@ adminRouter.delete(
ctrl.deleteWiki,
)
// ── CMS Pages (block-based page builder) ──────────────────────────────
adminRouter.get(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'List all CMS pages (summaries)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.responses[200] = { description: 'Page summaries', content: { "application/json": { schema: { type: "array", items: { type: "object", additionalProperties: true } } } } } */
pagesCtrl.listPages,
)
adminRouter.post(
'/pages',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Create a CMS page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { slug: { type: "string" }, title: { type: "string" }, status: { type: "string", enum: ["draft","published"] }, blocks: { type: "array", items: { type: "object" } }, metadata: { type: "object" }, settings: { type: "object" } } } } } } */
/* #swagger.responses[201] = { description: 'Created page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Invalid slug / title / blocks / metadata / settings', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[409] = { description: 'Slug already exists', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
body('slug').isString().trim().notEmpty(),
body('title').isString().trim().notEmpty().isLength({ max: 200 }),
validate,
pagesCtrl.createPage,
)
adminRouter.get(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Get a CMS page by id (full, incl. blocks)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'The page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.getPage,
)
adminRouter.patch(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Update a CMS page (title, status, blocks, metadata, settings)'
// #swagger.description = 'slug is immutable; disabling protection is rejected here (use /unprotect).'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[200] = { description: 'Updated page', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[400] = { description: 'Validation error (slug immutable, invalid blocks, etc.)', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[403] = { description: 'Disabling protection requires /unprotect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.updatePage,
)
adminRouter.delete(
'/pages/:id',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Delete a CMS page (blocked if protected)'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Deleted (echoes the id)', content: { "application/json": { schema: { $ref: "#/components/schemas/DeletedId" } } } } */
/* #swagger.responses[403] = { description: 'Page is protected', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.deletePage,
)
adminRouter.post(
'/pages/:id/unprotect',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Disable page protection (password step-up re-auth)'
// #swagger.description = 'Verifies the current admin password server-side, then flips protected → false.'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.requestBody = { required: true, content: { "application/json": { schema: { type: "object", properties: { password: { type: "string" } }, required: ["password"] } } } } */
/* #swagger.responses[200] = { description: 'Updated page (protected=false)', content: { "application/json": { schema: { type: "object", additionalProperties: true } } } } */
/* #swagger.responses[401] = { description: 'Password incorrect', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
body('password').isString().notEmpty(),
validate,
pagesCtrl.unprotectPage,
)
adminRouter.post(
'/pages/:id/preview',
// #swagger.tags = ['Admin · Pages']
// #swagger.summary = 'Mint a 1h draft-preview link for a page'
// #swagger.security = [{ "cookieAuth": [] }, { "bearerAuth": [] }]
// #swagger.parameters['id'] = { in: 'path', required: true, schema: { type: 'integer' }, description: 'Page id.' }
/* #swagger.responses[200] = { description: 'Preview token + path', content: { "application/json": { schema: { type: "object", properties: { token: { type: "string" }, expiresInSeconds: { type: "integer" }, path: { type: "string" } } } } } } */
/* #swagger.responses[404] = { description: 'Not found', content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } } } */
param('id').isInt(),
validate,
pagesCtrl.createPreview,
)
// ── Settings ──────────────────────────────────────────────────────────
adminRouter.get(
'/settings',

View File

@@ -0,0 +1,131 @@
// Admin CMS pages controller. Thin HTTP layer over pages.model — it translates
// the model's PageError (status + code) into responses and records audit-log
// entries for the lifecycle events the spec calls out (create / publish /
// unpublish / delete, protect on, and the password-gated unprotect).
const pages = require('../../../model/pages/pages.model')
const users = require('../../../model/users/users.model')
const activity = require('../../../model/activity/activity.model')
const token = require('../../../auth/token')
const logger = require('../../../utils/logger')('pages')
// Map a thrown error to a response. Known PageErrors carry a status + code (and
// sometimes a block-error list); anything else is an unexpected 500.
function fail(res, err) {
if (err && err.name === 'PageError') {
const body = { error: err.message, code: err.code }
if (err.errors) body.details = err.errors
return res.status(err.status).json(body)
}
logger.error('unexpected pages error', { error: err.message })
return res.status(500).json({ error: 'Internal error' })
}
async function listPages(req, res) {
return res.json(await pages.list())
}
async function getPage(req, res) {
const page = await pages.getById(Number(req.params.id))
if (!page) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
return res.json(page)
}
async function createPage(req, res) {
try {
const page = await pages.create(req.body, req.user.id)
await activity.log({ req, action: 'page.create', detail: { id: page.id, slug: page.slug } })
if (page.status === 'published') {
await activity.log({ req, action: 'page.publish', detail: { id: page.id, slug: page.slug } })
}
return res.status(201).json(page)
} catch (err) {
return fail(res, err)
}
}
async function updatePage(req, res) {
try {
const id = Number(req.params.id)
const before = await pages.getRawById(id)
if (!before) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
const page = await pages.update(id, req.body)
await activity.log({ req, action: 'page.update', detail: { id, slug: page.slug } })
// Emit dedicated audit events for the transitions the spec singles out.
if (before.status !== page.status) {
const action = page.status === 'published' ? 'page.publish' : 'page.unpublish'
await activity.log({ req, action, detail: { id, slug: page.slug } })
}
if (!before.protected && page.settings.protected) {
await activity.log({ req, action: 'page.protect', detail: { id, slug: page.slug } })
}
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
async function deletePage(req, res) {
try {
const id = Number(req.params.id)
const result = await pages.remove(id)
await activity.log({ req, action: 'page.delete', detail: { id } })
return res.json(result)
} catch (err) {
return fail(res, err)
}
}
// Step-up auth: verify the CURRENT admin's password against their own hash
// (independent of JWT validity) before flipping protected → false. On failure:
// no mutation, standard 401, and the entered password is never logged anywhere.
async function unprotectPage(req, res) {
try {
const id = Number(req.params.id)
const password = req.body?.password
if (typeof password !== 'string' || password === '') {
return res.status(400).json({ error: 'Password is required', code: 'password_required' })
}
const user = await users.getRawById(req.user.id)
const ok = await users.validatePassword(user, password)
if (!ok) {
logger.warn('failed page unprotect (bad password)', { pageId: id, userId: req.user.id })
return res.status(401).json({ error: 'Password is incorrect', code: 'bad_password' })
}
const page = await pages.unprotect(id)
await activity.log({ req, action: 'page.unprotect', detail: { id, slug: page.slug } })
return res.json(page)
} catch (err) {
return fail(res, err)
}
}
// Mint a 1h preview token for the page's current (possibly unpublished) state.
// Returns the token plus the ready-to-use public preview path.
async function createPreview(req, res) {
try {
const id = Number(req.params.id)
const page = await pages.getById(id)
if (!page) return res.status(404).json({ error: 'Page not found', code: 'not_found' })
const t = token.signPagePreview(id)
return res.json({
token: t,
expiresInSeconds: 3600,
path: `/api/v1/public/pages/${id}/preview/${t}`,
})
} catch (err) {
return fail(res, err)
}
}
module.exports = {
listPages,
getPage,
createPage,
updatePage,
deletePage,
unprotectPage,
createPreview,
}