Content Pages Module — Admin
HTTP surface for managing content (CMS) pages — operator-authored static pages addressable by slug. The content field stores raw HTML rendered verbatim by the storefront; pages form a tree via parentId.
HTTP surface for managing content (CMS) pages — operator-authored static pages (About, Return Policy, FAQ, landing pages, …) addressable by slug. The content field stores raw HTML typed by an admin and rendered verbatim by the storefront. Pages form a tree via a parentId self-reference, and the same module exposes unauthenticated storefront read endpoints.
Source:
api-modules/content/src/controllers/admin-content-page.controller.ts.HTML is stored as-typed with no server-side sanitisation — admins are trusted authors. The storefront read surface lives in
store/content.md.
Conventions
Authentication
All endpoints require a Better-Auth admin session and a role granting the matching content:* permission.
| Endpoint group | Permission |
|---|---|
GET /admin/content-pages, GET /admin/content-pages/:id | content: read |
POST /admin/content-pages, POST /admin/content-pages/:id/duplicate | content: create |
PUT /admin/content-pages/:id, POST /admin/content-pages/:id/restore | content: update |
DELETE /admin/content-pages/:id | content: delete |
Raw-HTML gate (content: manageRawHtml). Raw-HTML pages run author-controlled markup/scripts on the storefront, so a second permission gates them on top of the base grants above:
- Create with
template: "html"requirescontent: manageRawHtml(in addition tocontent: create);template: "default"does not. - Update rejects any change to
templateorcontentunless the caller holdscontent: manageRawHtml— re-sending the unchanged value is a no-op and allowed. This applies to every page, not only raw-HTML ones. - Duplicate of a subtree containing any raw-HTML page requires
content: manageRawHtml.
A caller lacking the permission gets 403. The admin UI mirrors this: the Raw HTML template option is hidden on create, and the template + content fields are disabled on edit.
Response envelope
Successful responses are wrapped by ResponseInterceptor:
{
"data": <payload>,
"message": "Success",
"statusCode": 200,
"metadata": { /* present on the paginated list only */ }
}The list endpoint is offset-paginated (metadata: { total, limit, offset, hasMore }) and returns summary rows (no HTML content body).
Error envelope
statusCode | errorCode examples |
|---|---|
| 400 | BAD_REQUEST, VALIDATION_ERROR (invalid parentId, cycle, self-parent) |
| 401 | UNAUTHORIZED |
| 403 | FORBIDDEN |
| 404 | NOT_FOUND |
| 409 | CONFLICT (slug in use; deleting a page with children; restore slug clash) |
| 500 | INTERNAL_SERVER_ERROR, DATABASE_ERROR |
Lifecycle
- Slug — kebab-case, auto-derived from
titlewhen omitted on create. Unique among live pages only (a soft-deleted page's slug is reusable). - publishedAt — stamped the first time a page becomes
PUBLISHED, then preserved across later status changes. - Soft delete — pages soft-delete via
deletedAt. Deleting a page that has live children returns 409; reassign or delete the children first.POST .../restorereverses the deletion, returning 409 if the slug has since been claimed by another live page. - Hierarchy —
parentIdforms a tree. Updates that would set a page as its own parent or move it beneath one of its own descendants return 400.
Domain types
ContentPage
type ContentPageStatus = "DRAFT" | "PUBLISHED";
type ContentPage = {
id: string;
title: string;
description: string | null; // page subtitle / short description (distinct from metaDescription)
slug: string;
content: string; // raw HTML, rendered verbatim by the storefront
status: ContentPageStatus;
template: "default" | "html"; // storefront render mode (default = prose, html = raw)
layout: string | null; // free-form layout hint (preserved from legacy CMS)
hideLowerMenu: boolean; // hide the storefront lower (category) nav on this page; default false
publishedAt: string | null; // ISO; set once on first publish
sortOrder: number;
parentId: string | null;
metaTitle: string | null;
metaDescription: string | null;
metaKeywords: string | null;
metaRobots: string | null; // defaults to "index, follow"
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null; // storage key (prefix with assets base URL)
ogType: string | null;
schema: Record<string, unknown> | null; // JSON-LD structured data
metadata: Record<string, unknown> | null;
createdAt: string; // ISO
updatedAt: string; // ISO
deletedAt: string | null;
};List rows (ContentPageSummary) carry every field above except content and schema.
Endpoints
GET /admin/content-pages — List pages — content: read
Offset-paginated summary list, ordered by sortOrder ASC by default.
Query
| Name | Type | Notes |
|---|---|---|
limit / offset | number? | pagination (default 100 / 0) |
status | "DRAFT" | "PUBLISHED"? | filter by status |
parentId | string? | parentId=<id> for children; parentId=null for top-level |
includeDeleted | boolean? | include soft-deleted pages (to restore them) |
searchValue | string? | substring match over title + slug |
sortBy / sortDirection | — | title / sortOrder / createdAt / updatedAt / publishedAt |
GET /admin/content-pages/:id — Get one — content: read
Returns the full ContentPage (incl. HTML content), or 404.
POST /admin/content-pages — Create — content: create (+ content: manageRawHtml when template: "html")
Body (CreateContentPage)
| Field | Type | Notes |
|---|---|---|
title | string | required |
content | string | required — raw HTML |
slug | string? | kebab-case; auto-derived from title when omitted |
description | string? | subtitle / short description |
status | "DRAFT" | "PUBLISHED" | default DRAFT |
template | "default" | "html" | storefront render mode; default default (prose). html = raw HTML as-authored |
layout | string? | free-form layout hint (preserved from the legacy CMS) |
hideLowerMenu | boolean? | hide the storefront lower (category) nav on this page; default false |
sortOrder | number? | default 0 |
parentId | string? | must reference a live page |
metaTitle / metaDescription / metaKeywords / metaRobots | string? | SEO; metaRobots defaults to "index, follow" |
ogTitle / ogDescription / ogImage / ogType | string? | Open Graph; ogImage is a storage key uploaded via the admin presigned flow |
schema | object? | JSON-LD structured data |
metadata | object? | forward-extensibility escape hatch |
Response 201 — the created ContentPage. 409 if the slug is already in use.
PUT /admin/content-pages/:id — Update — content: update (+ content: manageRawHtml to change template/content)
Partial update; all CreateContentPage fields optional. Setting status to PUBLISHED stamps publishedAt (once). Reparenting validates against cycles (400) and a missing/deleted parent (400). Changing slug to one held by another live page returns 409. Changing template or content without content: manageRawHtml returns 403 (re-sending the unchanged value is allowed).
DELETE /admin/content-pages/:id — Soft-delete — content: delete
Soft-deletes the page. 409 if the page has live children.
POST /admin/content-pages/:id/restore — Restore — content: update
Clears deletedAt. 404 if not a deleted page; 409 if the slug is now held by a live page.
POST /admin/content-pages/:id/duplicate — Duplicate — content: create (+ content: manageRawHtml if the subtree contains a raw-HTML page)
Deep-clones the page and its entire live subtree as DRAFTs. The new root's title is suffixed " (copy)"; every cloned slug is <slug>-copy (then -copy-2, -copy-3, … on collision). Returns 201 with the new subtree root.
Related modules
content(storefront) — public read surface for published pages. Seestore/content.md.admin-rbac— defines thecontentpermission set. Seeadmin/admin-rbac.md.
Catalog Module — Admin
HTTP surface for platform-admin reads of products + variants and full CRUD over the platform-wide taxonomy (brands, categories, tags, ingredients), with a vendor-request approval…
Customer Module — Admin
HTTP surface for the admin customer-management portal (list, inspect, ban/unban, set-role, delete, set-password, session revocation, and storefront impersonation of non-staff accounts) plus the customer picker and lifetime-stats recompute. The storefront address book lives in the same module but is documented in store/customer.md.